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:
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user