Skip to content

Commit c35b5e2

Browse files
committed
✨ add resize + conformance + exactness tests (PR 4)
Implement Virtualizer.resize(): clear wrap cache on column change, recompute estimated visual rows, clamp anchor sub-row. Row-only changes update rows without invalidation. Add 17 new tests: C.RESIZE.* (8), G.RESIZE.* (2), deferred C.APPEND.caches-displayWidth (1), exactness structural tests (6). 109 virtualizer tests total, 249 repo-wide.
1 parent 7462e0d commit c35b5e2

3 files changed

Lines changed: 306 additions & 2 deletions

File tree

virtualizer/test/exactness.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { describe, expect, it } from "./suite.ts";
2+
import { Virtualizer } from "../mod.ts";
3+
4+
function charMeasure(text: string): number {
5+
let cp = text.codePointAt(0)!;
6+
if (cp >= 0x4e00 && cp <= 0x9fff) return 2;
7+
if (cp < 0x20) return 0;
8+
return 1;
9+
}
10+
11+
describe("Exactness vs approximation structural tests", () => {
12+
it("Exact viewport assertions — O-1 through O-9 hold with varied content", () => {
13+
let v = new Virtualizer({ measureWidth: charMeasure, columns: 15, rows: 10 });
14+
v.appendLine("short");
15+
v.appendLine("a".repeat(30));
16+
v.appendLine("文字abc文字def");
17+
v.appendLine("\x1b[31mcolored\x1b[0m text");
18+
v.appendLine("");
19+
20+
let vp = v.resolveViewport();
21+
22+
// O-1: entries ordered
23+
for (let i = 1; i < vp.entries.length; i++) {
24+
expect(vp.entries[i].lineIndex).toBeGreaterThan(vp.entries[i - 1].lineIndex);
25+
}
26+
27+
for (let entry of vp.entries) {
28+
// O-3: wrapPoints monotonic
29+
for (let j = 1; j < entry.wrapPoints.length; j++) {
30+
expect(entry.wrapPoints[j]).toBeGreaterThan(entry.wrapPoints[j - 1]);
31+
}
32+
// O-6: totalSubRows
33+
expect(entry.totalSubRows).toBe(entry.wrapPoints.length + 1);
34+
// O-7: subrow bounds
35+
expect(entry.firstSubRow).toBeGreaterThanOrEqual(0);
36+
expect(entry.visibleSubRows).toBeGreaterThanOrEqual(0);
37+
expect(entry.firstSubRow + entry.visibleSubRows).toBeLessThanOrEqual(entry.totalSubRows);
38+
}
39+
40+
// O-8: total visible ≤ rows
41+
let totalVisible = vp.entries.reduce((s, e) => s + e.visibleSubRows, 0);
42+
expect(totalVisible).toBeLessThanOrEqual(10);
43+
});
44+
45+
it("Structural estimation non-negative — totalEstimatedVisualRows ≥ 0 after random ops", () => {
46+
let v = new Virtualizer({ measureWidth: charMeasure, columns: 20, rows: 10, maxLines: 50 });
47+
for (let i = 0; i < 100; i++) {
48+
v.appendLine("x".repeat(i % 40));
49+
}
50+
v.scrollBy(-30);
51+
v.resize(15, 10);
52+
expect(v.totalEstimatedVisualRows).toBeGreaterThanOrEqual(0);
53+
});
54+
55+
it("Structural estimation zero-empty — both fields 0 on empty buffer", () => {
56+
let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 });
57+
expect(v.totalEstimatedVisualRows).toBe(0);
58+
expect(v.currentEstimatedVisualRow).toBe(0);
59+
});
60+
61+
it("Structural estimation positive-nonempty — totalEstimatedVisualRows > 0 when lineCount > 0", () => {
62+
let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 });
63+
v.appendLine("x");
64+
expect(v.totalEstimatedVisualRows).toBeGreaterThan(0);
65+
});
66+
67+
it("Structural currentEstimate inequality — valid range when lineCount > 0", () => {
68+
let v = new Virtualizer({ measureWidth: charMeasure, columns: 20, rows: 10 });
69+
for (let i = 0; i < 50; i++) v.appendLine("a".repeat(i % 30));
70+
v.scrollBy(-20);
71+
expect(v.currentEstimatedVisualRow).toBeGreaterThanOrEqual(0);
72+
expect(v.currentEstimatedVisualRow).toBeLessThan(v.totalEstimatedVisualRows);
73+
});
74+
75+
it("Reference-behavior exact formula — estimation matches ceil(displayWidth/columns)", () => {
76+
let v = new Virtualizer({ measureWidth: charMeasure, columns: 10, rows: 24 });
77+
let before = v.totalEstimatedVisualRows;
78+
v.appendLine("a".repeat(15)); // displayWidth=15, ceil(15/10)=2
79+
expect(v.totalEstimatedVisualRows - before).toBe(2);
80+
});
81+
});

