feat: add view-model logic for history and dictionary, correction markup, stopwatch format

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 15:44:20 +02:00
co-authored by Claude Sonnet 5.5
parent cfe8208e11
commit fffd9d7022
2 changed files with 350 additions and 0 deletions
+177
View File
@@ -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[..<index]), knowledge: knowledge))
}
applyFilter()
}
private func applyFilter() {
let words = HistorySearch.fold(query).split(whereSeparator: \.isWhitespace).map(String.init)
guard !words.isEmpty else {
visibleRows = rows
return
}
visibleRows = rows.filter { row in
let haystack = HistorySearch.fold(row.entry.source + " " + row.entry.wrote)
return words.allSatisfy { haystack.contains($0) }
}
}
/// Live feedback for the editor; `id` is the entry being edited (nil for a new one).
public func warnings(for kind: DictionaryEntry.Kind, editing id: UUID?) -> [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()
}
}
+173
View File
@@ -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 })
}
}