Build Edit mode with debounced, byte-honest saves

The editing surface: the same hosted TextKit-1 text view gains an
editable branch with a per-keystroke line-scanner highlighter — chosen
over a parser re-parse because a mid-typing buffer is usually invalid
Markdown and 05 wants the delimiters themselves dimmed; apply only sets
attributes, so presentation-never-transforms is structural. Saves ride
a ~700ms injectable debounce through BoardWriter.writeBody —
toggleTaskMarker's idiom widened to the body span, frontmatter bytes
untouched, refusing to write when disk already holds that body, which
enforces all three gates (untouched, reverted, echo) at the layer that
owns the bytes with one isDirty predicate above it. Mode grammar lands
whole: ⌘E toggles with a checkmark, Return in Preview enters, Escape
returns, and every flip flushes first; window close flushes through
the existing retry/save-copy/discard modal, and the dismissal flush
deliberately reaches a tombstoned card. Dirty-buffer-wins: disk always
follows the snapshot, the buffer only when clean, both surfaces render
the buffer. Undo is the editor's own session-scoped NSUndoManager;
endEditSession names the pro-m1 one-commit-per-session boundary.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 10:46:02 -04:00
parent 6dc84176fb
commit e989c1f26e
16 changed files with 2195 additions and 78 deletions
+334
View File
@@ -0,0 +1,334 @@
import Foundation
import Testing
@testable import Kanban
/// The card window's Edit buffer meeting disk (05-card-window.md Edit, Write rules) the second
/// and larger of the app's two body writes, and the one whose guarantees are *negative*: the file
/// this suite cares most about is the one that was never written.
///
/// Three of 05's rules are only observable in bytes, so this suite reads bytes: an untouched session
/// leaves the file byte-identical **and its `mtime` untouched** (a stamped no-op would satisfy the
/// first and violate the promise), a real edit replaces the body span and nothing above it, and a
/// reverted or echoed edit is not written at all. Like the rest of the write suites this drives real
/// files in a temp board and never reads through the app's own snapshot. `WriterFixture`, `Ident` and
/// `Item` come from `WriterTestSupport.swift`.
// MARK: - Fixture
private let originalBody = """
# Notes
Some *prose* with a [link](https://example.com).
- [ ] a task
"""
/// The card, with everything a body write must leave alone above the closing delimiter: an unknown
/// key carrying an inline comment, a second unknown key in a shape the app never writes, a `created`
/// from before today, and a foreign `modified-by`.
private let editableCard = """
---
schema: 1
title: Notes
order: 1024
project: lanework # agent overlay
labels: [a, b, c]
created: 2026-01-01T09:00:00Z
modified: 2026-02-02T09:00:00Z
modified-by: claude
---
\(originalBody)
"""
private let cardPath = "\(Ident.lane1)/\(Ident.card1)"
private let siblingPath = "\(Ident.lane1)/\(Ident.card2)"
@MainActor
private func makeBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item(cardPath, editableCard)
try fixture.item(siblingPath, Item.rich(order: "2048", title: "Untouched"))
return fixture
}
/// The card's body as it is on disk right now split off at the closing delimiter by the same
/// parser the writer used, so "the body" means the same thing in the test as in the app.
private func body(of fixture: WriterFixture, _ relativePath: String) throws -> String {
try FrontmatterDocument.parse(fixture.indexText(relativePath)).body
}
/// The file's frontmatter lines, minus the two the stamp owns what has to be identical, comment
/// and key order included.
private func frontmatterLines(_ text: String) -> [String] {
let lines = text.components(separatedBy: "\n")
guard let closing = lines.dropFirst().firstIndex(where: { $0.trimmingCharacters(in: .whitespaces) == "---" })
else { return lines }
return lines[0 ..< closing].filter { !$0.hasPrefix("modified:") && !$0.hasPrefix("modified-by:") }
}
private func modificationDate(_ fixture: WriterFixture, _ relativePath: String) throws -> Date? {
let url = fixture.url(relativePath).appendingPathComponent("index.md")
return try FileManager.default.attributesOfItem(atPath: url.path)[.modificationDate] as? Date
}
// MARK: - The write
@MainActor
@Suite("BoardWriter ▸ writeBody")
struct WriteBodyTests {
@Test("A real edit replaces the body span and leaves every frontmatter byte alone")
func anEditReplacesOnlyTheBody() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let before = try fixture.indexText(cardPath)
let wrote = try BoardWriter.writeBody(inItemFolder: fixture.url(cardPath), body: "Replaced.\n")
#expect(wrote)
#expect(try body(of: fixture, cardPath) == "Replaced.\n")
// Key order, the unknown keys, the inline comment and `created` all survive the round-trip
// guarantee, which a body write inherits by editing the document rather than rebuilding it.
let after = try fixture.indexText(cardPath)
#expect(frontmatterLines(after) == frontmatterLines(before))
#expect(after.contains("project: lanework # agent overlay"))
#expect(after.contains("labels: [a, b, c]"))
#expect(after.contains("created: 2026-01-01T09:00:00Z"))
}
@Test("A body rewrite is an index.md rewrite, so it stamps modified and clears modified-by")
func theWriteStamps() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try BoardWriter.writeBody(inItemFolder: fixture.url(cardPath), body: "New text.\n")
let stamped = try #require(try FrontmatterDocument.parse(fixture.indexText(cardPath)).modified.value)
#expect(stamped.timeIntervalSinceNow > -30)
#expect(!(try fixture.indexText(cardPath).contains("modified-by")))
}
@Test("Writing the body the file already has writes nothing at all — bytes and mtime")
func anIdenticalBodyIsNeverReSerialized() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let before = try fixture.indexData(cardPath)
let mtime = try modificationDate(fixture, cardPath)
// Filesystem timestamps have coarse resolution; a write inside the same tick would be
// invisible to the `mtime` half of the assertion, so give it a moment to be able to differ.
Thread.sleep(forTimeInterval: 0.05)
let wrote = try BoardWriter.writeBody(inItemFolder: fixture.url(cardPath), body: originalBody)
#expect(!wrote, "an untouched body is never re-serialized (05 ▸ Write rules)")
#expect(try fixture.indexData(cardPath) == before)
// The `mtime` is the point: a no-op that still stamped `modified` would keep the *body*
// byte-identical while rewriting the file which is the thing the rule forbids.
#expect(try modificationDate(fixture, cardPath) == mtime)
}
@Test("A body that changed under the buffer is overwritten — this is not a staleness check")
func aForeignEditIsOverwritten() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
// Somebody else rewrote the card while the buffer held unsaved keystrokes.
try fixture.item(cardPath, editableCard.replacingOccurrences(of: "# Notes", with: "# Theirs"))
try BoardWriter.writeBody(inItemFolder: fixture.url(cardPath), body: "Mine.\n")
// "Deliberate last-writer-wins, the same no-merge-UI philosophy as sync" (05 Write rules).
#expect(try body(of: fixture, cardPath) == "Mine.\n")
}
@Test("An empty body is a legal body, and CRLF frontmatter stays CRLF")
func anEmptyBodyAndOddLineEndings() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let path = "\(Ident.lane1)/\(Ident.card3)"
try fixture.item(path, "---\r\nschema: 1\r\norder: 3072\r\n---\r\nold body\r\n")
try BoardWriter.writeBody(inItemFolder: fixture.url(path), body: "")
let after = try fixture.indexText(path)
#expect(try body(of: fixture, path).isEmpty)
#expect(after.contains("schema: 1\r\n"), "line endings are preserved per line, never normalized")
#expect(after.contains("modified: "))
}
@Test("No other file is opened, let alone rewritten, and no temp file is left behind")
func siblingsAreUntouched() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let siblingBefore = try fixture.indexData(siblingPath)
let laneBefore = try fixture.indexData(Ident.lane1)
try BoardWriter.writeBody(inItemFolder: fixture.url(cardPath), body: "Only this card.\n")
#expect(try fixture.indexData(siblingPath) == siblingBefore)
#expect(try fixture.indexData(Ident.lane1) == laneBefore)
// Hidden entries included the writer's temps are dot-prefixed.
#expect(try fixture.entryNames(cardPath) == ["index.md"])
}
}
// MARK: - Refusals
@MainActor
@Suite("BoardWriter ▸ writeBody refusals")
struct WriteBodyRefusalTests {
@Test("Frontmatter that cannot be edited in place refuses before the body is touched")
func uneditableFrontmatterRefuses() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
// A whole-frontmatter flow mapping: readable, renderable, and unwritable the settled
// readable-but-uneditable rule, which a body edit is no exemption from, because the write
// still has to stamp `modified` through the span editor.
let path = "\(Ident.lane1)/\(Ident.card3)"
try fixture.item(path, "---\n{schema: 1, order: 3072}\n---\nodd body\n")
let before = try fixture.indexData(path)
let error = writeFailure {
try BoardWriter.writeBody(inItemFolder: fixture.url(path), body: "new")
}
#expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine))
#expect(error?.operation == .editBody(title: nil), "the flow mapping's title is not addressable")
#expect(try fixture.indexData(path) == before)
}
@Test("A folder that is not a lane or a card refuses")
func strayFoldersRefuse() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
// The board root: its body is the board description, and no editor in the app opens it.
let error = writeFailure {
try BoardWriter.writeBody(inItemFolder: fixture.root, body: "nope")
}
if case .unreadable = error?.reason {} else {
Issue.record("expected an unreadable refusal, got \(String(describing: error?.reason))")
}
}
@Test("A failure names the card by the title the read found")
func failuresNameTheCard() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let folder = fixture.url(cardPath)
// Unwritable folder: the read and the parse both succeed, so the operation is enriched, and
// then the atomic replace cannot land its temp file.
try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: folder.path)
let error = writeFailure {
try BoardWriter.writeBody(inItemFolder: folder, body: "unwritable")
}
#expect(error?.operation == .editBody(title: "Notes"))
#expect(BannerCenter.headline(for: try #require(error)).hasPrefix("Couldn't save 'Notes'"))
}
}
// MARK: - Through the store
@MainActor
@Suite("BoardStore ▸ writeCardBody")
struct StoreWriteCardBodyTests {
@Test("A save lands on disk and reports that it did")
func theStoreWritesThrough() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let outcome = store.writeCardBody(inCard: ItemID(rawValue: Ident.card1), body: "Through the store.\n")
#expect(outcome == .written)
// Read back through the loader, never through the store's snapshot: the one-way flow means
// the snapshot only catches up when the watcher's reload lands (02-architecture.md).
#expect(try body(of: fixture, cardPath) == "Through the store.\n")
}
@Test("Saving what disk already says reports unchanged and writes nothing")
func anEchoWritesNothing() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let before = try fixture.indexData(cardPath)
#expect(store.writeCardBody(inCard: ItemID(rawValue: Ident.card1), body: originalBody) == .unchanged)
#expect(try fixture.indexData(cardPath) == before)
}
@Test("A tombstoned card is still written to, and stays tombstoned")
func aTombstonedCardStillTakesTheFlush() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try BoardWriter.deleteItem(at: fixture.url(cardPath))
let store = try BoardStore(rootURL: fixture.root)
// 05 Deletion & lifecycle: "a dirty Edit buffer flushes into the tombstoned card's folder
// before the window dismisses ... so the keystrokes survive Put Back".
let outcome = store.writeCardBody(inCard: ItemID(rawValue: Ident.card1), body: "Typed as it went.\n")
#expect(outcome == .written)
#expect(try body(of: fixture, cardPath) == "Typed as it went.\n")
// Surgical: the write replaced the body span, so the tombstone is still standing and Put
// Back still has something to put back.
#expect(try FrontmatterDocument.parse(fixture.indexText(cardPath)).deleted.value != nil)
}
@Test("A card that is not in the board at all reports vanished, and writes nowhere")
func aVanishedCardWritesNothing() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let before = try fixture.indexData(cardPath)
// "A card hard-deleted externally (folder gone) discards both nowhere left to write" (05).
#expect(store.writeCardBody(inCard: ItemID(rawValue: Ident.card4), body: "nowhere") == .vanished)
#expect(try fixture.indexData(cardPath) == before)
}
@Test("A read-only board suspends the save rather than failing it")
func theLockSuspends() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let before = try fixture.indexData(cardPath)
store.enterVanishedRootLock()
// "Editor buffers kept but their debounced saves suspended" (02 § the lock's scope) the
// buffer's owner reads this as "hold the text", not as "the write failed".
let outcome = store.writeCardBody(inCard: ItemID(rawValue: Ident.card1), body: "held")
#expect(outcome == .suspended(.vanishedRoot))
#expect(try fixture.indexData(cardPath) == before)
#expect(store.banners.oneShots.isEmpty, "the lock's row is the message; a refused tick posts nothing")
}
@Test("A failed save reports the error, and the banner has it")
func aFailedSaveReports() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
try FileManager.default.setAttributes(
[.posixPermissions: 0o500],
ofItemAtPath: fixture.url(cardPath).path
)
let outcome = store.writeCardBody(inCard: ItemID(rawValue: Ident.card1), body: "cannot land")
guard case let .failed(error) = outcome else {
Issue.record("expected a failure, got \(outcome)")
return
}
#expect(error.operation == .editBody(title: "Notes"))
// `performWrite` posts every `BoardWriteError` before it rethrows the caller never has to
// remember to, and a `try?` at a call site cannot make a failure silent.
#expect(store.banners.oneShots.contains { BannerCenter.headline(for: $0.error).hasPrefix("Couldn't save 'Notes'") })
}
}
+338
View File
@@ -0,0 +1,338 @@
import Foundation
import Testing
@testable import Kanban
/// The Edit buffer's state machine (05-card-window.md Edit, Write rules): the three write gates,
/// the ~700 ms debounce, and dirty-buffer-wins.
///
/// The session is deliberately a buffer and a clock with a *closure* for its destination, which is
/// what lets this suite be about the rules rather than about files: the fake below records every
/// save it is asked for, so "writes nothing" is an assertion about a count rather than an inference
/// from an `mtime`. The bytes those saves put on disk are `BodyWriteTests`'.
// MARK: - The fake destination
/// A stand-in for `BoardStore.writeCardBody`, recording what it was asked to write and answering
/// with whatever outcome the test wants.
@MainActor
private final class SaveSpy {
private(set) var written: [String] = []
var outcome: CardBodyWriteOutcome = .written
var count: Int { written.count }
var last: String? { written.last }
func save(_ text: String) -> CardBodyWriteOutcome {
written.append(text)
return outcome
}
}
@MainActor
private func makeSession(_ spy: SaveSpy, body: String = "original\n") -> CardBodyEditSession {
let session = CardBodyEditSession()
// Fast enough that a test never waits on the real 700 ms, slow enough that a keystroke arriving
// right after another can still cancel it the `DragSession.holdTimeout` precedent.
session.debounceInterval = .milliseconds(30)
session.save = { [spy] text in spy.save(text) }
session.adopt(diskBody: body)
return session
}
/// Polls until `condition` holds or the deadline passes the suite's shape for anything the
/// debounce has to actually elapse for.
@MainActor
private func waitUntil(_ deadline: Duration = .seconds(2), _ condition: () -> Bool) async {
let start = ContinuousClock.now
while !condition() {
guard ContinuousClock.now - start < deadline else { return }
try? await Task.sleep(for: .milliseconds(5))
}
}
// MARK: - The three gates
@MainActor
@Suite("Card body ▸ the write gates")
struct CardBodyWriteGateTests {
@Test("An untouched session writes nothing, ever")
func anUntouchedSessionWritesNothing() async {
let spy = SaveSpy()
let session = makeSession(spy)
// Opened, read, and left the flush that leaving Edit performs, on a buffer nobody typed
// into. "An untouched body is never rewritten" (05 Write rules).
#expect(session.flush() == .unchanged)
await waitUntil { spy.count > 0 }
#expect(spy.count == 0)
#expect(session.saveAttempts == 0)
}
@Test("A typed-then-reverted edit writes nothing, and leaves no timer standing")
func aRevertedEditWritesNothing() async {
let spy = SaveSpy()
let session = makeSession(spy)
session.edited("original\nand more")
#expect(session.isDirty)
// Undone before the debounce could fire.
session.edited("original\n")
#expect(!session.isDirty)
// The pending save was cancelled by the revert rather than firing on a no-op a write that
// landed here would stamp `modified` and mint a commit for nothing.
await waitUntil { spy.count > 0 }
#expect(spy.count == 0)
#expect(session.flush() == .unchanged)
#expect(spy.count == 0)
}
@Test("The echo of the app's own save is not written back")
func anEchoIsNotWrittenBack() async {
let spy = SaveSpy()
let session = makeSession(spy)
session.edited("typed\n")
#expect(session.flush() == .written)
#expect(spy.written == ["typed\n"])
// The watcher's reload arrives carrying what we just wrote. The buffer is clean against it,
// so nothing is written back which is what stops a save ringing forever round the one-way
// flow.
session.adopt(diskBody: "typed\n")
#expect(!session.isDirty)
#expect(session.flush() == .unchanged)
await waitUntil { spy.count > 1 }
#expect(spy.count == 1)
}
@Test("An external edit under a clean buffer is not written back either")
func aForeignEditUnderACleanBufferIsNotWrittenBack() async {
let spy = SaveSpy()
let session = makeSession(spy)
// Somebody else rewrote the file while the window sat there reading it. "A clean buffer
// follows disk" (05 Write rules) and following disk is not a reason to write to it.
session.adopt(diskBody: "theirs\n")
#expect(session.text == "theirs\n")
#expect(!session.isDirty)
#expect(session.flush() == .unchanged)
await waitUntil { spy.count > 0 }
#expect(spy.count == 0)
}
}
// MARK: - Dirty-buffer-wins
@MainActor
@Suite("Card body ▸ dirty-buffer-wins")
struct CardBodyDirtyBufferTests {
@Test("A snapshot never reloads a dirty buffer under the cursor")
func aDirtyBufferKeepsItsText() {
let spy = SaveSpy()
let session = makeSession(spy)
session.edited("mine, unsaved\n")
// The board, Preview and every other window take the new snapshot; this buffer does not.
session.adopt(diskBody: "theirs\n")
#expect(session.text == "mine, unsaved\n")
#expect(session.disk == "theirs\n", "the buffer knows what disk says — it just isn't showing it")
#expect(session.isDirty)
}
@Test("The buffer's own save then lands over the foreign edit — last writer wins")
func theFlushOverwritesTheForeignEdit() {
let spy = SaveSpy()
let session = makeSession(spy)
session.edited("mine, unsaved\n")
session.adopt(diskBody: "theirs\n")
#expect(session.flush() == .written)
#expect(spy.written == ["mine, unsaved\n"], "deliberate last-writer-wins (05 ▸ Write rules)")
#expect(!session.isDirty)
}
@Test("A foreign edit that happens to match the buffer settles it clean, writing nothing")
func aConvergentForeignEditNeedsNoWrite() async {
let spy = SaveSpy()
let session = makeSession(spy)
session.edited("same text\n")
// An agent wrote exactly what the user was typing. The file already says what they mean, so
// re-writing it would only stamp `modified`.
session.adopt(diskBody: "same text\n")
#expect(!session.isDirty)
await waitUntil { spy.count > 0 }
#expect(spy.count == 0)
}
@Test("A failed save keeps the buffer dirty, and the text")
func aFailureKeepsTheText() {
let spy = SaveSpy()
let session = makeSession(spy)
spy.outcome = .failed(BoardWriteError(
operation: .editBody(title: "Notes"),
path: "/x/index.md",
reason: .io(message: "the disk is full")
))
session.edited("precious\n")
let outcome = session.flush()
guard case .failed = outcome else {
Issue.record("expected the failure to be reported, got \(outcome)")
return
}
// "Nothing is lost while the window stays open" (02 § Write-failure surfacing): the text is
// still here, still dirty, and the next flush will try again.
#expect(session.text == "precious\n")
#expect(session.isDirty)
}
@Test("A suspended save — the read-only lock — also keeps the buffer, and does not throw a close")
func aSuspendedSaveKeepsTheBuffer() throws {
let spy = SaveSpy()
let session = makeSession(spy)
spy.outcome = .suspended(.unwritableLocation)
session.edited("held\n")
#expect(session.flush() == .suspended(.unwritableLocation))
#expect(session.isDirty)
// The close-time guard treats it as a non-failure: no write was attempted, the lock row has
// been standing all along, and a modal offering Try Again could only fail again.
try session.flushOrThrow()
}
@Test("A real failure is what the close-time modal is raised on")
func aFailureThrowsForTheGuard() {
let spy = SaveSpy()
let session = makeSession(spy)
spy.outcome = .failed(BoardWriteError(operation: .editBody(title: nil), path: "/x", reason: .io(message: "nope")))
session.edited("unsaved\n")
#expect(throws: BoardWriteError.self) { try session.flushOrThrow() }
}
@Test("A session with nowhere to write keeps its text rather than reporting success")
func aSessionWithNoDestinationHoldsOn() {
let session = CardBodyEditSession()
session.adopt(diskBody: "start\n")
session.edited("typed\n")
#expect(session.flush() == .vanished)
#expect(session.isDirty)
#expect(session.text == "typed\n")
}
}
// MARK: - The debounce
@MainActor
@Suite("Card body ▸ the debounce")
struct CardBodyDebounceTests {
@Test("Typing saves once the keystrokes stop")
func typingSavesAfterTheInterval() async {
let spy = SaveSpy()
let session = makeSession(spy)
session.edited("t\n")
#expect(spy.count == 0, "not on the keystroke itself")
await waitUntil { spy.count == 1 }
#expect(spy.written == ["t\n"])
#expect(!session.isDirty)
}
@Test("A burst of keystrokes is one save, of the last text")
func aBurstCoalesces() async {
let spy = SaveSpy()
let session = makeSession(spy)
for text in ["a", "ab", "abc", "abcd"] {
session.edited(text)
}
await waitUntil { spy.count >= 1 }
// Trailing debounce: each keystroke restarts the clock, so the run costs one write rather
// than one per character.
#expect(spy.written == ["abcd"])
}
@Test("A flush does not wait for the debounce, and the debounce does not fire behind it")
func aFlushPreemptsThePendingSave() async {
let spy = SaveSpy()
let session = makeSession(spy)
session.edited("typed\n")
#expect(session.flush() == .written)
#expect(spy.count == 1, "the flush wrote immediately — no flush lag on a mode exit (05)")
// And the timer it cancelled does not come back to write the same text a second time.
await waitUntil { spy.count > 1 }
#expect(spy.count == 1)
}
@Test("The production interval is ~700 ms")
func theDefaultIntervalIsTheDesignsNumber() {
// The only thing the seam must not do is quietly change the number the design settled
// (05 Edit: "Saved on a ~700 ms debounce").
#expect(CardBodyEditSession().debounceInterval == .milliseconds(700))
}
@Test("Ending the session flushes — the Edit→Preview flip is the effective Save")
func endingTheSessionFlushes() {
let spy = SaveSpy()
let session = makeSession(spy)
session.edited("typed on the way out\n")
#expect(session.endEditSession() == .written)
#expect(spy.written == ["typed on the way out\n"])
}
}
// MARK: - The mode flip
@MainActor
@Suite("Card body ▸ leaving Edit flushes")
struct CardBodyModeFlushTests {
@Test("Every way out of Edit flushes first")
func leavingEditFlushes() {
let presentation = CardBodyPresentation()
var flushes = 0
presentation.flushEdits = { flushes += 1 }
presentation.openIfNeeded(body: "")
#expect(presentation.mode == .edit)
// E / the menu item's toggle
presentation.toggleMode()
#expect(presentation.mode == .preview)
#expect(flushes == 1)
// Escape in the editor, which is the same flip through the same door
presentation.setMode(.edit)
#expect(flushes == 1, "entering Edit flushes nothing — there is nothing to flush yet")
presentation.setMode(.preview)
#expect(flushes == 2)
}
@Test("Setting the mode it already has flushes nothing")
func aRedundantSetIsNotAFlush() {
let presentation = CardBodyPresentation()
var flushes = 0
presentation.flushEdits = { flushes += 1 }
presentation.setMode(.edit)
presentation.setMode(.edit)
presentation.setMode(.edit)
#expect(flushes == 0, "a re-published focus value must not commit an untouched buffer")
}
}
+240
View File
@@ -0,0 +1,240 @@
import AppKit
import Foundation
import Testing
@testable import Kanban
/// The Edit editor's syntax highlighting (05-card-window.md Edit).
///
/// The suite exists for one promise above all others **"highlighting is presentation only: the text
/// is the raw Markdown, character for character"** and the whole reason the highlighter emits
/// `[Span]` rather than an attributed string is so that promise is checkable rather than merely
/// intended. The invariants below (in bounds, in order, non-overlapping, string unchanged after a
/// full application) hold for *every* input, so they are asserted over a corpus of deliberately
/// broken Markdown as well as over the tidy examples.
// MARK: - Helpers
private func spans(_ text: String) -> [MarkdownHighlighter.Span] {
MarkdownHighlighter.spans(in: text)
}
/// The substrings a token claims, in order assertions read as "what is bold here?" rather than as
/// arithmetic over offsets.
private func text(of text: String, token: MarkdownHighlighter.Token) -> [String] {
let ns = text as NSString
return spans(text).filter { $0.token == token }.map { ns.substring(with: $0.range) }
}
/// Every span's substring, whatever its token.
private func claimed(_ text: String, where predicate: (MarkdownHighlighter.Token) -> Bool) -> [String] {
let ns = text as NSString
return spans(text).filter { predicate($0.token) }.map { ns.substring(with: $0.range) }
}
// MARK: - Invariants
@Suite("Markdown highlighter ▸ invariants")
struct MarkdownHighlighterInvariantTests {
/// Tidy Markdown, half-typed Markdown, and text that is not Markdown at all the editor holds
/// all three, usually within a second of each other.
static let corpus: [String] = [
"",
"\n",
"plain prose with no markup at all",
"# Heading\n\nBody *text* here.\n",
"**bo",
"[label](",
"`unclosed code",
"~~~\nfence with no close\n",
"***",
"- [ ] task\n- [x] done\n - nested\n",
"> quoted **bold**\n>> deeper\n",
"| a | b |\n| - | - |\n| 1 | 2 |\n",
"snake_case_identifier and 2 * 3 * 4\n",
"```swift\nlet x = **not bold**\n```\n",
" indented code\n",
"emoji 🇬🇧 and combining é in *italics*\n",
"<https://example.com> and ![alt](shot.png)\n"
]
@Test("Every span lands inside the text, in order, without overlapping")
func spansPartitionCleanly() {
for sample in Self.corpus {
let length = (sample as NSString).length
var previousEnd = 0
for span in spans(sample) {
#expect(span.range.location >= 0)
#expect(span.range.upperBound <= length, "a span past the end of \(sample.debugDescription)")
#expect(span.range.location >= previousEnd, "spans overlap or go backwards in \(sample.debugDescription)")
previousEnd = span.range.upperBound
}
}
}
@MainActor
@Test("Applying the whole pass never changes a single character")
func applyingNeverAltersTheString() {
for sample in Self.corpus {
let storage = NSTextStorage(string: sample)
MarkdownHighlighter.highlight(storage, pointSize: 13)
#expect(storage.string == sample, "the highlighter rewrote \(sample.debugDescription)")
// And again, because an idempotent pass is what running on every keystroke amounts to.
MarkdownHighlighter.highlight(storage, pointSize: 13)
#expect(storage.string == sample)
}
}
@MainActor
@Test("A pass leaves no attributes from the pass before it")
func attributesAreRebuiltRatherThanAccumulated() {
let storage = NSTextStorage(string: "# Heading\n")
MarkdownHighlighter.highlight(storage, pointSize: 13)
// The user deletes the `#`: what was a heading is now prose, and must be drawn as prose.
storage.replaceCharacters(in: NSRange(location: 0, length: 2), with: "")
MarkdownHighlighter.highlight(storage, pointSize: 13)
let base = MarkdownHighlighter.baseAttributes(pointSize: 13)
let font = storage.attribute(.font, at: 0, effectiveRange: nil) as? NSFont
#expect(font == base[.font] as? NSFont, "a stale heading font would survive the edit that ended the heading")
}
}
// MARK: - Blocks
@Suite("Markdown highlighter ▸ blocks")
struct MarkdownHighlighterBlockTests {
@Test("A heading is its marker, dimmed, and its text, emphasized")
func headings() {
#expect(text(of: "# Title\n", token: .heading(level: 1)) == [" Title"])
#expect(text(of: "### Deeper\n", token: .heading(level: 3)) == [" Deeper"])
#expect(text(of: "# Title\n", token: .structural) == ["#"])
// Seven hashes is not a heading in CommonMark, and is not one here either.
#expect(text(of: "####### nope\n", token: .heading(level: 7)).isEmpty)
#expect(text(of: "#nospace\n", token: .heading(level: 1)).isEmpty)
}
@Test("List markers and task boxes are the marker, not the text")
func listMarkers() {
#expect(text(of: "- item\n", token: .listMarker) == ["-"])
#expect(text(of: "1. item\n", token: .listMarker) == ["1."])
#expect(text(of: " * nested\n", token: .listMarker) == ["*"])
// The checkbox belongs to the marker: `- [x] done` reads as one control plus a label.
#expect(text(of: "- [x] done\n", token: .listMarker) == ["-", " [x]"])
#expect(text(of: "- [ ] todo\n", token: .listMarker) == ["-", " [ ]"])
}
@Test("A thematic break is structure, and is not three list markers")
func thematicBreaks() {
#expect(text(of: "---\n", token: .structural) == ["---"])
#expect(text(of: "***\n", token: .structural) == ["***"])
#expect(text(of: "---\n", token: .listMarker).isEmpty)
}
@Test("A quote's chevrons dim and its content still highlights")
func quotes() {
#expect(text(of: "> quoted **bold**\n", token: .structural).contains(">"))
#expect(text(of: "> quoted **bold**\n", token: .strong) == ["bold"])
}
@Test("A fenced block is code from fence to fence, whatever is inside it")
func fencedCode() {
let sample = "```swift\nlet x = **not bold**\n# not a heading\n```\nafter\n"
#expect(text(of: sample, token: .strong).isEmpty, "markup inside a fence is code, not markup")
#expect(text(of: sample, token: .heading(level: 1)).isEmpty)
#expect(text(of: sample, token: .code).contains("let x = **not bold**"))
#expect(text(of: sample, token: .code).contains("# not a heading"))
// The info string is dimmed with the fence rather than tinted as code.
#expect(text(of: sample, token: .linkTarget) == ["swift"])
// And the block ends: text after the closing fence is ordinary again.
#expect(!text(of: sample, token: .code).contains("after"))
}
@Test("A tilde fence is not closed by a backtick fence")
func fenceMarkersMustMatch() {
let sample = "~~~\ncode\n```\nstill code\n~~~\nout\n"
#expect(text(of: sample, token: .code).contains("still code"))
#expect(!text(of: sample, token: .code).contains("out"))
}
@Test("An unclosed fence simply runs to the end — an editor is full of half-typed blocks")
func anUnclosedFenceDoesNotBreakTheRest() {
let sample = "```\ncode\nmore code\n"
#expect(text(of: sample, token: .code) == ["code", "more code"])
}
@Test("Indented code is code")
func indentedCode() {
#expect(text(of: " let x = 1\n", token: .code) == [" let x = 1"])
#expect(text(of: "\tlet x = 1\n", token: .code) == ["\tlet x = 1"])
}
}
// MARK: - Inlines
@Suite("Markdown highlighter ▸ inlines")
struct MarkdownHighlighterInlineTests {
@Test("Bold, italic and strikethrough style their content and dim their delimiters")
func emphasis() {
#expect(text(of: "a **bold** b\n", token: .strong) == ["bold"])
#expect(text(of: "a **bold** b\n", token: .structural) == ["**", "**"])
#expect(text(of: "a *italic* b\n", token: .emphasis) == ["italic"])
#expect(text(of: "a _italic_ b\n", token: .emphasis) == ["italic"])
#expect(text(of: "a ~~struck~~ b\n", token: .strikethrough) == ["struck"])
// `**` is tried before `*`, so bold is bold rather than two adjacent italics.
#expect(text(of: "**bold**\n", token: .emphasis).isEmpty)
}
@Test("Intraword underscores are not emphasis")
func underscoresInWords() {
#expect(text(of: "snake_case_name here\n", token: .emphasis).isEmpty)
#expect(text(of: "2 * 3 * 4\n", token: .emphasis).isEmpty, "spaced asterisks are arithmetic")
}
@Test("A code span tints its content and outranks the markup inside it")
func codeSpans() {
#expect(text(of: "use `let x = **y**` here\n", token: .code) == ["let x = **y**"])
#expect(text(of: "use `let x = **y**` here\n", token: .strong).isEmpty)
#expect(claimed("`a`\n") { $0 == .structural } == ["`", "`"])
}
@Test("A link's text reads as text and its target dims")
func links() {
let sample = "see [the docs](https://example.com/x) now\n"
#expect(text(of: sample, token: .linkText) == ["the docs"])
#expect(text(of: sample, token: .linkTarget) == ["https://example.com/x"])
// The brackets, the parens and an image's `!` are all structure.
#expect(text(of: sample, token: .structural) == ["[", "](", ")"])
#expect(text(of: "![alt](shot.png)\n", token: .structural) == ["![", "](", ")"])
#expect(text(of: "<https://example.com>\n", token: .linkTarget) == ["<https://example.com>"])
}
@Test("Emphasis inside a link's text does not eat the link")
func overlappingConstructsResolveByPrecedence() {
let sample = "[a **b** c](url)\n"
#expect(text(of: sample, token: .linkText) == ["a **b** c"])
#expect(text(of: sample, token: .linkTarget) == ["url"])
#expect(text(of: sample, token: .strong).isEmpty, "the link claimed the run first")
}
@Test("Half-typed markup styles what is there and invents nothing")
func halfTypedMarkup() {
// The delimiters dim as they are typed; the run styles when it closes. Nothing about this
// is an error state, which is the whole reason the editor scans lines rather than parsing.
#expect(text(of: "**bo\n", token: .strong).isEmpty)
#expect(text(of: "[label](\n", token: .linkText).isEmpty)
#expect(text(of: "`unclosed\n", token: .code).isEmpty)
}
@Test("Offsets survive text no ASCII assumption would")
func unicodeOffsets() {
// NSRange is UTF-16, and an emoji flag is two code units before the markup even starts
// a highlighter counting characters would style the wrong run here.
let sample = "🇬🇧 flag then **bold**\n"
#expect(text(of: sample, token: .strong) == ["bold"])
#expect(text(of: "é *accented* text\n", token: .emphasis) == ["accented"])
}
}