mirror of
https://github.com/phranck/TUIkit.git
synced 2026-05-21 09:50:35 +00:00
3fb4944472
- Add ServiceEnvironment.swift with 9 EnvironmentKeys for runtime services (stateStorage, lifecycle, keyEventDispatcher, renderCache, preferenceStorage, pulsePhase, cursorTimer, focusIndicatorColor, activeFocusSectionID) - Remove tuiContext, pulsePhase, cursorTimer, focusIndicatorColor, and activeFocusSectionID as direct RenderContext properties - Inject all services through EnvironmentValues in RenderLoop.buildEnvironment() - Add convenience RenderContext init that accepts TUIContext and auto-injects services into the environment - Simplify isolatedForBackground() to only swap environment values - Migrate ~49 access sites in ~25 source files from context.tuiContext.X and context.pulsePhase/cursorTimer to context.environment.X - Update 38 test files to use the new convenience init
206 lines
6.6 KiB
Swift
206 lines
6.6 KiB
Swift
// 🖥️ TUIKit — Terminal UI Kit for Swift
|
||
// EquatableViewTests.swift
|
||
//
|
||
// Created by LAYERED.work
|
||
// License: MIT
|
||
|
||
import Testing
|
||
|
||
@testable import TUIkit
|
||
|
||
/// A minimal equatable view for testing memoization behavior.
|
||
private struct LabelView: View, Equatable {
|
||
let text: String
|
||
|
||
var body: some View {
|
||
Text(text)
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
@Suite("EquatableView Tests", .serialized)
|
||
struct EquatableViewTests {
|
||
|
||
/// Creates a test context with a fresh environment including render cache.
|
||
private func testContext(
|
||
width: Int = 80,
|
||
height: Int = 24,
|
||
identity: ViewIdentity = ViewIdentity(path: "Root")
|
||
) -> RenderContext {
|
||
let tuiContext = TUIContext()
|
||
var env = EnvironmentValues()
|
||
env.stateStorage = tuiContext.stateStorage
|
||
env.lifecycle = tuiContext.lifecycle
|
||
env.keyEventDispatcher = tuiContext.keyEventDispatcher
|
||
env.renderCache = tuiContext.renderCache
|
||
env.preferenceStorage = tuiContext.preferences
|
||
return RenderContext(
|
||
availableWidth: width,
|
||
availableHeight: height,
|
||
environment: env,
|
||
identity: identity
|
||
)
|
||
}
|
||
|
||
// MARK: - First Render (Cache Miss)
|
||
|
||
@Test("First render produces correct output and populates cache")
|
||
func firstRenderPopulatesCache() {
|
||
let context = testContext()
|
||
let view = EquatableView(content: LabelView(text: "Hello"))
|
||
|
||
let buffer = renderToBuffer(view, context: context)
|
||
|
||
#expect(buffer.lines[0].stripped == "Hello")
|
||
#expect(context.environment.renderCache!.count == 1)
|
||
}
|
||
|
||
// MARK: - Cache Hit
|
||
|
||
@Test("Second render with equal content returns cached buffer")
|
||
func cacheHitOnEqualContent() {
|
||
let context = testContext()
|
||
|
||
// First render
|
||
let view1 = EquatableView(content: LabelView(text: "Static"))
|
||
let buffer1 = renderToBuffer(view1, context: context)
|
||
|
||
// Second render with equal view
|
||
let view2 = EquatableView(content: LabelView(text: "Static"))
|
||
let buffer2 = renderToBuffer(view2, context: context)
|
||
|
||
#expect(buffer1.lines == buffer2.lines)
|
||
#expect(context.environment.renderCache!.count == 1)
|
||
}
|
||
|
||
// MARK: - Cache Miss on Changed Content
|
||
|
||
@Test("Changed content causes cache miss and re-render")
|
||
func cacheMissOnChangedContent() {
|
||
let context = testContext()
|
||
|
||
// First render
|
||
let view1 = EquatableView(content: LabelView(text: "Before"))
|
||
let buffer1 = renderToBuffer(view1, context: context)
|
||
|
||
// Second render with different content
|
||
let view2 = EquatableView(content: LabelView(text: "After"))
|
||
let buffer2 = renderToBuffer(view2, context: context)
|
||
|
||
#expect(buffer1.lines != buffer2.lines)
|
||
#expect(buffer2.lines[0].stripped == "After")
|
||
}
|
||
|
||
// MARK: - Cache Miss on Size Change
|
||
|
||
@Test("Changed context size causes cache miss")
|
||
func cacheMissOnSizeChange() {
|
||
let tuiContext = TUIContext()
|
||
let identity = ViewIdentity(path: "Root")
|
||
|
||
var env = EnvironmentValues()
|
||
env.stateStorage = tuiContext.stateStorage
|
||
env.lifecycle = tuiContext.lifecycle
|
||
env.keyEventDispatcher = tuiContext.keyEventDispatcher
|
||
env.renderCache = tuiContext.renderCache
|
||
env.preferenceStorage = tuiContext.preferences
|
||
|
||
// First render at 80x24
|
||
let context1 = RenderContext(
|
||
availableWidth: 80,
|
||
availableHeight: 24,
|
||
environment: env,
|
||
identity: identity
|
||
)
|
||
let view = EquatableView(content: LabelView(text: "Size"))
|
||
_ = renderToBuffer(view, context: context1)
|
||
|
||
// Second render at 120x40 -- should miss
|
||
let context2 = RenderContext(
|
||
availableWidth: 120,
|
||
availableHeight: 40,
|
||
environment: env,
|
||
identity: identity
|
||
)
|
||
let buffer2 = renderToBuffer(view, context: context2)
|
||
|
||
#expect(buffer2.lines[0].stripped == "Size")
|
||
// Cache entry was overwritten with new size
|
||
#expect(tuiContext.renderCache.count == 1)
|
||
}
|
||
|
||
// MARK: - Cache Invalidation on State Change
|
||
|
||
@Test("clearAll empties the cache (simulates state-change invalidation)")
|
||
func clearAllEmptiesCache() {
|
||
let cache = RenderCache()
|
||
|
||
cache.store(
|
||
identity: ViewIdentity(path: "Root/A"),
|
||
view: "value",
|
||
buffer: FrameBuffer(text: "cached"),
|
||
contextWidth: 80,
|
||
contextHeight: 24
|
||
)
|
||
#expect(cache.count == 1)
|
||
|
||
// StateBox.didSet calls renderCache.clearAll() — test the effect directly
|
||
cache.clearAll()
|
||
|
||
#expect(cache.isEmpty)
|
||
}
|
||
|
||
// MARK: - .equatable() Modifier
|
||
|
||
@Test("equatable() modifier wraps view in EquatableView")
|
||
func equatableModifierCreatesWrapper() {
|
||
let label = LabelView(text: "Test")
|
||
let wrapped = label.equatable()
|
||
|
||
// Verify the wrapper produces correct output
|
||
let context = testContext()
|
||
let buffer = renderToBuffer(wrapped, context: context)
|
||
#expect(buffer.lines[0].stripped == "Test")
|
||
}
|
||
|
||
// MARK: - Integration with VStack
|
||
|
||
@Test("EquatableView inside VStack renders correctly")
|
||
func equatableViewInVStack() {
|
||
let context = testContext()
|
||
|
||
let stack = VStack {
|
||
EquatableView(content: LabelView(text: "Top"))
|
||
Text("Bottom")
|
||
}
|
||
|
||
let buffer = renderToBuffer(stack, context: context)
|
||
#expect(buffer.height == 2)
|
||
// VStack with default .center alignment centers shorter children
|
||
#expect(buffer.lines[0].contains("Top"))
|
||
#expect(buffer.lines[1].contains("Bottom"))
|
||
}
|
||
|
||
// MARK: - GC Integration
|
||
|
||
@Test("Cache entries for removed views are garbage collected")
|
||
func cacheGarbageCollection() {
|
||
let cache = RenderCache()
|
||
let activeId = ViewIdentity(path: "Root/Active")
|
||
let removedId = ViewIdentity(path: "Root/Removed")
|
||
|
||
cache.store(identity: activeId, view: "a", buffer: FrameBuffer(text: "a"), contextWidth: 80, contextHeight: 24)
|
||
cache.store(identity: removedId, view: "r", buffer: FrameBuffer(text: "r"), contextWidth: 80, contextHeight: 24)
|
||
#expect(cache.count == 2)
|
||
|
||
// Simulate render pass where only activeId is visited
|
||
cache.beginRenderPass()
|
||
cache.markActive(activeId)
|
||
cache.removeInactive()
|
||
|
||
#expect(cache.count == 1)
|
||
#expect(cache.lookup(identity: activeId, view: "a", contextWidth: 80, contextHeight: 24) != nil)
|
||
#expect(cache.lookup(identity: removedId, view: "r", contextWidth: 80, contextHeight: 24) == nil)
|
||
}
|
||
}
|