import Foundation import Testing import SstCore @testable import SstData struct StopwatchFormatTests { @Test func formatsMinutesSecondsAndTenths() { #expect(StopwatchFormat.string(0) == "00:00.0") #expect(StopwatchFormat.string(7.46) == "00:07.4") #expect(StopwatchFormat.string(65.2) == "01:05.2") #expect(StopwatchFormat.string(-3) == "00:00.0") #expect(StopwatchFormat.string(6039.9) == "00:39.9") // minutes wrap at 100 } } struct CorrectionMarkupTests { func correction(_ start: Int, _ length: Int, heard: String = "x", wrote: String = "y") -> AppliedCorrection { AppliedCorrection(heard: heard, wrote: wrote, rule: heard, start: start, length: length) } @Test func noCorrectionsGivesOnePlainSegment() { #expect(CorrectionMarkup.segments(finalText: "hallo wereld", corrections: []) == [TextSegment(text: "hallo wereld")]) } @Test func emptyTextGivesNoSegments() { #expect(CorrectionMarkup.segments(finalText: "", corrections: [correction(0, 1)]).isEmpty) } @Test func splitsAroundACorrectionInTheMiddle() { let c = correction(11, 11, heard: "cloud code", wrote: "Claude Code") let segments = CorrectionMarkup.segments(finalText: "ik gebruik Claude Code vandaag", corrections: [c]) #expect(segments == [ TextSegment(text: "ik gebruik "), TextSegment(text: "Claude Code", correction: c), TextSegment(text: " vandaag"), ]) } @Test func handlesCorrectionsAtTheStartAndEnd() { let first = correction(0, 3), last = correction(8, 3) let segments = CorrectionMarkup.segments(finalText: "abc def ghi", corrections: [last, first]) #expect(segments.map(\.text) == ["abc", " def ", "ghi"]) #expect(segments[0].correction == first && segments[2].correction == last && segments[1].correction == nil) } @Test func skipsOutOfRangeAndOverlappingCorrections() { let ok = correction(0, 3), overlapping = correction(2, 3), outside = correction(50, 4), empty = correction(4, 0) let segments = CorrectionMarkup.segments(finalText: "abc def", corrections: [ok, overlapping, outside, empty]) #expect(segments.map(\.text) == ["abc", " def"]) } @Test func offsetsAreUTF16SoEmojiDoNotShiftThem() { // "😀 " is 3 UTF-16 units; the corrected word starts at 3. let c = correction(3, 6, heard: "wereld", wrote: "wereld") let segments = CorrectionMarkup.segments(finalText: "😀 wereld", corrections: [c]) #expect(segments.map(\.text) == ["😀 ", "wereld"]) } } @MainActor struct HistoryViewModelTests { func store(_ texts: [String]) -> HistoryStore { let store = HistoryStore(fileURL: FileManager.default.temporaryDirectory.appending(path: "sst-vm-\(UUID().uuidString)/h.json")) for (index, text) in texts.enumerated() { store.add(HistoryEntry(date: Date(timeIntervalSince1970: 1_700_000_000 + Double(index)), duration: 1, rawText: text, finalText: text)) } return store } @Test func daysAreFilledAfterTheStoreLoads() { let url = FileManager.default.temporaryDirectory.appending(path: "sst-vm-\(UUID().uuidString)/h.json") let writer = HistoryStore(fileURL: url) writer.add(HistoryEntry(date: .now, duration: 1, rawText: "x", finalText: "x")) writer.flush() let store = HistoryStore(fileURL: url) let model = HistoryViewModel(store: store) store.onChange = { model.refresh() } #expect(model.days.isEmpty) store.load() #expect(!model.days.isEmpty) #expect(model.totalCount == 1) } @Test func listsAllEntriesNewestFirstAndFiltersByQuery() { let model = HistoryViewModel(store: store(["eerste", "tweede zin", "derde"])) #expect(model.totalCount == 3) #expect(model.days.flatMap(\.entries).map(\.finalText) == ["derde", "tweede zin", "eerste"]) model.query = "tweede" #expect(model.days.flatMap(\.entries).map(\.finalText) == ["tweede zin"]) #expect(model.totalCount == 3) } @Test func selectionIsClearedWhenItDisappearsFromTheFilter() { let history = store(["alpha", "beta"]) let model = HistoryViewModel(store: history) model.selection = history.entries.first { $0.finalText == "alpha" }?.id #expect(model.selectedEntry?.finalText == "alpha") model.query = "beta" #expect(model.selection == nil) } @Test func deleteRemovesFromStoreAndList() { let history = store(["a", "b"]) let model = HistoryViewModel(store: history) model.delete(id: history.entries[0].id) #expect(history.entries.count == 1) #expect(model.totalCount == 1) } @Test func refreshPicksUpNewEntries() { let history = store(["a"]) let model = HistoryViewModel(store: history) history.add(HistoryEntry(date: .now, duration: 1, rawText: "nieuw", finalText: "nieuw")) model.refresh() #expect(model.totalCount == 2) } } @MainActor struct DictionaryViewModelTests { func makeModel(_ text: String = "") throws -> (DictionaryViewModel, DictionaryStore) { let url = FileManager.default.temporaryDirectory.appending(path: "sst-dict-\(UUID().uuidString)/dictionary.txt") try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) try text.write(to: url, atomically: true, encoding: .utf8) let store = DictionaryStore(fileURL: url) try store.load() return (DictionaryViewModel(store: store, knowledge: FakeKnowledge()), store) } @Test func rowsCarryWarnings() throws { let (model, _) = try makeModel("Supabase\ncloud => Claude\n") #expect(model.rows.count == 2) #expect(model.rows[0].warnings.isEmpty) #expect(model.rows[1].worst == .warning) } @Test func duplicateIsFlaggedOnTheLaterEntryOnly() throws { let (model, _) = try makeModel("cloud code => Claude Code\nCloud-Code => Other\n") #expect(model.rows[0].worst != .error) #expect(model.rows[1].worst == .error) } @Test func addUpdateAndRemoveGoThroughTheStore() throws { let (model, store) = try makeModel() let id = model.add(.term("Vercel"))! #expect(store.entries == [term("Vercel")]) model.update(id: id, to: .correction(heard: "versel", wrote: "Vercel")) #expect(store.entries == [pair("versel", "Vercel")]) model.remove(id: id) #expect(store.entries.isEmpty) #expect(model.rows.isEmpty) } @Test func undoRestoresTheLastChangeStepByStep() throws { let (model, store) = try makeModel("Supabase\n") #expect(!model.canUndo) let id = model.add(.term("Vercel"))! model.remove(id: id) #expect(store.entries == [term("Supabase")]) model.undo() #expect(store.entries == [term("Supabase"), term("Vercel")]) model.undo() #expect(store.entries == [term("Supabase")]) #expect(!model.canUndo) model.undo() // nothing left: harmless #expect(store.entries == [term("Supabase")]) } @Test func externalChangeClearsUndo() throws { let (model, store) = try makeModel("Supabase\n") model.add(.term("Vercel")) #expect(model.canUndo) model.externalChangeDetected() #expect(!model.canUndo) model.undo() #expect(store.entries == [term("Supabase"), term("Vercel")]) } @Test func failedUndoKeepsTheSnapshot() throws { let (model, store) = try makeModel("Supabase\n") model.add(.term("Vercel")) let folder = store.fileURL.deletingLastPathComponent() try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: folder.path) model.undo() try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: folder.path) #expect(model.errorMessage != nil) #expect(model.canUndo) model.errorMessage = nil model.undo() #expect(store.entries == [term("Supabase")]) #expect(!model.canUndo) } @Test func searchMatchesBothSidesIgnoringCaseAndDiacritics() throws { let (model, _) = try makeModel("cloud code => Claude Code\nCafé\nVercel\n") model.query = "CLAUDE" #expect(model.visibleRows.count == 1) model.query = "cafe" #expect(model.visibleRows.count == 1) model.query = "" #expect(model.visibleRows.count == 3) } @Test func editorWarningsIgnoreTheEntryBeingEdited() throws { let (model, store) = try makeModel("cloud code => Claude Code\n") let id = store.entries[0].id let editingItself = model.warnings(for: .correction(heard: "cloud code", wrote: "Claude Code"), editing: id) #expect(!editingItself.contains { $0.level == .error }) let asNew = model.warnings(for: .correction(heard: "cloud code", wrote: "Other"), editing: nil) #expect(asNew.contains { $0.level == .error }) } @Test func removingAnUnknownIdChangesNothing() throws { let (model, store) = try makeModel("Supabase\n") let before = store.entries model.remove(id: UUID()) #expect(store.entries == before) #expect(model.canUndo == false) } @Test func editingTheEarlierOfTwoDuplicatesShowsNoDuplicateError() throws { let (model, store) = try makeModel("cloud code => A\nCloud-Code => B\n") let first = store.entries[0] let second = store.entries[1] #expect(!model.warnings(for: first.kind, editing: first.id).contains { $0.level == .error }) #expect(model.warnings(for: second.kind, editing: second.id).contains { $0.level == .error }) #expect(model.warnings(for: first.kind, editing: nil).contains { $0.level == .error }) } }