Files
multica/packages/core/issues/cache-helpers.test.ts
Lambda 645ea20b00 feat(web): custom properties settings tab + issue sidebar editors (MUL-4463)
- Settings → Properties: definition management mirroring the Labels tab
  (list with type badges/option chips/usage counts, create/edit dialog
  with option editor, archive/restore, 20-cap indicator). Admin-gated;
  members see a read-only catalog.
- Issue detail sidebar: custom properties join the built-in optional
  props' progressive disclosure — set values render as rows with
  type-appropriate editors (select/multi-select pickers, calendar,
  yes/no, inline input for text/number/url), unset ones live in the
  same '+ Add property' menu behind a separator. Archived definitions
  render read-only until cleared.
- Core: property types, zod schemas (lenient type strings for forward
  compat), api client methods, React Query hooks with optimistic
  single-key value writes, ws-updaters + realtime wiring for
  property:created/updated and issue_properties:changed.
- Locales: en/zh-Hans/ja/ko strings; Issue fixtures gain properties: {}.

Co-authored-by: multica-agent <github@multica.ai>
2026-07-14 17:29:00 +08:00

151 lines
4.8 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { Issue, ListIssuesCache } from "../types";
import { insertByPosition, patchIssueInBuckets } from "./cache-helpers";
const WS_ID = "ws-1";
function mk(id: string, status: Issue["status"], position: number): Issue {
return {
id,
workspace_id: WS_ID,
number: 1,
identifier: `MUL-${id}`,
title: id,
description: null,
status,
priority: "none",
assignee_type: null,
assignee_id: null,
creator_type: "member",
creator_id: "user-1",
parent_issue_id: null,
project_id: null,
position,
stage: null,
start_date: null,
due_date: null,
metadata: {},
properties: {},
labels: [],
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
};
}
function cache(byStatus: ListIssuesCache["byStatus"]): ListIssuesCache {
return { byStatus };
}
function ids(c: ListIssuesCache, status: Issue["status"]): string[] {
return (c.byStatus[status]?.issues ?? []).map((i) => i.id);
}
describe("insertByPosition", () => {
it("inserts at the position-sorted slot", () => {
const a = mk("a", "todo", 1);
const c = mk("c", "todo", 3);
const b = mk("b", "todo", 2);
expect(insertByPosition([a, c], b).map((i) => i.id)).toEqual([
"a",
"b",
"c",
]);
});
it("appends when the new position is the largest", () => {
const a = mk("a", "todo", 1);
const z = mk("z", "todo", 9);
expect(insertByPosition([a], z).map((i) => i.id)).toEqual(["a", "z"]);
});
it("prepends when the new position is the smallest", () => {
const b = mk("b", "todo", 2);
const a = mk("a", "todo", 1);
expect(insertByPosition([b], a).map((i) => i.id)).toEqual(["a", "b"]);
});
});
describe("patchIssueInBuckets — cross-status move", () => {
it("inserts the moved card at its position slot, not the end", () => {
const c0 = cache({
todo: { issues: [mk("moved", "todo", 5)], total: 1 },
in_progress: {
issues: [mk("x", "in_progress", 1), mk("y", "in_progress", 3)],
total: 2,
},
});
// Move "moved" into in_progress at position 2 (between x and y).
const next = patchIssueInBuckets(c0, "moved", {
status: "in_progress",
position: 2,
});
expect(ids(next, "in_progress")).toEqual(["x", "moved", "y"]);
expect(ids(next, "todo")).toEqual([]);
});
it("adjusts both bucket totals", () => {
const c0 = cache({
todo: { issues: [mk("moved", "todo", 5)], total: 1 },
in_progress: { issues: [mk("x", "in_progress", 1)], total: 1 },
});
const next = patchIssueInBuckets(c0, "moved", {
status: "in_progress",
position: 2,
});
expect(next.byStatus.todo?.total).toBe(0);
expect(next.byStatus.in_progress?.total).toBe(2);
});
// MUL-4261: `cancelled` is now a first-class paginated bucket, so cancelling
// an issue rebuckets it into `cancelled` (instead of dropping it) and the
// rebucketed card stays locatable for later patches.
it("rebuckets a cancelled issue and keeps it locatable", () => {
const c0 = cache({
todo: { issues: [mk("a", "todo", 1)], total: 1 },
cancelled: { issues: [], total: 0 },
});
const cancelled = patchIssueInBuckets(c0, "a", { status: "cancelled" });
expect(ids(cancelled, "todo")).toEqual([]);
expect(ids(cancelled, "cancelled")).toEqual(["a"]);
expect(cancelled.byStatus.cancelled?.total).toBe(1);
// A follow-up edit still finds the card in the cancelled bucket.
const renamed = patchIssueInBuckets(cancelled, "a", { title: "renamed" });
expect(renamed.byStatus.cancelled?.issues[0]?.title).toBe("renamed");
});
});
describe("patchIssueInBuckets — same status", () => {
it("keeps the slot for a plain field update (no reorder)", () => {
const c0 = cache({
todo: {
issues: [mk("a", "todo", 1), mk("b", "todo", 2), mk("c", "todo", 3)],
total: 3,
},
});
// A remote label/title edit must not move the card.
const next = patchIssueInBuckets(c0, "b", { title: "renamed" });
expect(ids(next, "todo")).toEqual(["a", "b", "c"]);
expect(next.byStatus.todo?.issues[1]?.title).toBe("renamed");
});
it("re-sorts within the column when position changes", () => {
const c0 = cache({
todo: {
issues: [mk("a", "todo", 1), mk("b", "todo", 2), mk("c", "todo", 3)],
total: 3,
},
});
// Drag "a" below "b" (new position 2.5).
const next = patchIssueInBuckets(c0, "a", { position: 2.5 });
expect(ids(next, "todo")).toEqual(["b", "a", "c"]);
});
});
describe("patchIssueInBuckets — unknown issue", () => {
it("returns the cache unchanged when the id is absent", () => {
const c0 = cache({ todo: { issues: [mk("a", "todo", 1)], total: 1 } });
expect(patchIssueInBuckets(c0, "ghost", { position: 9 })).toBe(c0);
});
});