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) } }