feat: consolidate iOS with API and web preserving history
Rebuild & Deploy / rebuild (push) Failing after 23s
Rebuild & Deploy / rebuild (push) Failing after 23s
This commit is contained in:
commit
d735ee72fa
102 files changed
+13077
-258
No files matched your search
@@ -0,0 +1,420 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user