314 lines
11 KiB
Swift
314 lines
11 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
import Testing
|
|
|
|
@testable import Termind
|
|
|
|
@Suite
|
|
struct TranscriptListUpdatePlanTests {
|
|
@Test
|
|
func firstSnapshotUsesApply() {
|
|
let plan = TranscriptListUpdatePlan.make(old: [], new: [item(1)])
|
|
|
|
#expect(plan.replacesAll)
|
|
#expect(plan.updates.isEmpty)
|
|
#expect(plan.appends.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func pureTailGrowthUsesAppendWithoutRevisitingExistingRows() {
|
|
let first = item(1)
|
|
let second = item(2)
|
|
let plan = TranscriptListUpdatePlan.make(old: [first], new: [first, second])
|
|
|
|
#expect(!plan.replacesAll)
|
|
#expect(plan.updates.isEmpty)
|
|
#expect(plan.appends == [second])
|
|
}
|
|
|
|
@Test
|
|
func aStreamingRevisionUpdatesOnlyItsStableRow() {
|
|
let id = uuid(1)
|
|
let old = TranscriptRenderItem(id: id, content: .empty)
|
|
let new = TranscriptRenderItem(id: id, content: .interrupted)
|
|
let untouched = item(2)
|
|
let plan = TranscriptListUpdatePlan.make(
|
|
old: [old, untouched], new: [new, untouched]
|
|
)
|
|
|
|
#expect(!plan.replacesAll)
|
|
#expect(plan.updates == [new])
|
|
#expect(plan.appends.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func removalOrReorderingFallsBackToDiffedApply() {
|
|
let first = item(1)
|
|
let second = item(2)
|
|
|
|
#expect(TranscriptListUpdatePlan.make(
|
|
old: [first, second], new: [second, first]
|
|
).replacesAll)
|
|
#expect(TranscriptListUpdatePlan.make(
|
|
old: [first, second], new: [first]
|
|
).replacesAll)
|
|
}
|
|
|
|
@Test
|
|
func appendingMarksOnlyTheNewRowForLeadingSpacing() {
|
|
let firstTimeline = TimelineItem.message(ChatMessage(
|
|
id: uuid(1), role: .assistant, text: "first"
|
|
))
|
|
let secondTimeline = TimelineItem.message(ChatMessage(
|
|
id: uuid(2), role: .assistant, text: "second"
|
|
))
|
|
|
|
let old = TranscriptRenderItem.items(
|
|
timeline: [firstTimeline], isInterrupted: false
|
|
)
|
|
let new = TranscriptRenderItem.items(
|
|
timeline: [firstTimeline, secondTimeline], isInterrupted: false
|
|
)
|
|
let plan = TranscriptListUpdatePlan.make(old: old, new: new)
|
|
|
|
#expect(!old[0].hasLeadingSpacing)
|
|
#expect(!new[0].hasLeadingSpacing)
|
|
#expect(new[1].hasLeadingSpacing)
|
|
#expect(plan.updates.isEmpty)
|
|
#expect(plan.appends == [new[1]])
|
|
}
|
|
|
|
private func item(_ value: UInt8) -> TranscriptRenderItem {
|
|
TranscriptRenderItem(id: uuid(value), content: .empty)
|
|
}
|
|
|
|
private func uuid(_ value: UInt8) -> UUID {
|
|
let bytes = uuid_t(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, value)
|
|
return UUID(uuid: bytes)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
@Suite
|
|
struct TranscriptPresentationStateTests {
|
|
@Test
|
|
func aReaderToggleSurvivesDefaultStateTransitions() {
|
|
let id = UUID()
|
|
let state = TranscriptPresentationState()
|
|
|
|
#expect(state.isExpanded(id, default: true))
|
|
state.toggleDisclosure(id, default: true)
|
|
#expect(!state.isExpanded(id, default: true))
|
|
#expect(state.readerDidToggle(id))
|
|
|
|
state.followDefault(true, for: id)
|
|
#expect(!state.isExpanded(id, default: true))
|
|
}
|
|
|
|
@Test
|
|
func untouchedDisclosureFollowsItsRuntimeDefault() {
|
|
let id = UUID()
|
|
let state = TranscriptPresentationState()
|
|
|
|
state.followDefault(true, for: id)
|
|
#expect(state.isExpanded(id))
|
|
#expect(!state.readerDidToggle(id))
|
|
}
|
|
|
|
@Test
|
|
func messageDraftSurvivesRowRecyclingAndCanBePruned() {
|
|
let id = UUID()
|
|
let state = TranscriptPresentationState()
|
|
|
|
state.beginEditingMessage(id, text: "draft")
|
|
state.setMessageEditText("updated", for: id)
|
|
state.setMessageBubbleWidth(240, for: id)
|
|
|
|
#expect(state.isEditingMessage(id))
|
|
#expect(state.messageEditText(id) == "updated")
|
|
#expect(state.messageBubbleWidth(id) == 240)
|
|
|
|
state.prune(validTimelineIDs: [])
|
|
#expect(!state.isEditingMessage(id))
|
|
#expect(state.messageEditText(id).isEmpty)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
@Suite
|
|
struct TranscriptListViewBridgeTests {
|
|
@Test
|
|
func installsTheSubtleBouncyAnimator() throws {
|
|
let listView = TranscriptListView.Coordinator().makeListView()
|
|
let animator = try #require(listView.rowAnimator)
|
|
let values = Dictionary(uniqueKeysWithValues:
|
|
Mirror(reflecting: animator).children.compactMap { child in
|
|
child.label.map { ($0, child.value) }
|
|
}
|
|
)
|
|
|
|
#expect(String(describing: type(of: animator)) == "ListBouncyAnimator")
|
|
#expect(values["damping"] as? CGFloat == 0.8)
|
|
#expect(values["frequency"] as? CGFloat == 2)
|
|
}
|
|
|
|
@Test
|
|
func hostedRowsUseTheirMeasuredIntrinsicHeights() {
|
|
let coordinator = TranscriptListView.Coordinator()
|
|
let listView = coordinator.makeListView()
|
|
let short = TranscriptRenderItem(id: UUID(), content: .empty)
|
|
let tall = TranscriptRenderItem(
|
|
id: UUID(), content: .interrupted, hasLeadingSpacing: true
|
|
)
|
|
listView.frame = CGRect(x: 0, y: 0, width: 320, height: 400)
|
|
|
|
coordinator.update(
|
|
listView: listView,
|
|
items: [short, tall],
|
|
latestUserMessageID: nil,
|
|
renderer: { item, _ in
|
|
AnyView(
|
|
Text(item.id == short.id
|
|
? "Short"
|
|
: String(repeating: "A wrapping transcript line. ", count: 80))
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.padding(8)
|
|
)
|
|
},
|
|
environment: .init(locale: Locale(identifier: "en"), colorScheme: .light)
|
|
)
|
|
listView.layoutSubtreeIfNeeded()
|
|
|
|
let shortHeight = listView.rectForRow(with: short.id).height
|
|
let tallHeight = listView.rectForRow(with: tall.id).height
|
|
#expect(shortHeight > 0)
|
|
#expect(tallHeight > 96)
|
|
#expect(tallHeight > shortHeight)
|
|
}
|
|
|
|
@Test
|
|
func aStableBlockGrowthRemeasuresBeforePlacingTheFollowingBlock() {
|
|
let coordinator = TranscriptListView.Coordinator()
|
|
let listView = coordinator.makeListView()
|
|
let growingID = UUID()
|
|
let followingID = UUID()
|
|
let short = TranscriptRenderItem(id: growingID, content: .empty)
|
|
let tall = TranscriptRenderItem(id: growingID, content: .interrupted)
|
|
let following = TranscriptRenderItem(id: followingID, content: .empty)
|
|
listView.frame = CGRect(x: 0, y: 0, width: 320, height: 1_200)
|
|
|
|
update(
|
|
coordinator, listView,
|
|
items: [short, following],
|
|
renderer: growingRenderer(id: growingID)
|
|
)
|
|
let shortHeight = listView.rectForRow(with: growingID).height
|
|
|
|
update(
|
|
coordinator, listView,
|
|
items: [tall, following],
|
|
renderer: growingRenderer(id: growingID)
|
|
)
|
|
listView.layoutSubtreeIfNeeded()
|
|
|
|
let grownFrame = listView.rectForRow(with: growingID)
|
|
let followingFrame = listView.rectForRow(with: followingID)
|
|
#expect(grownFrame.height > shortHeight)
|
|
#expect(followingFrame.minY == grownFrame.maxY)
|
|
#expect(listView.rowView(for: growingID)?.placedFrame == grownFrame)
|
|
#expect(listView.rowView(for: followingID)?.placedFrame == followingFrame)
|
|
}
|
|
|
|
@Test
|
|
func followingTheBottomUsesTheRemeasuredBlockHeight() {
|
|
let coordinator = TranscriptListView.Coordinator()
|
|
let listView = coordinator.makeListView()
|
|
let growingID = UUID()
|
|
let leading = TranscriptRenderItem(id: UUID(), content: .empty)
|
|
let short = TranscriptRenderItem(id: growingID, content: .empty)
|
|
let tall = TranscriptRenderItem(id: growingID, content: .interrupted)
|
|
listView.frame = CGRect(x: 0, y: 0, width: 320, height: 180)
|
|
|
|
update(
|
|
coordinator, listView,
|
|
items: [leading, short],
|
|
renderer: growingRenderer(id: growingID)
|
|
)
|
|
listView.scrollToBottom(animated: false)
|
|
let oldMaximum = listView.maximumContentOffset.y
|
|
|
|
update(
|
|
coordinator, listView,
|
|
items: [leading, tall],
|
|
renderer: growingRenderer(id: growingID)
|
|
)
|
|
|
|
#expect(listView.maximumContentOffset.y > oldMaximum)
|
|
#expect(listView.contentOffset.y == listView.maximumContentOffset.y)
|
|
let lastFrame = listView.rectForRow(with: growingID)
|
|
#expect(lastFrame.maxY + listView.bottomInset == listView.contentSize.height)
|
|
}
|
|
|
|
@Test
|
|
func blockGrowthDoesNotPullAReaderBackFromHistory() {
|
|
let coordinator = TranscriptListView.Coordinator()
|
|
let listView = coordinator.makeListView()
|
|
let growingID = UUID()
|
|
let short = TranscriptRenderItem(id: growingID, content: .empty)
|
|
let tall = TranscriptRenderItem(id: growingID, content: .interrupted)
|
|
let history = (0 ..< 10).map { _ in
|
|
TranscriptRenderItem(id: UUID(), content: .empty)
|
|
}
|
|
listView.frame = CGRect(x: 0, y: 0, width: 320, height: 180)
|
|
|
|
update(
|
|
coordinator, listView,
|
|
items: history + [short],
|
|
renderer: growingRenderer(id: growingID)
|
|
)
|
|
listView.setContentOffset(CGPoint(x: 0, y: 40), animated: false)
|
|
listView.layoutSubtreeIfNeeded()
|
|
let readerOffset = listView.contentOffset
|
|
|
|
update(
|
|
coordinator, listView,
|
|
items: history + [tall],
|
|
renderer: growingRenderer(id: growingID)
|
|
)
|
|
|
|
#expect(listView.contentOffset == readerOffset)
|
|
#expect(!listView.isScrolledToBottom(tolerance: Metrics.Space.l))
|
|
}
|
|
|
|
private func update(
|
|
_ coordinator: TranscriptListView.Coordinator,
|
|
_ listView: TranscriptListView.NSViewType,
|
|
items: [TranscriptRenderItem],
|
|
renderer: @escaping TranscriptListView.Renderer
|
|
) {
|
|
coordinator.update(
|
|
listView: listView,
|
|
items: items,
|
|
latestUserMessageID: nil,
|
|
renderer: renderer,
|
|
environment: .init(locale: Locale(identifier: "en"), colorScheme: .light)
|
|
)
|
|
}
|
|
|
|
private func growingRenderer(id: UUID) -> TranscriptListView.Renderer {
|
|
{ item, _ in
|
|
let isTall = item.id == id && item.content == .interrupted
|
|
return AnyView(
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text(isTall
|
|
? String(repeating: "Expanded work detail wraps over many lines. ", count: 80)
|
|
: "Short block")
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
Divider()
|
|
}
|
|
.padding(8)
|
|
)
|
|
}
|
|
}
|
|
}
|