This repository has been archived on 2026-10-04. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
hope-voice-ios/Tests/TranscriptDedupeTests.swift
T
2026-08-31 19:50:46 -04:00

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