diff --git a/Sources/SstData/ViewSupport.swift b/Sources/SstData/ViewSupport.swift new file mode 100644 index 0000000..8844642 --- /dev/null +++ b/Sources/SstData/ViewSupport.swift @@ -0,0 +1,177 @@ +import Combine +import Foundation +import SstCore + +/// "mm:ss.t" for the counter. +public enum StopwatchFormat { + public static func string(_ seconds: Double) -> String { + let tenths = Int(max(0, seconds) * 10) + return String(format: "%02d:%02d.%d", (tenths / 600) % 100, (tenths / 10) % 60, tenths % 10) + } +} + +public struct TextSegment: Equatable, Sendable { + public let text: String + /// Set when this piece was written by the dictionary correction. + public let correction: AppliedCorrection? + + public init(text: String, correction: AppliedCorrection? = nil) { + self.text = text + self.correction = correction + } +} + +public enum CorrectionMarkup { + /// Splits `finalText` into plain and corrected pieces. Offsets are UTF-16; out-of-range or overlapping ones are skipped. + public static func segments(finalText: String, corrections: [AppliedCorrection]) -> [TextSegment] { + let ns = finalText as NSString + guard ns.length > 0 else { return [] } + var result: [TextSegment] = [] + var cursor = 0 + for correction in corrections.sorted(by: { $0.start < $1.start }) { + let end = correction.start + correction.length + guard correction.length > 0, correction.start >= cursor, end <= ns.length else { continue } + if correction.start > cursor { + result.append(TextSegment(text: ns.substring(with: NSRange(location: cursor, length: correction.start - cursor)))) + } + result.append(TextSegment(text: ns.substring(with: NSRange(location: correction.start, length: correction.length)), correction: correction)) + cursor = end + } + if cursor < ns.length { + result.append(TextSegment(text: ns.substring(from: cursor))) + } + return result + } +} + +/// The searchable, day-grouped list behind the transcripts view. +@MainActor +public final class HistoryViewModel: ObservableObject { + private let store: HistoryStore + private let calendar: Calendar + + @Published public var query = "" { + didSet { refresh() } + } + @Published public var selection: UUID? + @Published public private(set) var days: [HistoryDay] = [] + @Published public private(set) var totalCount = 0 + + public init(store: HistoryStore, calendar: Calendar = .current) { + self.store = store + self.calendar = calendar + refresh() + } + + public func refresh() { + let visible = HistorySearch.filter(store.entries, query: query) + days = HistoryGrouping.byDay(visible, calendar: calendar) + totalCount = store.entries.count + if let selection, !visible.contains(where: { $0.id == selection }) { self.selection = nil } + } + + public var selectedEntry: HistoryEntry? { + store.entries.first { $0.id == selection } + } + + public func delete(id: UUID) { + store.remove(id: id) + refresh() + } +} + +/// Dictionary rows with warnings, search, editing and undo. +@MainActor +public final class DictionaryViewModel: ObservableObject { + public struct Row: Identifiable, Equatable { + public let id: UUID + public let entry: DictionaryEntry + public let warnings: [EntryWarning] + + public var worst: EntryWarning.Level? { warnings.map(\.level).max() } + } + + private let store: DictionaryStore + private let knowledge: WordKnowledge + private var undoStack: [DictionaryFile] = [] + + @Published public var query = "" { + didSet { applyFilter() } + } + @Published public private(set) var rows: [Row] = [] + @Published public private(set) var visibleRows: [Row] = [] + @Published public private(set) var canUndo = false + @Published public var errorMessage: String? + + public init(store: DictionaryStore, knowledge: WordKnowledge) { + self.store = store + self.knowledge = knowledge + reload() + } + + /// Rebuilds the rows from the store (call after the store changed). + public func reload() { + let entries = store.entries + rows = entries.enumerated().map { index, entry in + // Only earlier entries count as "already there", so a duplicate is flagged on the later one. + Row(id: entry.id, entry: entry, + warnings: EntryWarnings.check(entry.kind, among: Array(entries[.. [EntryWarning] { + EntryWarnings.check(kind, among: store.entries.filter { $0.id != id }, knowledge: knowledge) + } + + @discardableResult + public func add(_ kind: DictionaryEntry.Kind) -> UUID? { + var created: UUID? + mutate { created = $0.append(kind) } + return created + } + + public func update(id: UUID, to kind: DictionaryEntry.Kind) { + mutate { $0.update(id: id, kind: kind) } + } + + public func remove(id: UUID) { + mutate { $0.remove(id: id) } + } + + public func undo() { + guard let snapshot = undoStack.popLast() else { return } + canUndo = !undoStack.isEmpty + do { + try store.apply { $0 = snapshot } + } catch { + errorMessage = error.localizedDescription + } + reload() + } + + private func mutate(_ change: (inout DictionaryFile) -> Void) { + let snapshot = store.file + do { + try store.apply(change) + undoStack.append(snapshot) + canUndo = true + } catch { + errorMessage = error.localizedDescription + } + reload() + } +} diff --git a/Tests/SstDataTests/ViewSupportTests.swift b/Tests/SstDataTests/ViewSupportTests.swift new file mode 100644 index 0000000..3c60ed7 --- /dev/null +++ b/Tests/SstDataTests/ViewSupportTests.swift @@ -0,0 +1,173 @@ +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 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 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 }) + } +}