Files
Outpost/Vendor/swift-markdown-engine/Tests/MarkdownEngineTests/HeightBehaviorTests.swift
T
Puranjay Savar Mattas 6e1ffb96fd chore: vendor swift-markdown-engine as plain tracked source
Convert from git submodule to plain vendored copy. No push access to
upstream nodes-app/swift-markdown-engine meant our local fix commit
(05c1720) was stranded and unreachable from any remote on fresh
clones/CI. Vendoring as plain files folds it into normal repo history
instead.
2026-08-20 13:26:13 +01:00

815 lines
32 KiB
Swift

//
// HeightBehaviorTests.swift
// MarkdownEngineTests
//
// Configuration, inflation gating, overscroll zeroing, intrinsic content
// size, runtime switching, async re-report, readingWidth composition, and
// empty-document minimum height for the .fitsContent height behavior —
// headless (no window).
//
// Known coverage gap: `propagateCaretRevealToEnclosingScroller` (caret
// visibility in a tall .fitsContent block). This requires a live
// nested-scroll-view hierarchy (SwiftUI ScrollView hosting our
// NSScrollView) which cannot be constructed in a headless test.
// Verify manually in a host app.
//
import AppKit
import Testing
@testable import MarkdownEngine
// MARK: - Shared test stack
/// Scroll view + container + text view wired the way `makeNSView` wires them,
/// used by all HeightBehavior test suites.
@MainActor
struct HeightBehaviorStack {
let scrollView: ClampedScrollView
let container: NativeTextViewContainer
let textView: NativeTextView
init(
viewport: NSSize = NSSize(width: 600, height: 800),
heightBehavior: MarkdownEditorConfiguration.HeightBehavior = .scrolls
) {
let sv = ClampedScrollView(frame: NSRect(origin: .zero, size: viewport))
sv.fitsContent = heightBehavior == .fitsContent
let tv = NativeTextView(frame: .zero)
var config = MarkdownEditorConfiguration.default
config.heightBehavior = heightBehavior
tv.configuration = config
tv.overscrollPercent = config.overscroll.percent
tv.maxOverscrollPoints = config.overscroll.maxPoints
tv.minOverscrollPoints = config.overscroll.minPoints
tv.autoresizingMask = []
let c = NativeTextViewContainer(frame: NSRect(origin: .zero, size: viewport))
c.autoresizingMask = [.width]
c.textView = tv
tv.frame = NSRect(x: 0, y: 0, width: viewport.width, height: 0)
c.addSubview(tv)
sv.documentView = c
self.scrollView = sv
self.container = c
self.textView = tv
}
}
// MARK: - Configuration defaults
@Suite("HeightBehavior configuration")
struct HeightBehaviorDefaultTests {
@Test func defaultIsScrolls() {
let config = MarkdownEditorConfiguration.default
#expect(config.heightBehavior == .scrolls)
}
@Test func initWithFitsContent() {
let config = MarkdownEditorConfiguration(heightBehavior: .fitsContent)
#expect(config.heightBehavior == .fitsContent)
}
@Test func defaultInitExplicitlyScrolls() {
let config = MarkdownEditorConfiguration(heightBehavior: .scrolls)
#expect(config.heightBehavior == .scrolls)
}
}
// MARK: - Inflation gating
@MainActor
@Suite("FitsContent inflation gating")
struct FitsContentInflationTests {
@Test func scrollsInflatesShortDocToViewport() {
let stack = HeightBehaviorStack(heightBehavior: .scrolls)
stack.textView.baseContentHeight = 100
stack.textView.applyManagedFrameSize(width: 600)
// Existing behavior: short text view inflates to fill viewport.
#expect(stack.textView.frame.height == 800)
#expect(stack.container.frame.height == 800)
}
@Test func fitsContentNoInflation() {
let stack = HeightBehaviorStack(heightBehavior: .fitsContent)
stack.textView.baseContentHeight = 100
stack.textView.applyManagedFrameSize(width: 600)
// In .fitsContent, the text view is exactly content height, no inflation.
#expect(stack.textView.frame.height == 100)
// Container should also NOT inflate to viewport.
#expect(stack.container.frame.height == 100)
}
@Test func fitsContentRestackNoInflation() {
let stack = HeightBehaviorStack(heightBehavior: .fitsContent)
stack.textView.baseContentHeight = 200
stack.textView.applyManagedFrameSize(width: 600)
// With a header band, the container should still be exactly header + text.
stack.container.headerHeight = 40
let expectedTextHeight = stack.textView.frame.height
let expectedContainerHeight = 40 + expectedTextHeight
#expect(stack.container.frame.height == expectedContainerHeight)
}
@Test func scrollsRestackInflatesToViewport() {
let stack = HeightBehaviorStack(heightBehavior: .scrolls)
stack.textView.baseContentHeight = 100
stack.textView.applyManagedFrameSize(width: 600)
// The container inflates to viewport height.
#expect(stack.container.frame.height == 800)
// Adding a header still keeps the container at viewport height.
stack.container.headerHeight = 40
#expect(stack.container.frame.height == 800)
}
}
// MARK: - Overscroll zeroing
@MainActor
@Suite("FitsContent overscroll")
struct FitsContentOverscrollTests {
@Test func fitsContentOverscrollIsAlwaysZero() {
let stack = HeightBehaviorStack(heightBehavior: .fitsContent)
// Manually prime a tall content height and run the overscroll policy via
// reapplyOverscrollPolicy (which re-evaluates without re-measuring).
stack.textView.baseContentHeight = 1200
stack.textView.reapplyOverscrollPolicy(for: stack.scrollView)
#expect(stack.textView.activeBottomOverscroll == 0)
}
@Test func scrollsOverscrollIsNonZeroForTallContent() {
let stack = HeightBehaviorStack(heightBehavior: .scrolls)
// Manually prime a tall content height that exceeds the viewport.
stack.textView.baseContentHeight = 1200
stack.textView.reapplyOverscrollPolicy(for: stack.scrollView)
#expect(stack.textView.activeBottomOverscroll > 0)
}
@Test func fitsContentRecalcOverscrollStillMeasuresHeight() {
// Verify that recalcOverscroll still updates baseContentHeight in
// .fitsContent mode (critical: sizeThatFits / intrinsicContentSize
// report this value). The measuredBaseContentHeight in a headless
// text view returns the minimum (one line height), but the important
// assertion is that the call runs and assigns a value rather than
// short-circuiting the measurement.
let stack = HeightBehaviorStack(heightBehavior: .fitsContent)
stack.textView.baseContentHeight = 0
stack.textView.recalcOverscroll(for: stack.scrollView)
// baseContentHeight must be set to the measured value (> 0 for even
// an empty document in TextKit-2, which returns at least one line).
#expect(stack.textView.baseContentHeight > 0)
// Overscroll must still be zero in .fitsContent.
#expect(stack.textView.activeBottomOverscroll == 0)
}
}
// MARK: - ClampedScrollView intrinsic content size
@MainActor
@Suite("ClampedScrollView fitsContent")
struct ClampedScrollViewFitsContentTests {
@Test func intrinsicContentSizeReportsHeightWhenFitsContent() {
let stack = HeightBehaviorStack(heightBehavior: .fitsContent)
stack.textView.baseContentHeight = 350
stack.textView.applyManagedFrameSize(width: 600)
let intrinsic = stack.scrollView.intrinsicContentSize
#expect(intrinsic.width == NSView.noIntrinsicMetric)
#expect(intrinsic.height == stack.container.scrollableContentHeight)
}
@Test func intrinsicContentSizeDefaultsToNoMetricWhenScrolls() {
let stack = HeightBehaviorStack(heightBehavior: .scrolls)
stack.textView.baseContentHeight = 350
stack.textView.applyManagedFrameSize(width: 600)
let intrinsic = stack.scrollView.intrinsicContentSize
// Default NSScrollView returns noIntrinsicMetric for both dimensions.
#expect(intrinsic.width == NSView.noIntrinsicMetric)
#expect(intrinsic.height == NSView.noIntrinsicMetric)
}
@Test func intrinsicContentSizeIncludesHeaderHeight() {
let stack = HeightBehaviorStack(heightBehavior: .fitsContent)
stack.textView.baseContentHeight = 300
stack.textView.applyManagedFrameSize(width: 600)
stack.container.headerHeight = 50
let intrinsic = stack.scrollView.intrinsicContentSize
// scrollableContentHeight = headerHeight + textView.scrollableContentHeight
#expect(intrinsic.height == stack.container.scrollableContentHeight)
#expect(intrinsic.height == 50 + stack.textView.scrollableContentHeight)
}
}
// MARK: - Runtime heightBehavior switch
@MainActor
@Suite("Runtime heightBehavior switch")
struct RuntimeHeightBehaviorSwitchTests {
@Test func switchFromScrollsToFitsContentRemovesInflation() {
// Start in .scrolls — short content inflated to viewport.
let stack = HeightBehaviorStack(heightBehavior: .scrolls)
stack.textView.baseContentHeight = 100
stack.textView.applyManagedFrameSize(width: 600)
#expect(stack.textView.frame.height == 800)
#expect(stack.container.frame.height == 800)
// Switch to .fitsContent at runtime.
var newConfig = stack.textView.configuration
newConfig.heightBehavior = .fitsContent
stack.textView.configuration = newConfig
stack.scrollView.fitsContent = true
// Use reapplyOverscrollPolicy (not recalcOverscroll) to avoid
// TextKit-2 re-measuring the content height in a headless test,
// then applyManagedFrameSize to reconfigure the frame.
stack.textView.reapplyOverscrollPolicy(for: stack.scrollView)
stack.textView.applyManagedFrameSize(width: 600)
// Inflation removed: text view and container match content height.
#expect(stack.textView.frame.height == 100)
#expect(stack.container.frame.height == 100)
}
@Test func switchFromFitsContentToScrollsRestoresInflation() {
// Start in .fitsContent — exact content height.
let stack = HeightBehaviorStack(heightBehavior: .fitsContent)
stack.textView.baseContentHeight = 100
stack.textView.applyManagedFrameSize(width: 600)
#expect(stack.textView.frame.height == 100)
// Switch to .scrolls at runtime.
var newConfig = stack.textView.configuration
newConfig.heightBehavior = .scrolls
stack.textView.configuration = newConfig
stack.scrollView.fitsContent = false
stack.textView.recalcOverscroll(for: stack.scrollView)
// Inflation restored: text view fills the viewport.
#expect(stack.textView.frame.height == 800)
#expect(stack.container.frame.height == 800)
}
@Test func switchToFitsContentZerosOverscroll() {
// Start in .scrolls with tall content that has overscroll.
let stack = HeightBehaviorStack(heightBehavior: .scrolls)
stack.textView.baseContentHeight = 1200
stack.textView.reapplyOverscrollPolicy(for: stack.scrollView)
#expect(stack.textView.activeBottomOverscroll > 0)
// Switch to .fitsContent.
var newConfig = stack.textView.configuration
newConfig.heightBehavior = .fitsContent
stack.textView.configuration = newConfig
stack.scrollView.fitsContent = true
stack.textView.reapplyOverscrollPolicy(for: stack.scrollView)
#expect(stack.textView.activeBottomOverscroll == 0)
}
@Test func switchToScrollsRestoresOverscroll() {
// Start in .fitsContent — overscroll is zero.
let stack = HeightBehaviorStack(heightBehavior: .fitsContent)
stack.textView.baseContentHeight = 1200
stack.textView.reapplyOverscrollPolicy(for: stack.scrollView)
#expect(stack.textView.activeBottomOverscroll == 0)
// Switch to .scrolls.
var newConfig = stack.textView.configuration
newConfig.heightBehavior = .scrolls
stack.textView.configuration = newConfig
stack.scrollView.fitsContent = false
stack.textView.reapplyOverscrollPolicy(for: stack.scrollView)
#expect(stack.textView.activeBottomOverscroll > 0)
}
@Test func switchReconfiguresIntrinsicContentSize() {
let stack = HeightBehaviorStack(heightBehavior: .scrolls)
stack.textView.baseContentHeight = 400
stack.textView.applyManagedFrameSize(width: 600)
// In .scrolls, intrinsicContentSize.height is noIntrinsicMetric.
#expect(stack.scrollView.intrinsicContentSize.height == NSView.noIntrinsicMetric)
// Switch to .fitsContent.
var newConfig = stack.textView.configuration
newConfig.heightBehavior = .fitsContent
stack.textView.configuration = newConfig
stack.scrollView.fitsContent = true
stack.textView.recalcOverscroll(for: stack.scrollView)
// Now intrinsicContentSize should report actual height.
#expect(stack.scrollView.intrinsicContentSize.height == stack.container.scrollableContentHeight)
}
}
// MARK: - Reading width + fitsContent
@MainActor
@Suite("ReadingWidth + fitsContent")
struct ReadingWidthFitsContentTests {
@Test func readingWidthPreservedNoInflation() {
let viewport = NSSize(width: 800, height: 600)
let sv = ClampedScrollView(frame: NSRect(origin: .zero, size: viewport))
sv.fitsContent = true
let tv = NativeTextView(frame: .zero)
var config = MarkdownEditorConfiguration.default
config.heightBehavior = .fitsContent
config.readingWidth = 400
tv.configuration = config
tv.autoresizingMask = []
let c = NativeTextViewContainer(frame: NSRect(origin: .zero, size: viewport))
c.autoresizingMask = [.width]
c.textView = tv
let columnWidth = tv.readingColumnWidth
tv.frame = NSRect(x: 0, y: 0, width: columnWidth, height: 0)
c.addSubview(tv)
sv.documentView = c
tv.baseContentHeight = 200
tv.applyManagedFrameSize(width: columnWidth)
// Column keeps its fixed width.
#expect(tv.frame.width == columnWidth)
// Height is exact content (no viewport inflation).
#expect(tv.frame.height == 200)
// Container is also content-tall, not viewport-inflated.
#expect(c.frame.height == 200)
}
}
// MARK: - Empty-document minimum height
@MainActor
@Suite("FitsContent empty-document minimum height")
struct FitsContentEmptyDocMinHeightTests {
@Test func emptyDocHasPositiveHeight() {
let stack = HeightBehaviorStack(heightBehavior: .fitsContent)
// A fresh text view with no text inserted — recalcOverscroll measures
// the TextKit-2 content height, which returns at least one line height.
stack.textView.recalcOverscroll(for: stack.scrollView)
// baseContentHeight must be positive (at least one body line).
#expect(stack.textView.baseContentHeight > 0)
// The frame must reflect that minimum.
#expect(stack.textView.frame.height > 0)
// scrollableContentHeight = baseContentHeight (no overscroll).
#expect(stack.textView.scrollableContentHeight == stack.textView.baseContentHeight)
}
}
// MARK: - Header + fitsContent height composition
@MainActor
@Suite("Header + fitsContent height")
struct HeaderFitsContentTests {
@Test func staticHeaderIncludedInScrollableContentHeight() {
let stack = HeightBehaviorStack(heightBehavior: .fitsContent)
stack.textView.baseContentHeight = 300
stack.textView.applyManagedFrameSize(width: 600)
stack.container.headerHeight = 60
// scrollableContentHeight = header + text view's scrollableContentHeight
let expectedTotal: CGFloat = 60 + 300
#expect(stack.container.scrollableContentHeight == expectedTotal)
// Container frame matches exactly (no viewport inflation).
#expect(stack.container.frame.height == expectedTotal)
// Text view sits below the header.
#expect(stack.textView.frame.origin.y == 60)
}
@Test func headerChangeReReportsIntrinsicSize() {
let stack = HeightBehaviorStack(heightBehavior: .fitsContent)
stack.textView.baseContentHeight = 300
stack.textView.applyManagedFrameSize(width: 600)
let beforeHeader = stack.scrollView.intrinsicContentSize.height
stack.container.headerHeight = 80
let afterHeader = stack.scrollView.intrinsicContentSize.height
// Adding a header should increase the reported height by the header band.
#expect(afterHeader == beforeHeader + 80)
}
}
// MARK: - Scroll-wheel forwarding
@MainActor
@Suite("FitsContent scroll-wheel forwarding")
struct ScrollWheelForwardingTests {
/// Minimal NSView that records whether it received a scrollWheel event.
final class ScrollWheelSpy: NSView {
var receivedScrollWheel = false
override func scrollWheel(with event: NSEvent) {
receivedScrollWheel = true
}
}
@Test func fitsContentForwardsScrollWheelToNextResponder() {
let stack = HeightBehaviorStack(heightBehavior: .fitsContent)
let spy = ScrollWheelSpy(frame: .zero)
// Wire the spy as the scroll view's nextResponder.
stack.scrollView.nextResponder = spy
// Create a synthetic scroll event. CGEvent is used because
// NSEvent(scrollWheel:) does not exist as a public initializer.
guard let cgEvent = CGEvent(
scrollWheelEvent2Source: nil,
units: .pixel,
wheelCount: 1,
wheel1: 10,
wheel2: 0,
wheel3: 0
) else { return }
guard let nsEvent = NSEvent(cgEvent: cgEvent) else { return }
stack.scrollView.scrollWheel(with: nsEvent)
#expect(spy.receivedScrollWheel == true)
}
@Test func scrollsDoesNotForwardScrollWheel() {
let stack = HeightBehaviorStack(heightBehavior: .scrolls)
let spy = ScrollWheelSpy(frame: .zero)
stack.scrollView.nextResponder = spy
guard let cgEvent = CGEvent(
scrollWheelEvent2Source: nil,
units: .pixel,
wheelCount: 1,
wheel1: 10,
wheel2: 0,
wheel3: 0
) else { return }
guard let nsEvent = NSEvent(cgEvent: cgEvent) else { return }
stack.scrollView.scrollWheel(with: nsEvent)
// In .scrolls mode, scrollWheel is handled by super (NSScrollView),
// not forwarded to nextResponder.
#expect(spy.receivedScrollWheel == false)
}
}
// MARK: - Async height-change re-report
@MainActor
@Suite("FitsContent async height-change re-report")
struct AsyncHeightChangeTests {
@Test func contentHeightChangeUpdatesFrame() {
// Simulate an async content height change (e.g. image/LaTeX finishing layout)
// by mutating baseContentHeight and calling applyManagedFrameSize.
let stack = HeightBehaviorStack(heightBehavior: .fitsContent)
stack.textView.baseContentHeight = 200
stack.textView.applyManagedFrameSize(width: 600)
#expect(stack.textView.frame.height == 200)
#expect(stack.container.frame.height == 200)
// Async height change: content grows.
stack.textView.baseContentHeight = 450
stack.textView.applyManagedFrameSize(width: 600)
#expect(stack.textView.frame.height == 450)
#expect(stack.container.frame.height == 450)
// intrinsicContentSize reflects the new height.
#expect(stack.scrollView.intrinsicContentSize.height == 450)
}
@Test func contentHeightChangePreservesExactHeight() {
// After an async height change, no viewport inflation occurs.
let stack = HeightBehaviorStack(
viewport: NSSize(width: 600, height: 800),
heightBehavior: .fitsContent
)
stack.textView.baseContentHeight = 300
stack.textView.applyManagedFrameSize(width: 600)
// Grow past the viewport: still exact content height, not clamped.
stack.textView.baseContentHeight = 1200
stack.textView.applyManagedFrameSize(width: 600)
#expect(stack.textView.frame.height == 1200)
#expect(stack.container.frame.height == 1200)
}
@Test func heightShrinkUpdatesFrame() {
// Content can also shrink (e.g. image removed, text deleted).
let stack = HeightBehaviorStack(heightBehavior: .fitsContent)
stack.textView.baseContentHeight = 600
stack.textView.applyManagedFrameSize(width: 600)
#expect(stack.textView.frame.height == 600)
stack.textView.baseContentHeight = 150
stack.textView.applyManagedFrameSize(width: 600)
#expect(stack.textView.frame.height == 150)
#expect(stack.container.frame.height == 150)
#expect(stack.scrollView.intrinsicContentSize.height == 150)
}
@Test func heightChangeInScrollsModeInflates() {
// Contrast: in .scrolls mode, short content inflates to viewport.
let stack = HeightBehaviorStack(
viewport: NSSize(width: 600, height: 800),
heightBehavior: .scrolls
)
stack.textView.baseContentHeight = 200
stack.textView.applyManagedFrameSize(width: 600)
#expect(stack.textView.frame.height == 800)
// Growing past viewport: frame matches content.
stack.textView.baseContentHeight = 1200
stack.textView.applyManagedFrameSize(width: 600)
#expect(stack.textView.frame.height == 1200)
// Shrinking below viewport: inflates back to viewport.
stack.textView.baseContentHeight = 300
stack.textView.applyManagedFrameSize(width: 600)
#expect(stack.textView.frame.height == 800)
}
@Test func asyncChangeWithHeaderUpdatesTotal() {
// Async height change with a header band present.
let stack = HeightBehaviorStack(heightBehavior: .fitsContent)
stack.textView.baseContentHeight = 200
stack.textView.applyManagedFrameSize(width: 600)
stack.container.headerHeight = 60
// Simulate async growth.
stack.textView.baseContentHeight = 400
stack.textView.applyManagedFrameSize(width: 600)
let expectedTotal: CGFloat = 60 + 400
#expect(stack.textView.frame.height == 400)
#expect(stack.container.frame.height == expectedTotal)
#expect(stack.scrollView.intrinsicContentSize.height == expectedTotal)
}
}
// MARK: - Per-keystroke recalc chain
@MainActor
@Suite("FitsContent per-keystroke recalc chain")
struct FitsContentRecalcChainTests {
@Test func recalcUpdatesFrameAndContainer() {
// The per-keystroke chain: recalcOverscroll measures content,
// calls applyManagedFrameSize, which sizes the frame and
// triggers container restack.
let stack = HeightBehaviorStack(heightBehavior: .fitsContent)
// Prime with an initial recalc (empty doc → one line height).
stack.textView.recalcOverscroll(for: stack.scrollView)
let initialHeight = stack.textView.frame.height
#expect(initialHeight > 0)
// The container matches the text view (no header, no inflation).
#expect(stack.container.frame.height == initialHeight)
// Overscroll stays zero.
#expect(stack.textView.activeBottomOverscroll == 0)
}
@Test func recalcAfterHeightChangeUpdatesIntrinsicSize() {
// After a height change driven through recalcOverscroll, the
// scroll view's intrinsicContentSize must reflect the new value.
let stack = HeightBehaviorStack(heightBehavior: .fitsContent)
stack.textView.recalcOverscroll(for: stack.scrollView)
let initial = stack.scrollView.intrinsicContentSize.height
// Manually set a taller base and re-run the recalc chain.
stack.textView.baseContentHeight = 500
stack.textView.applyManagedFrameSize(width: 600)
let updated = stack.scrollView.intrinsicContentSize.height
#expect(updated == 500)
#expect(updated > initial)
}
}
// MARK: - Height change after runtime switch
@MainActor
@Suite("Height change after runtime switch")
struct HeightChangeAfterRuntimeSwitchTests {
@Test func heightChangeAfterSwitchToFitsContent() {
// Start in .scrolls, switch to .fitsContent, then change height.
let stack = HeightBehaviorStack(
viewport: NSSize(width: 600, height: 800),
heightBehavior: .scrolls
)
stack.textView.baseContentHeight = 200
stack.textView.applyManagedFrameSize(width: 600)
#expect(stack.textView.frame.height == 800) // inflated
// Switch to .fitsContent.
var config = stack.textView.configuration
config.heightBehavior = .fitsContent
stack.textView.configuration = config
stack.scrollView.fitsContent = true
stack.textView.reapplyOverscrollPolicy(for: stack.scrollView)
stack.textView.applyManagedFrameSize(width: 600)
#expect(stack.textView.frame.height == 200) // deflated
// Now simulate an async height change.
stack.textView.baseContentHeight = 350
stack.textView.applyManagedFrameSize(width: 600)
// Must be exact content height, no inflation.
#expect(stack.textView.frame.height == 350)
#expect(stack.container.frame.height == 350)
#expect(stack.scrollView.intrinsicContentSize.height == 350)
}
@Test func heightChangeAfterSwitchToScrolls() {
// Start in .fitsContent, switch to .scrolls, then change height.
let stack = HeightBehaviorStack(
viewport: NSSize(width: 600, height: 800),
heightBehavior: .fitsContent
)
stack.textView.baseContentHeight = 200
stack.textView.applyManagedFrameSize(width: 600)
#expect(stack.textView.frame.height == 200)
// Switch to .scrolls.
var config = stack.textView.configuration
config.heightBehavior = .scrolls
stack.textView.configuration = config
stack.scrollView.fitsContent = false
stack.textView.recalcOverscroll(for: stack.scrollView)
#expect(stack.textView.frame.height == 800) // inflated
// Async height change: still below viewport → stays inflated.
stack.textView.baseContentHeight = 400
stack.textView.applyManagedFrameSize(width: 600)
#expect(stack.textView.frame.height == 800)
// intrinsicContentSize should revert to noIntrinsicMetric.
#expect(stack.scrollView.intrinsicContentSize.height == NSView.noIntrinsicMetric)
}
}
// MARK: - Runtime switch scroller visibility
@MainActor
@Suite("Runtime switch scroller visibility")
struct RuntimeSwitchScrollerVisibilityTests {
// All tests call the shared production function
// `HeightBehavior.wantsVerticalScroller(for:)` — the same function
// used by `makeNSView` and `updateNSView` to set
// `scrollView.hasVerticalScroller`. No duplicated logic.
@Test func fitsContentAlwaysDisablesVerticalScroller() {
// Even when the scrollers policy has vertical scroller = true,
// .fitsContent overrides it to false (nothing to scroll).
let policy = ScrollersPolicy(hasVerticalScroller: true)
let result = MarkdownEditorConfiguration.HeightBehavior.fitsContent
.wantsVerticalScroller(for: policy)
#expect(result == false)
}
@Test func scrollsRespectsScrollersPolicy() {
// In .scrolls, the scroller follows the policy.
let policyOn = ScrollersPolicy(hasVerticalScroller: true)
#expect(
MarkdownEditorConfiguration.HeightBehavior.scrolls
.wantsVerticalScroller(for: policyOn) == true
)
let policyOff = ScrollersPolicy(hasVerticalScroller: false)
#expect(
MarkdownEditorConfiguration.HeightBehavior.scrolls
.wantsVerticalScroller(for: policyOff) == false
)
}
}
// MARK: - Full runtime reconfiguration chain
@MainActor
@Suite("Full runtime reconfiguration")
struct FullRuntimeReconfigurationTests {
@Test func switchReconfiguresAllThreeLayers() {
// Verify that a runtime switch syncs the heightBehavior across
// scroll view (fitsContent flag), text view (configuration),
// and that intrinsicContentSize reflects the new mode.
let stack = HeightBehaviorStack(
viewport: NSSize(width: 600, height: 800),
heightBehavior: .scrolls
)
stack.textView.baseContentHeight = 300
stack.textView.applyManagedFrameSize(width: 600)
// Verify initial state: .scrolls.
#expect(stack.scrollView.fitsContent == false)
#expect(stack.textView.configuration.heightBehavior == .scrolls)
#expect(stack.scrollView.intrinsicContentSize.height == NSView.noIntrinsicMetric)
// Switch to .fitsContent, mimicking updateNSView.
var newConfig = stack.textView.configuration
newConfig.heightBehavior = .fitsContent
stack.textView.configuration = newConfig
stack.scrollView.fitsContent = true
stack.textView.reapplyOverscrollPolicy(for: stack.scrollView)
stack.textView.applyManagedFrameSize(width: 600)
stack.scrollView.invalidateIntrinsicContentSize()
// All three layers should reflect .fitsContent.
#expect(stack.scrollView.fitsContent == true)
#expect(stack.textView.configuration.heightBehavior == .fitsContent)
#expect(stack.textView.activeBottomOverscroll == 0)
#expect(stack.textView.frame.height == 300)
#expect(stack.container.frame.height == 300)
#expect(stack.scrollView.intrinsicContentSize.height == 300)
}
@Test func switchPreservesContentHeight() {
// Content height should survive a round-trip switch:
// .scrolls → .fitsContent → .scrolls
// Uses reapplyOverscrollPolicy (not recalcOverscroll) to avoid
// TextKit-2 re-measuring in a headless test, which would overwrite
// the primed baseContentHeight.
//
// Use a short content (100pt) that stays below the overscroll
// activation threshold so the .scrolls frame inflates to exactly
// the viewport height with no added overscroll.
let stack = HeightBehaviorStack(
viewport: NSSize(width: 600, height: 800),
heightBehavior: .scrolls
)
stack.textView.baseContentHeight = 100
stack.textView.applyManagedFrameSize(width: 600)
let originalBaseHeight = stack.textView.baseContentHeight
// → .fitsContent
var fc = stack.textView.configuration
fc.heightBehavior = .fitsContent
stack.textView.configuration = fc
stack.scrollView.fitsContent = true
stack.textView.reapplyOverscrollPolicy(for: stack.scrollView)
stack.textView.applyManagedFrameSize(width: 600)
#expect(stack.textView.baseContentHeight == originalBaseHeight)
#expect(stack.textView.frame.height == 100)
// → back to .scrolls
var sc = stack.textView.configuration
sc.heightBehavior = .scrolls
stack.textView.configuration = sc
stack.scrollView.fitsContent = false
stack.textView.reapplyOverscrollPolicy(for: stack.scrollView)
stack.textView.applyManagedFrameSize(width: 600)
#expect(stack.textView.baseContentHeight == originalBaseHeight)
// Frame is inflated to viewport since content (100) < viewport (800)
// and content is below the overscroll activation threshold.
#expect(stack.textView.frame.height == 800)
}
@Test func switchWithHeaderPreservesTotal() {
// With a header band, switching should preserve the total
// scrollableContentHeight composition.
let stack = HeightBehaviorStack(
viewport: NSSize(width: 600, height: 800),
heightBehavior: .fitsContent
)
stack.textView.baseContentHeight = 250
stack.textView.applyManagedFrameSize(width: 600)
stack.container.headerHeight = 50
let expectedTotal: CGFloat = 50 + 250
#expect(stack.container.scrollableContentHeight == expectedTotal)
// Switch to .scrolls.
var sc = stack.textView.configuration
sc.heightBehavior = .scrolls
stack.textView.configuration = sc
stack.scrollView.fitsContent = false
stack.textView.reapplyOverscrollPolicy(for: stack.scrollView)
stack.textView.applyManagedFrameSize(width: 600)
// scrollableContentHeight still composes header + content.
// (The container inflates to viewport, but scrollableContentHeight
// is the real content height, used by clampToInsets.)
let scrollsTotal: CGFloat = 50 + stack.textView.scrollableContentHeight
#expect(stack.container.scrollableContentHeight == scrollsTotal)
}
}