From 0f18fb174ecdcb7aacb6973b739c26606eaacbd3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 08:45:45 +0000 Subject: [PATCH 1/2] test: property-check display-width clustering and parseKey totality Six fast-check properties over the ui-02 measurement layer and the stdin key parser: - displayWidth is total, integer, non-negative, and bounded by two columns per code point over an adversarial alphabet (ZWJ, VS-16, keycap, combining marks, skin tones, regional indicators, CJK, emoji) - plain alphabets (no joiners/modifiers) sum per-character widths exactly and concatenate additively - the table formatter's standing assumption - truncateToWidth returns a self-consistent prefix (its reported width IS displayWidth of the kept text), stays in budget, stops only when the remaining gap is 0 or 1 columns (clusters max out at 2), and is idempotent - truncation prefixes grow monotonically with the width budget - parseKey is total over arbitrary byte buffers and only returns known KeyAction values - the full recognized-sequence table is pinned, and everything else maps to null Companion to the property suites in #574/#575/#592-#597. https://claude.ai/code/session_01XNtnkLbBiXZxfQQYLMpucB --- test/property/display-width.property.test.ts | 180 +++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 test/property/display-width.property.test.ts diff --git a/test/property/display-width.property.test.ts b/test/property/display-width.property.test.ts new file mode 100644 index 00000000..a96d40fa --- /dev/null +++ b/test/property/display-width.property.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vitest"; +import * as fc from "fast-check"; +import { displayWidth, truncateToWidth } from "../../lib/ui/display-width.js"; +import { parseKey, type KeyAction } from "../../lib/ui/ansi.js"; + +// Adversarial code-point alphabet: plain ASCII and CJK, plus every cluster +// mechanic the implementation special-cases — ZWJ, variation selector-16, +// keycap, combining marks, skin-tone modifiers, regional indicators, emoji. +const arbAdversarialText = fc + .array( + fc.constantFrom( + "a", + "Z", + "7", + " ", + "漢", + "한", + "🚀", + "👩", + "👨", + "☀", + "❤", + "‍", // ZWJ + "️", // variation selector-16 + "⃣", // combining enclosing keycap + "́", // combining acute accent + "\u{1f3fb}", // skin-tone modifier + "\u{1f1e6}", // regional indicator A + "\u{1f1fa}", // regional indicator U + ), + { minLength: 0, maxLength: 16 }, + ) + .map((chars) => chars.join("")); + +// Plain alphabet with no joiners/modifiers: per-character widths are +// independent, giving an exact sum oracle (the table-formatter assumption). +const PLAIN_WIDTHS: ReadonlyArray = [ + ["a", 1], + ["B", 1], + ["7", 1], + [" ", 1], + ["-", 1], + ["é", 1], + ["漢", 2], + ["字", 2], + ["한", 2], + ["🚀", 2], +]; + +const arbPlainText = fc.array( + fc.constantFrom(...PLAIN_WIDTHS.map(([char]) => char)), + { minLength: 0, maxLength: 20 }, +); + +const KEY_ACTIONS: readonly KeyAction[] = [ + "up", + "down", + "home", + "end", + "enter", + "escape", + "escape-start", + null, +]; + +describe("display-width property invariants", () => { + it("displayWidth is total, non-negative, and bounded by two columns per code point", () => { + fc.assert( + fc.property(arbAdversarialText, (text) => { + const width = displayWidth(text); + expect(Number.isInteger(width)).toBe(true); + expect(width).toBeGreaterThanOrEqual(0); + expect(width).toBeLessThanOrEqual([...text].length * 2); + }), + ); + }); + + it("plain text width equals the sum of per-character widths", () => { + fc.assert( + fc.property(arbPlainText, (chars) => { + const widthByChar = new Map(PLAIN_WIDTHS); + const expected = chars.reduce( + (sum, char) => sum + (widthByChar.get(char) ?? 0), + 0, + ); + expect(displayWidth(chars.join(""))).toBe(expected); + // With no joiners or modifiers in the alphabet, concatenation is + // exactly additive — the assumption the table formatter relies on. + const half = Math.floor(chars.length / 2); + const left = chars.slice(0, half).join(""); + const right = chars.slice(half).join(""); + expect(displayWidth(left) + displayWidth(right)).toBe(expected); + }), + ); + }); + + it("truncateToWidth returns a self-consistent, in-budget prefix that is maximal", () => { + fc.assert( + fc.property( + arbAdversarialText, + fc.integer({ min: 0, max: 20 }), + (text, maxWidth) => { + const { text: kept, width } = truncateToWidth(text, maxWidth); + // Self-consistency: the reported width is the measurer's answer. + expect(displayWidth(kept)).toBe(width); + expect(width).toBeLessThanOrEqual(maxWidth); + expect(text.startsWith(kept)).toBe(true); + if (kept !== text) { + // Maximality: clusters are at most 2 columns wide, so a gap of + // 2+ columns means the next cluster would have fit — the only + // legal reason to stop early is a remaining gap of 0 or 1. + expect(maxWidth - width).toBeLessThanOrEqual(1); + } + // Idempotence: re-truncating the kept prefix changes nothing. + const again = truncateToWidth(kept, maxWidth); + expect(again.text).toBe(kept); + expect(again.width).toBe(width); + }, + ), + ); + }); + + it("truncation prefixes grow monotonically with the width budget", () => { + fc.assert( + fc.property( + arbAdversarialText, + fc.integer({ min: 0, max: 18 }), + fc.integer({ min: 0, max: 6 }), + (text, smaller, delta) => { + const narrow = truncateToWidth(text, smaller); + const wide = truncateToWidth(text, smaller + delta); + expect(wide.text.startsWith(narrow.text)).toBe(true); + expect(wide.width).toBeGreaterThanOrEqual(narrow.width); + }, + ), + ); + }); +}); + +describe("parseKey property invariants", () => { + it("is total over arbitrary byte buffers and only ever returns known actions", () => { + fc.assert( + fc.property(fc.uint8Array({ maxLength: 12 }), (bytes) => { + const action = parseKey(Buffer.from(bytes)); + expect(KEY_ACTIONS.includes(action)).toBe(true); + }), + ); + }); + + it("recognized sequences are stable and unrecognized ones map to null", () => { + const table: ReadonlyArray = [ + ["\x1b[A", "up"], + ["\x1bOA", "up"], + ["\x1b[B", "down"], + ["\x1bOB", "down"], + ["\x1b[H", "home"], + ["\x1bOH", "home"], + ["\x1b[1~", "home"], + ["\x1b[7~", "home"], + ["\x1b[F", "end"], + ["\x1bOF", "end"], + ["\x1b[4~", "end"], + ["\x1b[8~", "end"], + ["\r", "enter"], + ["\n", "enter"], + ["\x03", "escape"], + ["\x1b", "escape-start"], + ]; + const known = new Set(table.map(([sequence]) => sequence)); + for (const [sequence, action] of table) { + expect(parseKey(Buffer.from(sequence))).toBe(action); + } + fc.assert( + fc.property(fc.string({ maxLength: 6 }), (input) => { + fc.pre(!known.has(input)); + expect(parseKey(Buffer.from(input))).toBeNull(); + }), + ); + }); +}); From 70707c978a60c5b1ca55788a3dfc343b8f224f18 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 08:53:14 +0000 Subject: [PATCH 2/2] test: make the 2-column cluster granularity self-enforcing One extra budget column can admit at most two more columns of content, which is externally equivalent to 'no cluster wider than 2' - the assumption the truncation maximality bound relies on. https://claude.ai/code/session_01XNtnkLbBiXZxfQQYLMpucB --- test/property/display-width.property.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/property/display-width.property.test.ts b/test/property/display-width.property.test.ts index a96d40fa..82ca4040 100644 --- a/test/property/display-width.property.test.ts +++ b/test/property/display-width.property.test.ts @@ -131,6 +131,13 @@ describe("display-width property invariants", () => { const wide = truncateToWidth(text, smaller + delta); expect(wide.text.startsWith(narrow.text)).toBe(true); expect(wide.width).toBeGreaterThanOrEqual(narrow.width); + // Self-enforcing cluster granularity: one extra budget column can + // admit at most 2 more columns of content. This is exactly the + // "no cluster wider than 2" assumption the maximality bound in + // the truncation property relies on — if a wider cluster type is + // ever introduced, this step assertion fails first. + const step = truncateToWidth(text, smaller + 1); + expect(step.width - narrow.width).toBeLessThanOrEqual(2); }, ), );