Files
lanework/KanbanTests/MarkdownHighlighterTests.swift
rzen e989c1f26e 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
2026-07-28 10:46:02 -04:00

241 lines
11 KiB
Swift

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"])
}
}