Archived
421 lines
18 KiB
Swift
421 lines
18 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import Voice
|
|
|
|
@MainActor
|
|
struct TranscriptDedupeTests {
|
|
private final class ClockBox {
|
|
var time: Date
|
|
init(time: Date) { self.time = time }
|
|
}
|
|
|
|
/// Returns a handler wired to a shared clock box, plus the box itself so
|
|
/// tests can advance time deterministically.
|
|
private func freshHandler() -> (handler: TranscriptHandler, clock: ClockBox) {
|
|
// Isolate from any persisted entries left over from other runs.
|
|
UserDefaults.standard.removeObject(forKey: TranscriptHistory.key)
|
|
let history = TranscriptHistory()
|
|
let handler = TranscriptHandler(history: history)
|
|
let box = ClockBox(time: Date(timeIntervalSince1970: 1_000_000))
|
|
handler.now = { box.time }
|
|
return (handler, box)
|
|
}
|
|
|
|
private func advance(_ box: ClockBox, _ seconds: TimeInterval) {
|
|
box.time = box.time.addingTimeInterval(seconds)
|
|
}
|
|
|
|
// MARK: Cross-source dedupe (opposite source within window)
|
|
|
|
@Test("primary final then identical data final is suppressed (primary→data)")
|
|
func primaryThenDataSuppressed() {
|
|
let (handler, _) = freshHandler()
|
|
handler.receiveSegment(id: "seg-1", role: .agent, text: "good morning", isFinal: true)
|
|
#expect(handler.events.count == 1)
|
|
|
|
// Data topic delivers the same final within the window → suppressed.
|
|
handler.receiveDataTranscript(role: .agent, text: "good morning")
|
|
#expect(handler.events.count == 1)
|
|
#expect(handler.events[0].isFinal)
|
|
#expect(handler.events[0].text == "good morning")
|
|
}
|
|
|
|
@Test("data final then identical primary final is suppressed (data→primary)")
|
|
func dataThenPrimarySuppressed() {
|
|
let (handler, _) = freshHandler()
|
|
handler.receiveDataTranscript(role: .agent, text: "good morning")
|
|
#expect(handler.events.count == 1)
|
|
|
|
// Primary transcription delivers the same final within the window → suppressed.
|
|
handler.receiveSegment(id: "seg-1", role: .agent, text: "good morning", isFinal: true)
|
|
#expect(handler.events.count == 1)
|
|
#expect(handler.events[0].isFinal)
|
|
#expect(handler.events[0].text == "good morning")
|
|
}
|
|
|
|
// MARK: Same-source repeats are never suppressed
|
|
|
|
@Test("same-source primary repeat within window is NOT suppressed")
|
|
func sameSourcePrimaryRepeatAllowed() {
|
|
let (handler, _) = freshHandler()
|
|
handler.receiveSegment(id: "seg-1", role: .user, text: "hello", isFinal: true)
|
|
#expect(handler.events.count == 1)
|
|
|
|
// Same source (primary), same text, within window → allowed.
|
|
handler.receiveSegment(id: "seg-2", role: .user, text: "hello", isFinal: true)
|
|
#expect(handler.events.count == 2)
|
|
}
|
|
|
|
@Test("same-source data repeat within window is NOT suppressed")
|
|
func sameSourceDataRepeatAllowed() {
|
|
let (handler, _) = freshHandler()
|
|
handler.receiveDataTranscript(role: .user, text: "hello")
|
|
#expect(handler.events.count == 1)
|
|
|
|
// Same source (data), same text, within window → allowed.
|
|
handler.receiveDataTranscript(role: .user, text: "hello")
|
|
#expect(handler.events.count == 2)
|
|
}
|
|
|
|
// MARK: Window expiry
|
|
|
|
@Test("opposite-source final after window expiry is NOT suppressed")
|
|
func oppositeSourceAfterWindowAllowed() {
|
|
let (handler, clock) = freshHandler()
|
|
handler.receiveSegment(id: "seg-1", role: .agent, text: "good morning", isFinal: true)
|
|
#expect(handler.events.count == 1)
|
|
|
|
// Advance past the 5-second dedupe window.
|
|
advance(clock, 6)
|
|
|
|
// Data topic delivers the same final after the window → allowed.
|
|
handler.receiveDataTranscript(role: .agent, text: "good morning")
|
|
#expect(handler.events.count == 2)
|
|
}
|
|
|
|
@Test("opposite-source final exactly at window boundary is NOT suppressed")
|
|
func oppositeSourceAtBoundaryAllowed() {
|
|
let (handler, clock) = freshHandler()
|
|
handler.receiveSegment(id: "seg-1", role: .agent, text: "good morning", isFinal: true)
|
|
|
|
// Advance exactly to the window boundary (5.0s). The check is
|
|
// `timeIntervalSince < dedupeWindow`, so at exactly 5.0 it's NOT suppressed.
|
|
advance(clock, 5.0)
|
|
handler.receiveDataTranscript(role: .agent, text: "good morning")
|
|
#expect(handler.events.count == 2)
|
|
}
|
|
|
|
@Test("opposite-source final just before window boundary is suppressed")
|
|
func oppositeSourceJustBeforeBoundarySuppressed() {
|
|
let (handler, clock) = freshHandler()
|
|
handler.receiveSegment(id: "seg-1", role: .agent, text: "good morning", isFinal: true)
|
|
|
|
// Advance to 4.9s — still within the window.
|
|
advance(clock, 4.9)
|
|
handler.receiveDataTranscript(role: .agent, text: "good morning")
|
|
#expect(handler.events.count == 1)
|
|
}
|
|
|
|
// MARK: Interleaved roles
|
|
|
|
@Test("interleaved roles do not interfere with cross-source dedupe")
|
|
func interleavedRolesDedupe() {
|
|
let (handler, _) = freshHandler()
|
|
handler.receiveSegment(id: "seg-1", role: .user, text: "hi there", isFinal: false)
|
|
handler.receiveDataTranscript(role: .agent, text: "hello")
|
|
handler.receiveDataTranscript(role: .user, text: "hi there")
|
|
|
|
// User partial replaced in place; agent final appended. Two events total.
|
|
#expect(handler.events.count == 2)
|
|
#expect(handler.events[0].isFinal)
|
|
#expect(handler.events[0].text == "hi there")
|
|
#expect(handler.events[1].isFinal)
|
|
#expect(handler.events[1].text == "hello")
|
|
}
|
|
|
|
// MARK: Partial replacement
|
|
|
|
@Test("data final matching open partial replaces it instead of duplicating")
|
|
func dataFinalReplacesMatchingPartial() {
|
|
let (handler, _) = freshHandler()
|
|
handler.receiveSegment(id: "seg-1", role: .user, text: "hello world", isFinal: false)
|
|
handler.receiveDataTranscript(role: .user, text: "hello world")
|
|
|
|
#expect(handler.events.count == 1)
|
|
#expect(handler.events[0].isFinal)
|
|
#expect(handler.events[0].id == "seg-1")
|
|
}
|
|
|
|
@Test("data final with differing open partial replaces the partial in place")
|
|
func differentPartialReplaced() {
|
|
let (handler, _) = freshHandler()
|
|
handler.receiveSegment(id: "seg-1", role: .user, text: "first utterance", isFinal: false)
|
|
handler.receiveDataTranscript(role: .user, text: "second utterance")
|
|
|
|
#expect(handler.events.count == 1)
|
|
#expect(handler.events[0].isFinal)
|
|
#expect(handler.events[0].text == "second utterance")
|
|
}
|
|
|
|
@Test("data final with no open partial appends a new final row")
|
|
func dataFinalWithoutPartialAppends() {
|
|
let (handler, _) = freshHandler()
|
|
handler.receiveDataTranscript(role: .agent, text: "surprise final")
|
|
|
|
#expect(handler.events.count == 1)
|
|
#expect(handler.events[0].isFinal)
|
|
#expect(handler.events[0].text == "surprise final")
|
|
}
|
|
|
|
// MARK: Max events trimming
|
|
|
|
@Test("canonical collection trims to max 50 finals")
|
|
func trimsToMaxEvents() {
|
|
let (handler, _) = freshHandler()
|
|
// Add 55 distinct finals (different text so no dedupe).
|
|
for i in 0..<55 {
|
|
handler.receiveSegment(id: "seg-\(i)", role: .agent, text: "utterance \(i)", isFinal: true)
|
|
}
|
|
#expect(handler.events.count == TranscriptHandler.maxEvents)
|
|
// The oldest finals are dropped; the newest remain.
|
|
#expect(handler.events.last?.text == "utterance 54")
|
|
}
|
|
|
|
// MARK: Clear
|
|
|
|
@Test("clear removes events, dedupe state, and persistence")
|
|
func clearResetsState() {
|
|
let (handler, _) = freshHandler()
|
|
handler.receiveSegment(id: "seg-1", role: .user, text: "test", isFinal: true)
|
|
#expect(handler.events.count == 1)
|
|
|
|
handler.clearAll()
|
|
#expect(handler.events.isEmpty)
|
|
|
|
// After clear, the same text can be recorded again (dedupe state reset).
|
|
handler.receiveSegment(id: "seg-2", role: .user, text: "test", isFinal: true)
|
|
#expect(handler.events.count == 1)
|
|
}
|
|
|
|
// MARK: Restoration from persisted history
|
|
|
|
@Test("handler initializes canonical events from persisted history")
|
|
func restoresFromHistory() {
|
|
UserDefaults.standard.removeObject(forKey: TranscriptHistory.key)
|
|
let history = TranscriptHistory()
|
|
// Simulate a prior session's persisted finals.
|
|
history.sync(finals: [
|
|
(role: "user", text: "old question", timestamp: Date(timeIntervalSince1970: 900_000)),
|
|
(role: "agent", text: "old answer", timestamp: Date(timeIntervalSince1970: 900_005)),
|
|
])
|
|
|
|
let handler = TranscriptHandler(history: history)
|
|
#expect(handler.events.count == 2)
|
|
#expect(handler.events[0].text == "old question")
|
|
#expect(handler.events[0].role == .user)
|
|
#expect(handler.events[1].text == "old answer")
|
|
#expect(handler.events[1].role == .agent)
|
|
|
|
// A new live event appends after the restored entries.
|
|
handler.receiveSegment(id: "seg-1", role: .user, text: "new question", isFinal: true)
|
|
#expect(handler.events.count == 3)
|
|
#expect(handler.events.last?.text == "new question")
|
|
}
|
|
|
|
// MARK: Live append after restoration
|
|
|
|
@Test("live finals append after restored entries in canonical collection")
|
|
func liveAppendAfterRestore() {
|
|
UserDefaults.standard.removeObject(forKey: TranscriptHistory.key)
|
|
let history = TranscriptHistory()
|
|
history.sync(finals: [
|
|
(role: "agent", text: "restored line", timestamp: Date(timeIntervalSince1970: 800_000)),
|
|
])
|
|
|
|
let handler = TranscriptHandler(history: history)
|
|
#expect(handler.events.count == 1)
|
|
|
|
// Live partial then final.
|
|
handler.receiveSegment(id: "seg-1", role: .user, text: "live partial", isFinal: false)
|
|
#expect(handler.events.count == 2)
|
|
handler.receiveSegment(id: "seg-1", role: .user, text: "live final", isFinal: true)
|
|
#expect(handler.events.count == 2)
|
|
#expect(handler.events.last?.text == "live final")
|
|
#expect(handler.events.last?.isFinal == true)
|
|
}
|
|
|
|
// MARK: Persistence sync
|
|
|
|
@Test("persistence mirrors canonical finals after each change")
|
|
func persistenceMirrorsCanonical() {
|
|
let (handler, _) = freshHandler()
|
|
handler.receiveSegment(id: "seg-1", role: .user, text: "persist me", isFinal: true)
|
|
|
|
// The history store should now contain exactly the canonical finals.
|
|
#expect(handler.history.entries.count == 1)
|
|
#expect(handler.history.entries[0].text == "persist me")
|
|
#expect(handler.history.entries[0].role == "user")
|
|
}
|
|
|
|
@Test("cross-source dedupe does not drop the row from persistence")
|
|
func dedupeDoesNotDropPersistence() {
|
|
let (handler, _) = freshHandler()
|
|
handler.receiveSegment(id: "seg-1", role: .agent, text: "deduped line", isFinal: true)
|
|
// Data copy is suppressed — still one canonical event.
|
|
handler.receiveDataTranscript(role: .agent, text: "deduped line")
|
|
|
|
#expect(handler.events.count == 1)
|
|
#expect(handler.history.entries.count == 1)
|
|
}
|
|
|
|
// MARK: Consume-on-suppress (Iteration 06 Item 2)
|
|
|
|
@Test("primary final, data duplicate suppressed, second data final with same text accepted")
|
|
func consumeOnSuppressAllowsSecondSameSource() {
|
|
let (handler, _) = freshHandler()
|
|
// Step 1: Primary delivers "hello".
|
|
handler.receiveSegment(id: "seg-1", role: .user, text: "hello", isFinal: true)
|
|
#expect(handler.events.count == 1)
|
|
|
|
// Step 2: Data delivers the same "hello" within window → suppressed.
|
|
handler.receiveDataTranscript(role: .user, text: "hello")
|
|
#expect(handler.events.count == 1)
|
|
|
|
// Step 3: Data delivers a *second* legitimate "hello" (same source as
|
|
// step 2). The consume-on-suppress should have updated the record to
|
|
// .data, so this same-source repeat is NOT suppressed.
|
|
handler.receiveDataTranscript(role: .user, text: "hello")
|
|
#expect(handler.events.count == 2)
|
|
}
|
|
|
|
@Test("data final, primary duplicate suppressed, second primary final with same text accepted")
|
|
func consumeOnSuppressAllowsSecondPrimary() {
|
|
let (handler, _) = freshHandler()
|
|
// Step 1: Data delivers "world".
|
|
handler.receiveDataTranscript(role: .agent, text: "world")
|
|
#expect(handler.events.count == 1)
|
|
|
|
// Step 2: Primary delivers the same "world" within window → suppressed.
|
|
handler.receiveSegment(id: "seg-1", role: .agent, text: "world", isFinal: true)
|
|
#expect(handler.events.count == 1)
|
|
|
|
// Step 3: Primary delivers a *second* legitimate "world" (same source
|
|
// as step 2). The consume-on-suppress should have updated the record to
|
|
// .primary, so this same-source repeat is NOT suppressed.
|
|
handler.receiveSegment(id: "seg-2", role: .agent, text: "world", isFinal: true)
|
|
#expect(handler.events.count == 2)
|
|
}
|
|
|
|
@Test("same-source immediate repeats still accepted after consume")
|
|
func sameSourceRepeatsAfterConsume() {
|
|
let (handler, _) = freshHandler()
|
|
// Primary "hi" → recorded as .primary.
|
|
handler.receiveSegment(id: "seg-1", role: .user, text: "hi", isFinal: true)
|
|
#expect(handler.events.count == 1)
|
|
|
|
// Data "hi" → suppressed (opposite source), record consumed (removed).
|
|
handler.receiveDataTranscript(role: .user, text: "hi")
|
|
#expect(handler.events.count == 1)
|
|
|
|
// Primary "hi" again → no record exists (was consumed), so this is a
|
|
// new pair and is accepted.
|
|
handler.receiveSegment(id: "seg-2", role: .user, text: "hi", isFinal: true)
|
|
#expect(handler.events.count == 2)
|
|
|
|
// Data "hi" again → suppressed (opposite source of the new record).
|
|
handler.receiveDataTranscript(role: .user, text: "hi")
|
|
#expect(handler.events.count == 2)
|
|
}
|
|
|
|
@Test("persistence remains exact after consume-on-suppress")
|
|
func persistenceExactAfterConsume() {
|
|
let (handler, _) = freshHandler()
|
|
handler.receiveSegment(id: "seg-1", role: .user, text: "exact", isFinal: true)
|
|
// Data duplicate suppressed.
|
|
handler.receiveDataTranscript(role: .user, text: "exact")
|
|
#expect(handler.events.count == 1)
|
|
#expect(handler.history.entries.count == 1)
|
|
|
|
// Second data final accepted.
|
|
handler.receiveDataTranscript(role: .user, text: "exact")
|
|
#expect(handler.events.count == 2)
|
|
#expect(handler.history.entries.count == 2)
|
|
}
|
|
|
|
// MARK: Session start preserves history (Iteration 06 Item 1)
|
|
|
|
@Test("starting a new session preserves restored history in canonical collection")
|
|
func newSessionPreservesHistory() {
|
|
UserDefaults.standard.removeObject(forKey: TranscriptHistory.key)
|
|
let history = TranscriptHistory()
|
|
history.sync(finals: [
|
|
(role: "user", text: "prior question", timestamp: Date(timeIntervalSince1970: 800_000)),
|
|
(role: "agent", text: "prior answer", timestamp: Date(timeIntervalSince1970: 800_005)),
|
|
])
|
|
|
|
// Simulate what happens when a new VoiceSession is created: the handler
|
|
// initializes from persisted history. Starting a session does NOT call
|
|
// clearAll() — it preserves the restored entries.
|
|
let handler = TranscriptHandler(history: history)
|
|
#expect(handler.events.count == 2)
|
|
#expect(handler.events[0].text == "prior question")
|
|
#expect(handler.events[1].text == "prior answer")
|
|
|
|
// A new live event appends after the restored entries.
|
|
handler.receiveSegment(id: "seg-1", role: .user, text: "new utterance", isFinal: true)
|
|
#expect(handler.events.count == 3)
|
|
#expect(handler.events[0].text == "prior question")
|
|
#expect(handler.events.last?.text == "new utterance")
|
|
}
|
|
|
|
// MARK: Repeated source pairs (Iteration 07 Item 3)
|
|
|
|
@Test("P1,D1,P2,D2 produces exactly two canonical and persisted rows")
|
|
func primaryDataPrimaryDataPairs() {
|
|
let (handler, _) = freshHandler()
|
|
// P1: Primary delivers "hello".
|
|
handler.receiveSegment(id: "seg-p1", role: .user, text: "hello", isFinal: true)
|
|
#expect(handler.events.count == 1)
|
|
|
|
// D1: Data delivers the same "hello" → suppressed (opposite source), record consumed.
|
|
handler.receiveDataTranscript(role: .user, text: "hello")
|
|
#expect(handler.events.count == 1)
|
|
|
|
// P2: Primary delivers a *second* legitimate "hello". No record exists
|
|
// (was consumed by D1 suppression), so this is accepted as a new pair.
|
|
handler.receiveSegment(id: "seg-p2", role: .user, text: "hello", isFinal: true)
|
|
#expect(handler.events.count == 2)
|
|
|
|
// D2: Data delivers the same "hello" again → suppressed (opposite source of P2).
|
|
handler.receiveDataTranscript(role: .user, text: "hello")
|
|
#expect(handler.events.count == 2)
|
|
|
|
// Persistence mirrors the canonical collection exactly.
|
|
#expect(handler.history.entries.count == 2)
|
|
}
|
|
|
|
@Test("D1,P1,D2,P2 produces exactly two canonical and persisted rows")
|
|
func dataPrimaryDataPrimaryPairs() {
|
|
let (handler, _) = freshHandler()
|
|
// D1: Data delivers "hello".
|
|
handler.receiveDataTranscript(role: .user, text: "hello")
|
|
#expect(handler.events.count == 1)
|
|
|
|
// P1: Primary delivers the same "hello" → suppressed (opposite source), record consumed.
|
|
handler.receiveSegment(id: "seg-p1", role: .user, text: "hello", isFinal: true)
|
|
#expect(handler.events.count == 1)
|
|
|
|
// D2: Data delivers a *second* legitimate "hello". No record exists
|
|
// (was consumed by P1 suppression), so this is accepted as a new pair.
|
|
handler.receiveDataTranscript(role: .user, text: "hello")
|
|
#expect(handler.events.count == 2)
|
|
|
|
// P2: Primary delivers the same "hello" again → suppressed (opposite source of D2).
|
|
handler.receiveSegment(id: "seg-p2", role: .user, text: "hello", isFinal: true)
|
|
#expect(handler.events.count == 2)
|
|
|
|
// Persistence mirrors the canonical collection exactly.
|
|
#expect(handler.history.entries.count == 2)
|
|
}
|
|
}
|