virtualizer/test/resize.test.ts

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import { describe, expect, it } from "./suite.ts";
2+
import { Virtualizer } from "../mod.ts";
3+
import { skipAnsiSequence } from "../ansi-scanner.ts";
4+
5+
function charMeasure(text: string): number {
6+
let cp = text.codePointAt(0)!;
7+
if (cp >= 0x4e00 && cp <= 0x9fff) return 2;
8+
if (cp < 0x20) return 0;
9+
return 1;
10+
}
11+
12+
function sliceAtWrapPoints(text: string, wrapPoints: number[]): string[] {
13+
if (wrapPoints.length === 0) return [text];
14+
let slices: string[] = [];
15+
let prev = 0;
16+
for (let wp of wrapPoints) {
17+
slices.push(text.slice(prev, wp));
18+
prev = wp;
19+
}
20+
slices.push(text.slice(prev));
21+
return slices;
22+
}
23+
24+
function stripAnsi(text: string): string {
25+
let result = "";
26+
let i = 0;
27+
while (i < text.length) {
28+
let skip = skipAnsiSequence(text, i);
29+
if (skip > 0) { i += skip; continue; }
30+
result += text[i];
31+
i++;
32+
}
33+
return result;
34+
}
35+
36+
function visibleWidth(text: string): number {
37+
let stripped = stripAnsi(text);
38+
let w = 0;
39+
for (let i = 0; i < stripped.length; i++) {
40+
let cp = stripped.codePointAt(i)!;
41+
if (cp > 0xffff) { i++; w += 2; }
42+
else if (cp >= 0x4e00 && cp <= 0x9fff) w += 2;
43+
else if (cp < 0x20) w += 0;
44+
else w += 1;
45+
}
46+
return w;
47+
}
48+
49+
describe("C.RESIZE — resize", () => {
50+
it("C.RESIZE.wrap-cache-invalidated — wrap points recomputed after column change", () => {
51+
let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 });
52+
v.appendLine("a".repeat(100));
53+
let vp1 = v.resolveViewport();
54+
expect(vp1.entries[0].totalSubRows).toBe(2); // ceil(100/80)=2
55+
56+
v.resize(50, 24);
57+
let vp2 = v.resolveViewport();
58+
expect(vp2.entries[0].totalSubRows).toBe(2); // ceil(100/50)=2, but with exact wrapping
59+
// Verify wrap points are different from width-80
60+
expect(vp2.entries[0].wrapPoints).not.toEqual(vp1.entries[0].wrapPoints);
61+
});
62+
63+
it("C.RESIZE.anchor-subrow-clamped — anchorSubRow clamped to new wrap count", () => {
64+
let v = new Virtualizer({ measureWidth: charMeasure, columns: 20, rows: 24 });
65+
v.appendLine("a".repeat(100)); // 5 sub-rows at width 20
66+
v.scrollBy(4); // anchorSubRow=4
67+
expect(v.anchorSubRow).toBe(4);
68+
69+
v.resize(50, 24); // now 2 sub-rows → clamp to 1
70+
expect(v.anchorSubRow).toBe(1);
71+
});
72+
73+
it("C.RESIZE.bottom-follow-preserved — isAtBottom survives resize", () => {
74+
let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 });
75+
v.appendLine("hello");
76+
expect(v.isAtBottom).toBe(true);
77+
v.resize(40, 24);
78+
expect(v.isAtBottom).toBe(true);
79+
});
80+
81+
it("C.RESIZE.row-only-no-invalidation — row-only change does not affect wrapping", () => {
82+
let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 });
83+
v.appendLine("a".repeat(100));
84+
let vp1 = v.resolveViewport();
85+
let total1 = v.totalEstimatedVisualRows;
86+
let anchor1 = v.anchorLineIndex;
87+
88+
v.resize(80, 40);
89+
expect(v.totalEstimatedVisualRows).toBe(total1);
90+
expect(v.anchorLineIndex).toBe(anchor1);
91+
});
92+
93+
it("C.RESIZE.viewport-correct-after-resize — all output invariants hold after resize", () => {
94+
let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 });
95+
v.appendLine("short");
96+
v.appendLine("a".repeat(100));
97+
v.appendLine("文字".repeat(20));
98+
99+
v.resize(40, 24);
100+
let vp = v.resolveViewport();
101+
102+
// O-1: entries ordered
103+
for (let i = 1; i < vp.entries.length; i++) {
104+
expect(vp.entries[i].lineIndex).toBeGreaterThan(vp.entries[i - 1].lineIndex);
105+
}
106+
107+
for (let entry of vp.entries) {
108+
// O-6: totalSubRows
109+
expect(entry.totalSubRows).toBe(entry.wrapPoints.length + 1);
110+
// O-7: subrow bounds
111+
expect(entry.firstSubRow + entry.visibleSubRows).toBeLessThanOrEqual(entry.totalSubRows);
112+
// O-9: sliced width within columns
113+
let slices = sliceAtWrapPoints(entry.text, entry.wrapPoints);
114+
for (let slice of slices) {
115+
expect(visibleWidth(slice)).toBeLessThanOrEqual(40);
116+
}
117+
}
118+
119+
// O-8: visible rows within budget
120+
let totalVisible = vp.entries.reduce((s, e) => s + e.visibleSubRows, 0);
121+
expect(totalVisible).toBeLessThanOrEqual(24);
122+
});
123+
124+
it("C.RESIZE.estimation-fields-valid-after-resize — estimation constraints hold", () => {
125+
let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 });
126+
for (let i = 0; i < 100; i++) v.appendLine(`line ${i}`);
127+
128+
v.resize(40, 24);
129+
expect(v.totalEstimatedVisualRows).toBeGreaterThan(0);
130+
expect(v.currentEstimatedVisualRow).toBeGreaterThanOrEqual(0);
131+
expect(v.currentEstimatedVisualRow).toBeLessThan(v.totalEstimatedVisualRows);
132+
});
133+
134+
it("C.RESIZE.displayWidth-unchanged — cached displayWidth unchanged across resize", () => {
135+
let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 });
136+
let idx = v.appendLine("hello");
137+
let before = v.getLineDisplayWidth(idx);
138+
v.resize(40, 24);
139+
expect(v.getLineDisplayWidth(idx)).toBe(before);
140+
});
141+
142+
it("C.RESIZE.empty-buffer — resize on empty buffer does not error", () => {
143+
let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 });
144+
v.resize(40, 24);
145+
let vp = v.resolveViewport();
146+
expect(vp.entries.length).toBe(0);
147+
expect(vp.totalEstimatedVisualRows).toBe(0);
148+
});
149+
});
150+
151+
describe("G.RESIZE — resize golden fixtures", () => {
152+
it("G.RESIZE.narrow-to-wide — wrapping removed when columns increase", () => {
153+
let v = new Virtualizer({ measureWidth: charMeasure, columns: 40, rows: 24 });
154+
v.appendLine("a".repeat(80));
155+
let vp1 = v.resolveViewport();
156+
expect(vp1.entries[0].totalSubRows).toBe(2);
157+
158+
v.resize(80, 24);
159+
let vp2 = v.resolveViewport();
160+
expect(vp2.entries[0].totalSubRows).toBe(1);
161+
expect(vp2.entries[0].wrapPoints).toEqual([]);
162+
});
163+
164+
it("G.RESIZE.wide-to-narrow — wrapping added when columns decrease", () => {
165+
let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 });
166+
v.appendLine("a".repeat(80));
167+
let vp1 = v.resolveViewport();
168+
expect(vp1.entries[0].totalSubRows).toBe(1);
169+
170+
v.resize(20, 24);
171+
let vp2 = v.resolveViewport();
172+
expect(vp2.entries[0].totalSubRows).toBe(4);
173+
expect(vp2.entries[0].wrapPoints.length).toBe(3);
174+
});
175+
});
176+
177+
describe("C.APPEND.caches-displayWidth (deferred from PR 2)", () => {
178+
it("C.APPEND.caches-displayWidth — displayWidth unchanged after resize", () => {
179+
let v = new Virtualizer({ measureWidth: charMeasure, columns: 80, rows: 24 });
180+
let idx = v.appendLine("abc");
181+
expect(v.getLineDisplayWidth(idx)).toBe(3);
182+
v.resize(2, 24);
183+
expect(v.getLineDisplayWidth(idx)).toBe(3);
184+
});
185+
});

