diff --git a/virtualizer/test/exactness.test.ts b/virtualizer/test/exactness.test.ts new file mode 100644 index 0000000..56877ee --- /dev/null +++ b/virtualizer/test/exactness.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "./suite.ts"; +import { Virtualizer } from "../mod.ts"; + +function charMeasure(text: string): number { + let cp = text.codePointAt(0)!; + if (cp >= 0x4e00 && cp <= 0x9fff) return 2; + if (cp < 0x20) return 0; + return 1; +} + +describe("Exactness vs approximation structural tests", () => { + it("Exact viewport assertions — O-1 through O-9 hold with varied content", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 15, rows: 10 }); + v.appendLine("short"); + v.appendLine("a".repeat(30)); + v.appendLine("文字abc文字def"); + v.appendLine("\x1b[31mcolored\x1b[0m text"); + v.appendLine(""); + + let vp = v.resolveViewport(); + + // O-1: entries ordered + for (let i = 1; i < vp.entries.length; i++) { + expect(vp.entries[i].lineIndex).toBeGreaterThan(vp.entries[i - 1].lineIndex); + } + + for (let entry of vp.entries) { + // O-3: wrapPoints monotonic + for (let j = 1; j < entry.wrapPoints.length; j++) { + expect(entry.wrapPoints[j]).toBeGreaterThan(entry.wrapPoints[j - 1]); + } + // O-6: totalSubRows + expect(entry.totalSubRows).toBe(entry.wrapPoints.length + 1); + // O-7: subrow bounds + expect(entry.firstSubRow).toBeGreaterThanOrEqual(0); + expect(entry.visibleSubRows).toBeGreaterThanOrEqual(0); + expect(entry.firstSubRow + entry.visibleSubRows).toBeLessThanOrEqual(entry.totalSubRows); + } + + // O-8: total visible ≤ rows + let totalVisible = vp.entries.reduce((s, e) => s + e.visibleSubRows, 0); + expect(totalVisible).toBeLessThanOrEqual(10); + }); + + it("Structural estimation non-negative — totalEstimatedVisualRows ≥ 0 after random ops", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 20, rows: 10, maxLines: 50 }); + for (let i = 0; i < 100; i++) { + v.appendLine("x".repeat(i % 40)); + } + v.scrollBy(-30); + v.resize(15, 10); + expect(v.totalEstimatedVisualRows).toBeGreaterThanOrEqual(0); + }); + + it("Structural estimation zero-empty — both fields 0 on empty buffer", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + expect(v.totalEstimatedVisualRows).toBe(0); + expect(v.currentEstimatedVisualRow).toBe(0); + }); + + it("Structural estimation positive-nonempty — totalEstimatedVisualRows > 0 when lineCount > 0", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + v.appendLine("x"); + expect(v.totalEstimatedVisualRows).toBeGreaterThan(0); + }); + + it("Structural currentEstimate inequality — valid range when lineCount > 0", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 20, rows: 10 }); + for (let i = 0; i < 50; i++) v.appendLine("a".repeat(i % 30)); + v.scrollBy(-20); + expect(v.currentEstimatedVisualRow).toBeGreaterThanOrEqual(0); + expect(v.currentEstimatedVisualRow).toBeLessThan(v.totalEstimatedVisualRows); + }); + + it("Reference-behavior exact formula — estimation matches ceil(displayWidth/columns)", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 10, rows: 24 }); + let before = v.totalEstimatedVisualRows; + v.appendLine("a".repeat(15)); // displayWidth=15, ceil(15/10)=2 + expect(v.totalEstimatedVisualRows - before).toBe(2); + }); +}); diff --git a/virtualizer/test/resize.test.ts b/virtualizer/test/resize.test.ts new file mode 100644 index 0000000..0521edb --- /dev/null +++ b/virtualizer/test/resize.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from "./suite.ts"; +import { Virtualizer } from "../mod.ts"; +import { skipAnsiSequence } from "../ansi-scanner.ts"; + +function charMeasure(text: string): number { + let cp = text.codePointAt(0)!; + if (cp >= 0x4e00 && cp <= 0x9fff) return 2; + if (cp < 0x20) return 0; + return 1; +} + +function sliceAtWrapPoints(text: string, wrapPoints: number[]): string[] { + if (wrapPoints.length === 0) return [text]; + let slices: string[] = []; + let prev = 0; + for (let wp of wrapPoints) { + slices.push(text.slice(prev, wp)); + prev = wp; + } + slices.push(text.slice(prev)); + return slices; +} + +function stripAnsi(text: string): string { + let result = ""; + let i = 0; + while (i < text.length) { + let skip = skipAnsiSequence(text, i); + if (skip > 0) { i += skip; continue; } + result += text[i]; + i++; + } + return result; +} + +function visibleWidth(text: string): number { + let stripped = stripAnsi(text); + let w = 0; + for (let i = 0; i < stripped.length; i++) { + let cp = stripped.codePointAt(i)!; + if (cp > 0xffff) { i++; w += 2; } + else if (cp >= 0x4e00 && cp <= 0x9fff) w += 2; + else if (cp < 0x20) w += 0; + else w += 1; + } + return w; +} + +describe("C.RESIZE — resize", () => { + it("C.RESIZE.wrap-cache-invalidated — wrap points recomputed after column change", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + v.appendLine("a".repeat(100)); + let vp1 = v.resolveViewport(); + expect(vp1.entries[0].totalSubRows).toBe(2); // ceil(100/80)=2 + + v.resize(50, 24); + let vp2 = v.resolveViewport(); + expect(vp2.entries[0].totalSubRows).toBe(2); // ceil(100/50)=2, but with exact wrapping + // Verify wrap points are different from width-80 + expect(vp2.entries[0].wrapPoints).not.toEqual(vp1.entries[0].wrapPoints); + }); + + it("C.RESIZE.anchor-subrow-clamped — anchorSubRow clamped to new wrap count", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 20, rows: 24 }); + v.appendLine("a".repeat(100)); // 5 sub-rows at width 20 + v.scrollBy(4); // anchorSubRow=4 + expect(v.anchorSubRow).toBe(4); + + v.resize(50, 24); // now 2 sub-rows → clamp to 1 + expect(v.anchorSubRow).toBe(1); + }); + + it("C.RESIZE.bottom-follow-preserved — isAtBottom survives resize", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + v.appendLine("hello"); + expect(v.isAtBottom).toBe(true); + v.resize(40, 24); + expect(v.isAtBottom).toBe(true); + }); + + it("C.RESIZE.row-only-no-invalidation — row-only change does not affect wrapping", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + v.appendLine("a".repeat(100)); + let vp1 = v.resolveViewport(); + let total1 = v.totalEstimatedVisualRows; + let anchor1 = v.anchorLineIndex; + + v.resize(80, 40); + expect(v.totalEstimatedVisualRows).toBe(total1); + expect(v.anchorLineIndex).toBe(anchor1); + }); + + it("C.RESIZE.viewport-correct-after-resize — all output invariants hold after resize", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + v.appendLine("short"); + v.appendLine("a".repeat(100)); + v.appendLine("文字".repeat(20)); + + v.resize(40, 24); + let vp = v.resolveViewport(); + + // O-1: entries ordered + for (let i = 1; i < vp.entries.length; i++) { + expect(vp.entries[i].lineIndex).toBeGreaterThan(vp.entries[i - 1].lineIndex); + } + + for (let entry of vp.entries) { + // O-6: totalSubRows + expect(entry.totalSubRows).toBe(entry.wrapPoints.length + 1); + // O-7: subrow bounds + expect(entry.firstSubRow + entry.visibleSubRows).toBeLessThanOrEqual(entry.totalSubRows); + // O-9: sliced width within columns + let slices = sliceAtWrapPoints(entry.text, entry.wrapPoints); + for (let slice of slices) { + expect(visibleWidth(slice)).toBeLessThanOrEqual(40); + } + } + + // O-8: visible rows within budget + let totalVisible = vp.entries.reduce((s, e) => s + e.visibleSubRows, 0); + expect(totalVisible).toBeLessThanOrEqual(24); + }); + + it("C.RESIZE.estimation-fields-valid-after-resize — estimation constraints hold", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + for (let i = 0; i < 100; i++) v.appendLine(`line ${i}`); + + v.resize(40, 24); + expect(v.totalEstimatedVisualRows).toBeGreaterThan(0); + expect(v.currentEstimatedVisualRow).toBeGreaterThanOrEqual(0); + expect(v.currentEstimatedVisualRow).toBeLessThan(v.totalEstimatedVisualRows); + }); + + it("C.RESIZE.displayWidth-unchanged — cached displayWidth unchanged across resize", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + let idx = v.appendLine("hello"); + let before = v.getLineDisplayWidth(idx); + v.resize(40, 24); + expect(v.getLineDisplayWidth(idx)).toBe(before); + }); + + it("C.RESIZE.empty-buffer — resize on empty buffer does not error", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + v.resize(40, 24); + let vp = v.resolveViewport(); + expect(vp.entries.length).toBe(0); + expect(vp.totalEstimatedVisualRows).toBe(0); + }); +}); + +describe("G.RESIZE — resize golden fixtures", () => { + it("G.RESIZE.narrow-to-wide — wrapping removed when columns increase", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 40, rows: 24 }); + v.appendLine("a".repeat(80)); + let vp1 = v.resolveViewport(); + expect(vp1.entries[0].totalSubRows).toBe(2); + + v.resize(80, 24); + let vp2 = v.resolveViewport(); + expect(vp2.entries[0].totalSubRows).toBe(1); + expect(vp2.entries[0].wrapPoints).toEqual([]); + }); + + it("G.RESIZE.wide-to-narrow — wrapping added when columns decrease", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + v.appendLine("a".repeat(80)); + let vp1 = v.resolveViewport(); + expect(vp1.entries[0].totalSubRows).toBe(1); + + v.resize(20, 24); + let vp2 = v.resolveViewport(); + expect(vp2.entries[0].totalSubRows).toBe(4); + expect(vp2.entries[0].wrapPoints.length).toBe(3); + }); +}); + +describe("C.APPEND.caches-displayWidth (deferred from PR 2)", () => { + it("C.APPEND.caches-displayWidth — displayWidth unchanged after resize", () => { + let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 }); + let idx = v.appendLine("abc"); + expect(v.getLineDisplayWidth(idx)).toBe(3); + v.resize(2, 24); + expect(v.getLineDisplayWidth(idx)).toBe(3); + }); +}); diff --git a/virtualizer/virtualizer.ts b/virtualizer/virtualizer.ts index 22fd18e..ad9c258 100644 --- a/virtualizer/virtualizer.ts +++ b/virtualizer/virtualizer.ts @@ -135,8 +135,46 @@ export class Virtualizer { return newLineIndex; } - resize(_columns: number, _rows: number): void { - throw new Error("not implemented"); + /** + * resize(columns, rows) — §10.3 + * + * On column-width change: clear wrap cache, recompute estimation, + * clamp anchor sub-row, recompute currentEstimatedVisualRow. + * On row-only change: just update rows. + */ + resize(columns: number, rows: number): void { + if (columns !== this._columns) { + // Column width changed — invalidate wrap cache (INV-5) + this._wrapCache.clear(); + + // Recompute totalEstimatedVisualRows with new column width + let newTotal = 0; + let baseIndex = this._ringBuffer.baseIndex; + for (let i = baseIndex; i < baseIndex + this._ringBuffer.lineCount; i++) { + let entry = this._ringBuffer.get(i); + if (!entry) break; + newTotal += Math.max(1, Math.ceil(entry.displayWidth / columns)); + } + this._totalEstimatedVisualRows = newTotal; + + // Clamp anchor sub-row at new width + if (this._ringBuffer.lineCount > 0) { + let anchorEntry = this._ringBuffer.get(this._anchorLineIndex); + if (anchorEntry) { + let newEstimate = Math.max(1, Math.ceil(anchorEntry.displayWidth / columns)); + if (this._anchorSubRow >= newEstimate) { + this._anchorSubRow = newEstimate - 1; + } + } + } + + this._columns = columns; + + // Recompute currentEstimatedVisualRow + this._recomputeCurrentEstimate(); + } + + this._rows = rows; } /**