Files
Jyotisha/frontend/tests/drag-width-handle-20261027.test.tsx
T
Jesse_ChenandClaude Opus 5.5 e80bc64d93
Independent Staging Quality Gate / validate (push) Successful in 15m27s
Independent Staging Quality Gate / publish (push) Successful in 4m27s
Chart page on one sheet with a clickable North/South board (issue #114)
The chart page is now one page: board and inspector beside the nine-body
table, house themes, dasha strip and summary, with four deep-reading tabs
below (time, strength, yogas, chart book). Clicking a body or house draws its
rulership and aspect lines and fills the inspector. Shadbala, yogas and the
birth tithi come from the natal packet; ashtakavarga and house strength are a
new lazy layer; the transit layer takes any date. The chat's natal panel
carries the same board, opens by default, and is a bottom sheet on phones.

Logged two engine bugs found on the way (BUG-1462 sunrise, BUG-1463
/api/shadbala); neither is fixed here.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Ad9rqAB8pXXvyzh7f4X8v
2026-10-27 12:47:14 +08:00

140 lines
5.3 KiB
TypeScript

/**
* BUG-1456 / BUG-1457: the chat chart edge and the sidebar edge drag, and take
* the arrow keys, through one hook. Drag below the minimum closes the panel.
*/
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { ChatChartSplit } from "../src/components/chat-chart-split.tsx";
import { dragWidthFromKey, dragWidthFromPointer, useDragWidth, type DragWidthEdge } from "../src/hooks/use-drag-width.ts";
import { CHAT_CHART_WIDTH_MAX, CHAT_CHART_WIDTH_MIN } from "../src/lib/chat-chart-panel-state.ts";
type Handlers = ReturnType<typeof useDragWidth>;
function captureHandlers(props: Parameters<typeof useDragWidth>[0]): Handlers {
let captured: Handlers | null = null;
function Probe() {
captured = useDragWidth(props);
return null;
}
renderToStaticMarkup(<Probe />);
assert.ok(captured);
return captured;
}
function fakeWindow() {
const buckets = new Map<string, Set<(event: { clientX: number }) => void>>();
const original = (globalThis as { window?: unknown }).window;
(globalThis as { window?: unknown }).window = {
addEventListener(type: string, listener: (event: { clientX: number }) => void) {
const set = buckets.get(type) ?? new Set();
set.add(listener);
buckets.set(type, set);
},
removeEventListener(type: string, listener: (event: { clientX: number }) => void) {
buckets.get(type)?.delete(listener);
},
};
return {
fire(type: string, clientX = 0) {
for (const listener of [...(buckets.get(type) ?? [])]) listener({ clientX });
},
count(type: string) {
return buckets.get(type)?.size ?? 0;
},
restore() {
(globalThis as { window?: unknown }).window = original;
},
};
}
function pointerDown(clientX: number) {
return { button: 0, clientX, preventDefault() {} } as unknown as Parameters<Handlers["onPointerDown"]>[0];
}
function keyDown(key: string) {
let prevented = false;
const event = { key, preventDefault() { prevented = true; } } as unknown as Parameters<Handlers["onKeyDown"]>[0];
return { event, prevented: () => prevented };
}
test("pointer and key widths follow the moving edge", () => {
const cases: Array<[DragWidthEdge, number, number]> = [["right", 40, 340], ["left", 40, 260]];
for (const [edge, delta, expected] of cases) assert.equal(dragWidthFromPointer(300, delta, edge), expected);
const range = { min: 280, max: 560 };
assert.equal(dragWidthFromKey(360, "ArrowLeft", range, "left"), 376);
assert.equal(dragWidthFromKey(360, "ArrowRight", range, "left"), 344);
assert.equal(dragWidthFromKey(360, "ArrowRight", range, "right"), 376);
assert.equal(dragWidthFromKey(284, "ArrowRight", range, "left"), 280, "keys clamp instead of collapsing");
assert.equal(dragWidthFromKey(360, "End", range, "left"), 560);
assert.equal(dragWidthFromKey(360, "Enter", range, "left"), null);
});
test("dragging the chat chart edge reports widths and closes below the minimum", () => {
const win = fakeWindow();
const widths: number[] = [];
let collapsed = 0;
try {
const handlers = captureHandlers({
width: 360,
min: CHAT_CHART_WIDTH_MIN,
max: CHAT_CHART_WIDTH_MAX,
edge: "left",
onWidth: (width) => widths.push(width),
onCollapse: () => { collapsed += 1; },
});
handlers.onPointerDown(pointerDown(1000));
win.fire("pointermove", 960);
// 原值:拖到 700(宽 660,超过旧上限 560 被夹到上限)
// 新值:拖到 500(宽 860,超过新上限 760 被夹到上限)
// 原因:issue #114 M8 右栏装完整盘面,上限 560 → 760;这一步仍然验证「超上限被夹」
win.fire("pointermove", 500);
win.fire("pointermove", 1100);
win.fire("pointerup");
assert.deepEqual(widths, [400, CHAT_CHART_WIDTH_MAX]);
assert.equal(collapsed, 1);
assert.equal(win.count("pointermove"), 0, "listeners removed after the drag");
} finally {
win.restore();
}
});
test("arrow keys move the edge and are announced as a separator", () => {
const widths: number[] = [];
const handlers = captureHandlers({
width: 360,
min: CHAT_CHART_WIDTH_MIN,
max: CHAT_CHART_WIDTH_MAX,
edge: "left",
onWidth: (width) => widths.push(width),
onCollapse: () => {},
});
const left = keyDown("ArrowLeft");
handlers.onKeyDown(left.event);
assert.equal(left.prevented(), true);
const other = keyDown("a");
handlers.onKeyDown(other.event);
assert.equal(other.prevented(), false);
assert.deepEqual(widths, [376]);
assert.equal(handlers.role, "separator");
assert.equal(handlers["aria-valuenow"], 360);
});
test("the open chat chart renders a focusable separator on its edge", () => {
const html = renderToStaticMarkup(
<ChatChartSplit open={true} width={360} subjectId="self" onOpenChange={() => {}} onWidth={() => {}} />,
);
assert.match(html, /<button[^>]*class="[^"]*chat-chart-resize-handle/);
assert.match(html, /role="separator"/);
assert.match(html, /aria-valuenow="360"/);
assert.match(html, /aria-label="调整本命盘宽度"/);
});
test("the sidebar edge uses the same hook, so it takes the arrow keys too", () => {
const source = readFileSync(new URL("../src/components/shell-frame.tsx", import.meta.url), "utf8");
assert.match(source, /useDragWidth\(\{/);
assert.match(source, /\{\.\.\.resize\}/);
});