virtualizer/virtualizer.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,8 +135,46 @@ export class Virtualizer {
135135
return newLineIndex;
136136
}
137137

138-
resize(_columns: number, _rows: number): void {
139-
throw new Error("not implemented");
138+
/**
139+
* resize(columns, rows) — §10.3
140+
*
141+
* On column-width change: clear wrap cache, recompute estimation,
142+
* clamp anchor sub-row, recompute currentEstimatedVisualRow.
143+
* On row-only change: just update rows.
144+
*/
145+
resize(columns: number, rows: number): void {
146+
if (columns !== this._columns) {
147+
// Column width changed — invalidate wrap cache (INV-5)
148+
this._wrapCache.clear();
149+
150+
// Recompute totalEstimatedVisualRows with new column width
151+
let newTotal = 0;
152+
let baseIndex = this._ringBuffer.baseIndex;
153+
for (let i = baseIndex; i < baseIndex + this._ringBuffer.lineCount; i++) {
154+
let entry = this._ringBuffer.get(i);
155+
if (!entry) break;
156+
newTotal += Math.max(1, Math.ceil(entry.displayWidth / columns));
157+
}
158+
this._totalEstimatedVisualRows = newTotal;
159+
160+
// Clamp anchor sub-row at new width
161+
if (this._ringBuffer.lineCount > 0) {
162+
let anchorEntry = this._ringBuffer.get(this._anchorLineIndex);
163+
if (anchorEntry) {
164+
let newEstimate = Math.max(1, Math.ceil(anchorEntry.displayWidth / columns));
165+
if (this._anchorSubRow >= newEstimate) {
166+
this._anchorSubRow = newEstimate - 1;
167+
}
168+
}
169+
}
170+
171+
this._columns = columns;
172+
173+
// Recompute currentEstimatedVisualRow
174+
this._recomputeCurrentEstimate();
175+
}
176+
177+
this._rows = rows;
140178
}
141179

142180
/**

0 commit comments

Comments
 (0)