mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
test(desktop): harden Buzz Term banner invariants
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz> Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
This commit is contained in:
parent
5c92315eb6
commit
98ebc8f904
@@ -50,7 +50,19 @@ after(() => dom.window.close());
|
||||
beforeEach(() => {
|
||||
cleanup?.();
|
||||
dom.window.localStorage.clear();
|
||||
dom.window.HTMLCanvasElement.prototype.getBoundingClientRect = () => ({
|
||||
bottom: 782,
|
||||
height: 782,
|
||||
left: 0,
|
||||
right: 940.8,
|
||||
top: 0,
|
||||
width: 940.8,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON() {},
|
||||
});
|
||||
dom.window.HTMLCanvasElement.prototype.getContext = () => ({
|
||||
clearRect() {},
|
||||
fillRect() {},
|
||||
fillStyle: "",
|
||||
fillText() {},
|
||||
@@ -81,7 +93,7 @@ function fixture(overrides = {}) {
|
||||
sessions: [{ active: true, closing: false, id: "one", title: "SHELL" }],
|
||||
...overrides,
|
||||
};
|
||||
const view = render(
|
||||
const renderTree = (nextProps) =>
|
||||
createElement(
|
||||
ThemeProvider,
|
||||
null,
|
||||
@@ -89,10 +101,17 @@ function fixture(overrides = {}) {
|
||||
className: "buzz-huddle-app-surface",
|
||||
tabIndex: -1,
|
||||
}),
|
||||
createElement(TerminalSubstrate, props),
|
||||
),
|
||||
);
|
||||
return { calls, props, view };
|
||||
createElement(TerminalSubstrate, nextProps),
|
||||
);
|
||||
const view = render(renderTree(props));
|
||||
return {
|
||||
calls,
|
||||
props,
|
||||
view,
|
||||
rerender(nextOverrides) {
|
||||
view.rerender(renderTree({ ...props, ...nextOverrides }));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function ready(view) {
|
||||
@@ -136,62 +155,81 @@ test("mounted IME paths neither toggle nor emit preedit text", async () => {
|
||||
assert.deepEqual(calls.input, ["か"]);
|
||||
});
|
||||
|
||||
test("first active PTY output dismisses the welcome overlay", async () => {
|
||||
const baseFrame = {
|
||||
cursor: { column: 0, line: 0, visible: false },
|
||||
full: false,
|
||||
rows: [
|
||||
{
|
||||
line: 0,
|
||||
spans: [
|
||||
{
|
||||
style: { fg: 0, bg: 0, flags: 0 },
|
||||
clusters: [{ column: 0, text: "$", width: 1 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
line: 8,
|
||||
spans: [
|
||||
{
|
||||
style: { fg: 0, bg: 0, flags: 0 },
|
||||
clusters: [{ column: 70, text: "x", width: 1 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
viewport: { columns: 80, generation: 1, screenLines: 24 },
|
||||
};
|
||||
const { view } = fixture({ frame: baseFrame });
|
||||
await ready(view);
|
||||
await waitFor(() =>
|
||||
assert.equal(view.container.querySelector(".buzz-terminal-welcome"), null),
|
||||
);
|
||||
const EMPTY_FRAME = {
|
||||
cursor: { column: 0, line: 0, visible: false },
|
||||
full: false,
|
||||
rows: [],
|
||||
viewport: { columns: 112, generation: 1, screenLines: 46 },
|
||||
};
|
||||
|
||||
cleanup();
|
||||
const intersecting = fixture({
|
||||
frame: {
|
||||
...baseFrame,
|
||||
rows: [
|
||||
const VISIBLE_FRAME = {
|
||||
...EMPTY_FRAME,
|
||||
viewport: { ...EMPTY_FRAME.viewport, generation: 2 },
|
||||
rows: [
|
||||
{
|
||||
line: 0,
|
||||
spans: [
|
||||
{
|
||||
line: 8,
|
||||
spans: [
|
||||
{
|
||||
style: { fg: 0, bg: 0, flags: 0 },
|
||||
clusters: [{ column: 30, text: "x", width: 1 }],
|
||||
},
|
||||
],
|
||||
style: { fg: 0, bg: 0, flags: 0 },
|
||||
clusters: [{ column: 0, text: "$", width: 1 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await ready(intersecting.view);
|
||||
],
|
||||
};
|
||||
|
||||
async function expectWelcome(view, present) {
|
||||
await waitFor(() =>
|
||||
assert.equal(
|
||||
intersecting.view.container.querySelector(".buzz-terminal-welcome"),
|
||||
null,
|
||||
),
|
||||
present
|
||||
? assert.ok(view.container.querySelector(".buzz-terminal-welcome"))
|
||||
: assert.equal(
|
||||
view.container.querySelector(".buzz-terminal-welcome"),
|
||||
null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
test("non-empty output from the active PTY dismisses the welcome overlay", async () => {
|
||||
const subject = fixture({ frame: EMPTY_FRAME });
|
||||
await ready(subject.view);
|
||||
await expectWelcome(subject.view, true);
|
||||
|
||||
subject.rerender({ frame: VISIBLE_FRAME });
|
||||
await expectWelcome(subject.view, false);
|
||||
});
|
||||
|
||||
test("empty active output keeps the welcome overlay", async () => {
|
||||
const subject = fixture({ frame: EMPTY_FRAME });
|
||||
await ready(subject.view);
|
||||
await expectWelcome(subject.view, true);
|
||||
|
||||
subject.rerender({
|
||||
frame: {
|
||||
...EMPTY_FRAME,
|
||||
viewport: { ...EMPTY_FRAME.viewport, generation: 2 },
|
||||
},
|
||||
});
|
||||
await expectWelcome(subject.view, true);
|
||||
});
|
||||
|
||||
test("non-empty output from an inactive PTY keeps the welcome overlay", async () => {
|
||||
const subject = fixture({
|
||||
sessionFrames: [{ frame: EMPTY_FRAME, sessionId: "one" }],
|
||||
sessions: [
|
||||
{ active: true, closing: false, id: "one", title: "SHELL" },
|
||||
{ active: false, closing: false, id: "two", title: "LOG" },
|
||||
],
|
||||
});
|
||||
await ready(subject.view);
|
||||
await expectWelcome(subject.view, true);
|
||||
|
||||
subject.rerender({
|
||||
sessionFrames: [
|
||||
{ frame: EMPTY_FRAME, sessionId: "one" },
|
||||
{ frame: VISIBLE_FRAME, sessionId: "two" },
|
||||
],
|
||||
});
|
||||
await expectWelcome(subject.view, true);
|
||||
});
|
||||
|
||||
test("mounted wheel path accumulates fractional lines per active session", async () => {
|
||||
|
||||
@@ -12,6 +12,50 @@ function layers(banner) {
|
||||
);
|
||||
}
|
||||
|
||||
function frameBounds(banner) {
|
||||
const cells = banner.cells.flatMap((row, y) =>
|
||||
row.flatMap((cell, x) =>
|
||||
cell.layer === "bevel_hi" || cell.layer === "bevel_lo" ? [{ x, y }] : [],
|
||||
),
|
||||
);
|
||||
return {
|
||||
top: Math.min(...cells.map(({ y }) => y)),
|
||||
left: Math.min(...cells.map(({ x }) => x)),
|
||||
bottom: Math.max(...cells.map(({ y }) => y)) + 1,
|
||||
right: Math.max(...cells.map(({ x }) => x)) + 1,
|
||||
};
|
||||
}
|
||||
|
||||
function headRows(banner) {
|
||||
const cells = banner.cells.flatMap((row, y) =>
|
||||
row.flatMap((cell, x) =>
|
||||
cell.layer === "head" ? [{ ...cell, x, y }] : [],
|
||||
),
|
||||
);
|
||||
const left = Math.min(...cells.map(({ x }) => x));
|
||||
const right = Math.max(...cells.map(({ x }) => x));
|
||||
const top = Math.min(...cells.map(({ y }) => y));
|
||||
const bottom = Math.max(...cells.map(({ y }) => y));
|
||||
return Array.from({ length: bottom - top + 1 }, (_, row) =>
|
||||
Array.from(
|
||||
{ length: right - left + 1 },
|
||||
(_, column) =>
|
||||
cells.find(({ x, y }) => x === left + column && y === top + row)
|
||||
?.char ?? " ",
|
||||
)
|
||||
.join("")
|
||||
.trimEnd(),
|
||||
);
|
||||
}
|
||||
|
||||
const EXPECTED_BUZZ_TERM = [
|
||||
"██ ██",
|
||||
"██▄▄▄ ██ ██ ██████ ██████ █████ ▄███▄ ██ ▄██ ██▄██▄██",
|
||||
"██▀▀██ ██ ██ ▄██ ▄██ ██ ██▄▄▄█ ███▀▀ ██ ██ ██",
|
||||
"██ ██ ██ ██ ▄██▀ ▄██▀ ██ ██ ██ ██ ██ ██",
|
||||
"██████ ▀█████ ██████ ██████ ███ ▀███▀ ██ ██ ██ ██",
|
||||
];
|
||||
|
||||
test("builds the amended four-layer Buzz Term composite", () => {
|
||||
const banner = buildTerminalBanner(183, 69, 17 / 8.4);
|
||||
assert.ok(banner);
|
||||
@@ -22,16 +66,33 @@ test("builds the amended four-layer Buzz Term composite", () => {
|
||||
assert.equal(Math.min(...sweep), 0);
|
||||
assert.equal(Math.max(...sweep), 1);
|
||||
assert.ok(head.every((cell) => cell.layer === "head"));
|
||||
const { top, left, bottom, right } = banner.reserved;
|
||||
const { top, left, bottom, right } = frameBounds(banner);
|
||||
assert.equal(banner.cells[top][left].layer, "bevel_hi");
|
||||
assert.equal(banner.cells[bottom - 1][right - 1].layer, "bevel_lo");
|
||||
assert.match(
|
||||
seen
|
||||
.get("head")
|
||||
.map((cell) => cell.char)
|
||||
.join(""),
|
||||
/█/,
|
||||
);
|
||||
assert.deepEqual(headRows(banner), EXPECTED_BUZZ_TERM);
|
||||
});
|
||||
|
||||
test("requires every frame emitter independently", () => {
|
||||
const banner = buildTerminalBanner(183, 69, 17 / 8.4);
|
||||
assert.ok(banner);
|
||||
const { top, left, bottom, right } = frameBounds(banner);
|
||||
|
||||
for (let x = left; x < right; x += 1)
|
||||
assert.equal(banner.cells[top][x].layer, "bevel_hi", `top_row:${x}`);
|
||||
for (let y = top + 1; y < bottom - 1; y += 1) {
|
||||
assert.equal(banner.cells[y][left].layer, "bevel_hi", `left_rail:${y}`);
|
||||
assert.equal(
|
||||
banner.cells[y][right - 1].layer,
|
||||
"bevel_lo",
|
||||
`right_rail:${y}`,
|
||||
);
|
||||
}
|
||||
for (let x = left; x < right; x += 1)
|
||||
assert.equal(
|
||||
banner.cells[bottom - 1][x].layer,
|
||||
"bevel_lo",
|
||||
`bottom_row:${x}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("emits complete hexagons only and fades per ray", () => {
|
||||
@@ -43,18 +104,6 @@ test("emits complete hexagons only and fades per ray", () => {
|
||||
assert.ok(field.some((cell) => cell.t > 0.5));
|
||||
});
|
||||
|
||||
test("safe mode replaces ambiguous frame rails without changing geometry", () => {
|
||||
const ink = buildTerminalBanner(183, 69, 17 / 8.4, false);
|
||||
const safe = buildTerminalBanner(183, 69, 17 / 8.4, true);
|
||||
assert.ok(ink && safe);
|
||||
assert.deepEqual(ink.reserved, safe.reserved);
|
||||
assert.ok(ink.cells.flat().some((cell) => cell.char === "▌"));
|
||||
assert.equal(
|
||||
safe.cells.flat().some((cell) => cell.char === "▌"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("declines viewports too small for the complete wordmark", () => {
|
||||
assert.equal(buildTerminalBanner(40, 20, 17 / 8.4), null);
|
||||
});
|
||||
|
||||
@@ -8,12 +8,6 @@ export type TerminalBannerCell = Readonly<{
|
||||
|
||||
export type TerminalBanner = Readonly<{
|
||||
cells: readonly (readonly TerminalBannerCell[])[];
|
||||
reserved: Readonly<{
|
||||
top: number;
|
||||
left: number;
|
||||
bottom: number;
|
||||
right: number;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
const HEX = [
|
||||
@@ -48,22 +42,27 @@ const INK_FRAME = {
|
||||
left: "▌",
|
||||
right: "▐",
|
||||
} as const;
|
||||
const SAFE_FRAME = {
|
||||
topLeft: "▛",
|
||||
topRight: "▜",
|
||||
bottomLeft: "▙",
|
||||
bottomRight: "▟",
|
||||
top: "░",
|
||||
bottom: "░",
|
||||
left: "▐",
|
||||
right: "▐",
|
||||
} as const;
|
||||
const LAYERS: readonly TerminalBannerLayer[] = [
|
||||
"field",
|
||||
"head",
|
||||
"bevel_hi",
|
||||
"bevel_lo",
|
||||
];
|
||||
type TerminalBannerEmitter =
|
||||
| "field"
|
||||
| "top_row"
|
||||
| "left_rail"
|
||||
| "right_rail"
|
||||
| "bottom_row"
|
||||
| "wordmark";
|
||||
const EMITTERS: readonly TerminalBannerEmitter[] = [
|
||||
"field",
|
||||
"top_row",
|
||||
"left_rail",
|
||||
"right_rail",
|
||||
"bottom_row",
|
||||
"wordmark",
|
||||
];
|
||||
|
||||
function trimRight(value: string): string {
|
||||
return value.replace(/\s+$/, "");
|
||||
@@ -123,7 +122,6 @@ export function buildTerminalBanner(
|
||||
columns: number,
|
||||
rows: number,
|
||||
cellAspect: number,
|
||||
safe = false,
|
||||
): TerminalBanner | null {
|
||||
const mark = fitWordmark(columns - 10);
|
||||
if (!mark || rows < mark.length + 6) return null;
|
||||
@@ -142,15 +140,19 @@ export function buildTerminalBanner(
|
||||
const grid: TerminalBannerCell[][] = Array.from({ length: rows }, () =>
|
||||
Array.from({ length: columns }, () => ({ char: " ", t: 0 })),
|
||||
);
|
||||
const emitted = new Set<TerminalBannerEmitter>();
|
||||
const put = (
|
||||
y: number,
|
||||
x: number,
|
||||
char: string,
|
||||
layer: TerminalBannerLayer,
|
||||
t = 0,
|
||||
emitter?: TerminalBannerEmitter,
|
||||
) => {
|
||||
if (y >= 0 && y < rows && x >= 0 && x < columns)
|
||||
if (y >= 0 && y < rows && x >= 0 && x < columns) {
|
||||
grid[y][x] = { char, layer, t };
|
||||
if (emitter) emitted.add(emitter);
|
||||
}
|
||||
};
|
||||
|
||||
const halfWidth = frameWidth / 2;
|
||||
@@ -190,7 +192,7 @@ export function buildTerminalBanner(
|
||||
)
|
||||
continue;
|
||||
for (const [offsetY, offsetX, char] of HEX)
|
||||
put(hex.y + offsetY, hex.x + offsetX, char, "field", ray * ray);
|
||||
put(hex.y + offsetY, hex.x + offsetX, char, "field", ray * ray, "field");
|
||||
}
|
||||
|
||||
for (
|
||||
@@ -206,23 +208,39 @@ export function buildTerminalBanner(
|
||||
grid[y][x] = { char: " ", t: 0 };
|
||||
}
|
||||
|
||||
const frame = safe ? SAFE_FRAME : INK_FRAME;
|
||||
put(top, left, frame.topLeft, "bevel_hi");
|
||||
const frame = INK_FRAME;
|
||||
put(top, left, frame.topLeft, "bevel_hi", 0, "top_row");
|
||||
for (let x = 0; x < innerWidth; x += 1)
|
||||
put(top, left + x + 1, frame.top, "bevel_hi");
|
||||
put(top, left + frameWidth - 1, frame.topRight, "bevel_hi");
|
||||
put(top, left + x + 1, frame.top, "bevel_hi", 0, "top_row");
|
||||
put(top, left + frameWidth - 1, frame.topRight, "bevel_hi", 0, "top_row");
|
||||
for (let y = top + 1; y < top + frameHeight - 1; y += 1) {
|
||||
put(y, left, frame.left, "bevel_hi");
|
||||
put(y, left + frameWidth - 1, frame.right, "bevel_lo");
|
||||
put(y, left, frame.left, "bevel_hi", 0, "left_rail");
|
||||
put(y, left + frameWidth - 1, frame.right, "bevel_lo", 0, "right_rail");
|
||||
}
|
||||
put(top + frameHeight - 1, left, frame.bottomLeft, "bevel_lo");
|
||||
put(
|
||||
top + frameHeight - 1,
|
||||
left,
|
||||
frame.bottomLeft,
|
||||
"bevel_lo",
|
||||
0,
|
||||
"bottom_row",
|
||||
);
|
||||
for (let x = 0; x < innerWidth; x += 1)
|
||||
put(top + frameHeight - 1, left + x + 1, frame.bottom, "bevel_lo");
|
||||
put(
|
||||
top + frameHeight - 1,
|
||||
left + x + 1,
|
||||
frame.bottom,
|
||||
"bevel_lo",
|
||||
0,
|
||||
"bottom_row",
|
||||
);
|
||||
put(
|
||||
top + frameHeight - 1,
|
||||
left + frameWidth - 1,
|
||||
frame.bottomRight,
|
||||
"bevel_lo",
|
||||
0,
|
||||
"bottom_row",
|
||||
);
|
||||
|
||||
const markLeft = left + Math.floor((frameWidth - width) / 2);
|
||||
@@ -241,6 +259,7 @@ export function buildTerminalBanner(
|
||||
cell.char,
|
||||
"head",
|
||||
(cell.x - minX) / span,
|
||||
"wordmark",
|
||||
);
|
||||
|
||||
const seen = new Map<TerminalBannerLayer, number[]>();
|
||||
@@ -249,16 +268,9 @@ export function buildTerminalBanner(
|
||||
if (cell.layer)
|
||||
seen.set(cell.layer, [...(seen.get(cell.layer) ?? []), cell.t]);
|
||||
if (LAYERS.some((layer) => !seen.has(layer))) return null;
|
||||
if (EMITTERS.some((emitter) => !emitted.has(emitter))) return null;
|
||||
const sweep = seen.get("head") ?? [];
|
||||
if (Math.min(...sweep) !== 0 || Math.max(...sweep) !== 1) return null;
|
||||
|
||||
return {
|
||||
cells: grid,
|
||||
reserved: {
|
||||
top,
|
||||
left,
|
||||
bottom: top + frameHeight,
|
||||
right: left + frameWidth,
|
||||
},
|
||||
};
|
||||
return { cells: grid };
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
encodePaste,
|
||||
encodeTerminalKey,
|
||||
reduceHandoff,
|
||||
updateWelcomeForOutput,
|
||||
} from "./terminalState.ts";
|
||||
|
||||
test("ownership changes on completed chord, never key-down or repeat", () => {
|
||||
@@ -96,21 +95,3 @@ test("pixel scrolling retains fractional lines in both directions", () => {
|
||||
assert.equal(result.lines, -1);
|
||||
assert.equal(result.state.remainderPx, -1);
|
||||
});
|
||||
|
||||
test("welcome survives an ordinary prompt outside its reserved rectangle", () => {
|
||||
const welcome = {
|
||||
visible: true,
|
||||
reserved: { top: 4, left: 10, bottom: 10, right: 40 },
|
||||
};
|
||||
assert.equal(
|
||||
updateWelcomeForOutput(welcome, [{ top: 0, left: 0, bottom: 1, right: 20 }])
|
||||
.visible,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
updateWelcomeForOutput(welcome, [
|
||||
{ top: 9, left: 39, bottom: 11, right: 42 },
|
||||
]).visible,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -115,30 +115,3 @@ export function accumulateScrollLines(
|
||||
lines,
|
||||
};
|
||||
}
|
||||
|
||||
export type Rect = { top: number; left: number; bottom: number; right: number };
|
||||
|
||||
export function rectsIntersect(a: Rect, b: Rect): boolean {
|
||||
return !(
|
||||
a.right <= b.left ||
|
||||
b.right <= a.left ||
|
||||
a.bottom <= b.top ||
|
||||
b.bottom <= a.top
|
||||
);
|
||||
}
|
||||
|
||||
export type WelcomeState = {
|
||||
visible: boolean;
|
||||
reserved: Rect | null;
|
||||
};
|
||||
|
||||
export function updateWelcomeForOutput(
|
||||
state: WelcomeState,
|
||||
changed: readonly Rect[],
|
||||
): WelcomeState {
|
||||
const reserved = state.reserved;
|
||||
if (!state.visible || !reserved) return state;
|
||||
return changed.some((rect) => rectsIntersect(rect, reserved))
|
||||
? { ...state, visible: false }
|
||||
: state;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user