Rename, delete with confirmation, pin, share and reorder moved from Home's session hook into the shared (app) shell, so /chart, /ephemeris, /reports and /people rows get the same menu and drag as /. Home only adds its paging cursor, what the open conversation does when deleted, and which local chats are unsaved. BUG-1467. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Ad9rqAB8pXXvyzh7f4X8v
110 lines
5.0 KiB
TypeScript
110 lines
5.0 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { randomUUID } from "node:crypto";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
archiveOrderBatch,
|
|
parseOrderBatch,
|
|
postOrderBatch,
|
|
sidebarOrderBatch,
|
|
} from "../src/lib/order-batch.ts";
|
|
|
|
test("dragging 60 chats sends one order request for the whole group", async () => {
|
|
const sessions = Array.from({ length: 60 }, () => ({
|
|
id: randomUUID(),
|
|
sidebarOrder: null as number | null,
|
|
}));
|
|
const ordered = sessions.map((session) => session.id).reverse();
|
|
const batch = sidebarOrderBatch(ordered, sessions, () => true);
|
|
assert.ok(batch);
|
|
assert.equal(batch.ids.length, 60);
|
|
assert.deepEqual(batch.orders, ordered.map((_, index) => (index + 1) * 10));
|
|
const calls: Array<{ url: string; method: string; body: string }> = [];
|
|
await postOrderBatch("/api/sessions/order", batch, async (url, init) => {
|
|
calls.push({
|
|
url: String(url),
|
|
method: String(init?.method),
|
|
body: String(init?.body),
|
|
});
|
|
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
|
});
|
|
assert.equal(calls.length, 1);
|
|
assert.equal(calls[0]?.url, "/api/sessions/order");
|
|
assert.equal(calls[0]?.method, "POST");
|
|
const body = JSON.parse(calls[0]?.body ?? "{}") as { ids: string[] };
|
|
assert.equal(body.ids.length, 60);
|
|
});
|
|
|
|
test("an unsaved chat stays out of the order request, and a failed request throws", async () => {
|
|
const local = { id: randomUUID(), sidebarOrder: null as number | null };
|
|
const cloud = { id: randomUUID(), sidebarOrder: null as number | null };
|
|
const batch = sidebarOrderBatch(
|
|
[local.id, cloud.id],
|
|
[local, cloud],
|
|
(session) => session.id === cloud.id,
|
|
);
|
|
assert.deepEqual(batch, { ids: [cloud.id], orders: [20] });
|
|
await assert.rejects(
|
|
() => postOrderBatch("/api/sessions/order", batch!, async () => new Response("{}", { status: 500 })),
|
|
/排序没有保存/,
|
|
);
|
|
});
|
|
|
|
test("archive order is one batch and never includes self", () => {
|
|
const first = randomUUID();
|
|
const second = randomUUID();
|
|
assert.equal(archiveOrderBatch(["self", first], [{ id: first, archiveOrder: 10 }]), null);
|
|
assert.deepEqual(
|
|
archiveOrderBatch(["self", second, first], [
|
|
{ id: first, archiveOrder: 10 },
|
|
{ id: second, archiveOrder: 20 },
|
|
]),
|
|
{ ids: [second, first], orders: [10, 20] },
|
|
);
|
|
});
|
|
|
|
test("order batch rejects a short, duplicate, or non-integer body", () => {
|
|
const id = "11111111-1111-4111-8111-111111111449";
|
|
assert.equal(parseOrderBatch(null), null);
|
|
assert.equal(parseOrderBatch({ ids: [], orders: [] }), null);
|
|
assert.equal(parseOrderBatch({ ids: [id], orders: [1, 2] }), null);
|
|
assert.equal(parseOrderBatch({ ids: [id, id], orders: [10, 20] }), null);
|
|
assert.equal(parseOrderBatch({ ids: [id], orders: [1.5] }), null);
|
|
assert.equal(parseOrderBatch({ ids: ["self"], orders: [10] }), null);
|
|
assert.deepEqual(parseOrderBatch({ ids: [id], orders: [10] }), { ids: [id], orders: [10] });
|
|
});
|
|
|
|
test("reorder and archive each post one batch, and the migration stays out of db/migrations", () => {
|
|
// 原值:读 src/hooks/use-session-management.ts
|
|
// 新值:读 src/hooks/use-shell-session-actions.ts
|
|
// 原因:issue #116 排序搬进共用外壳,每个页面都能拖;断言内容不变
|
|
const hook = readFileSync(new URL("../src/hooks/use-shell-session-actions.ts", import.meta.url), "utf8");
|
|
const reorder = hook.slice(hook.indexOf("function reorderSessions"), hook.indexOf("function togglePinnedSession"));
|
|
assert.match(reorder, /postOrderBatch\(\s*"\/api\/sessions\/order"/);
|
|
assert.doesNotMatch(reorder, /Promise\.allSettled|writeChatSession\(/);
|
|
assert.match(reorder, /排序没有保存/);
|
|
const page = readFileSync(new URL("../src/components/people/people-page.tsx", import.meta.url), "utf8");
|
|
const archive = page.slice(page.indexOf("async function persistArchiveOrder"), page.indexOf("function openPerson"));
|
|
assert.match(archive, /postOrderBatch\(\s*"\/api\/chart-profiles\/order"/);
|
|
assert.doesNotMatch(archive, /\/api\/chart-profiles\/\$\{/);
|
|
const sessionRoute = readFileSync(new URL("../src/app/api/sessions/order/route.ts", import.meta.url), "utf8");
|
|
assert.equal(sessionRoute.match(/consumeUserRequestRateLimit\(/g)?.length, 1);
|
|
assert.match(sessionRoute, /set_chat_session_sidebar_order/);
|
|
const archiveRoute = readFileSync(new URL("../src/app/api/chart-profiles/order/route.ts", import.meta.url), "utf8");
|
|
assert.match(archiveRoute, /set_chart_profile_archive_order/);
|
|
assert.doesNotMatch(archiveRoute, /consumeUserRequestRateLimit/);
|
|
const sql = readFileSync(
|
|
new URL("../supabase/migrations/20261026120000_sidebar_archive_order_batch.sql", import.meta.url),
|
|
"utf8",
|
|
);
|
|
assert.match(sql, /security invoker/);
|
|
assert.doesNotMatch(sql, /security definer/i);
|
|
assert.match(sql, /profile\.role = 'other'/);
|
|
assert.match(sql, /raise exception 'order_row_missing'/);
|
|
assert.equal(
|
|
existsSync(new URL("../db/migrations/20261026120000_sidebar_archive_order_batch.sql", import.meta.url)),
|
|
false,
|
|
);
|
|
});
|