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
140 lines
5.3 KiB
TypeScript
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\}/);
|
|
});
|