import { describe, expect, it } from "vitest"; import { runRecordQuery } from "./index.js"; import { encodeCursor, decodeCursor, paginateRows } from "./paginate.js"; import { sortRecords } from "./sort.js"; import { groupRecords, EMPTY_KEY } from "./group.js"; import { pivotValues, assembleRows } from "./pivot.js"; import { propertiesMap } from "../filter/index.js"; import type { RecordRow, PropertyInfo, PropertyValue } from "../values/types.js"; const props: PropertyInfo[] = [ { id: "p-title", name: "Title", type: "title", config: {} }, { id: "p-num", name: "Estimate", type: "number", config: {} }, { id: "p-date", name: "Due", type: "date", config: {} }, { id: "p-status", name: "Status", type: "select", config: { options: [ { id: "todo", name: "To Do", color: "gray" }, { id: "done", name: "Done", color: "green" }, ], }, }, ]; const map = propertiesMap(props); const row = ( id: string, position: string, values: Record, archivedAt?: number, ): RecordRow => ({ id, pageId: `pg-${id}`, position, createdTime: 1, lastEditedTime: 1, values, ...(archivedAt ? { archivedAt } : {}), }); const rows: RecordRow[] = [ row("r1", "a0", { "p-title": "One", "p-num": 5, "p-status": "todo", "p-date": "2025-06-01" }), row("r2", "a1", { "p-title": "Two", "p-num": 15, "p-status": "done", "p-date": "2025-06-02" }), row("r3", "a2", { "p-title": "Three", "p-num": 10, "p-status": "todo", "p-date": "2025-06-03" }), row("r4", "a3", { "p-title": "Four", "p-status": "done", "p-date": "2025-06-04" }), row("r5", "a4", { "p-title": "Archived", "p-num": 99 }, 123), ]; describe("sort", () => { it("multi-key stable with (position, id) tiebreak", () => { const sorted = sortRecords( [...rows], [ { propertyId: "p-status", direction: "asc" }, { propertyId: "p-num", direction: "desc" }, ], map, ); // todo(5,10) then done(15,null→last within desc? nulls last): todo: 10,5; done: 15, then r4(null) expect(sorted.map((r) => r.id)).toEqual(["r3", "r1", "r2", "r4", "r5"]); }); it("nulls last for asc, first for desc (nullOrder)", () => { const asc = sortRecords([...rows], [{ propertyId: "p-num", direction: "asc" }], map); expect(asc[asc.length - 1]!.id).toBe("r4"); // null last const desc = sortRecords( [...rows], [{ propertyId: "p-num", direction: "desc", nullOrder: "first" }], map, ); expect(desc[0]!.id).toBe("r4"); // null first }); it("rejects >5 keys and duplicates", () => { expect(() => sortRecords( rows, [ { propertyId: "p-num", direction: "asc" }, { propertyId: "p-num", direction: "asc" }, ], map, ), ).toThrow(/duplicate/); }); }); describe("group", () => { it("select grouping: option order + empty last", () => { const groups = groupRecords(rows, [{ propertyId: "p-status", direction: "asc" }], map); expect(groups.map((g) => [g.key, g.count])).toEqual([ ["todo", 2], ["done", 2], [EMPTY_KEY, 1], ]); }); it("date grouping by day bucket + direction", () => { const groups = groupRecords(rows, [{ propertyId: "p-date", direction: "desc" }], map); expect(groups[0]!.key).toBe("2025-06-04"); expect(groups[groups.length - 1]!.key).toBe(EMPTY_KEY); }); it("nested 2-level groups", () => { const groups = groupRecords( rows, [ { propertyId: "p-status", direction: "asc" }, { propertyId: "p-num", direction: "asc" }, ], map, ); const todo = groups.find((g) => g.key === "todo")!; expect(todo.children).toBeDefined(); expect(todo.children!.length).toBeGreaterThanOrEqual(2); }); }); describe("paginate (opaque keyset cursor)", () => { it("round-trips the cursor and pages", () => { const page1 = paginateRows(rows, undefined, 2); expect(page1.items.map((r) => r.id)).toEqual(["r1", "r2"]); expect(page1.cursor).not.toBeNull(); const page2 = paginateRows(rows, page1.cursor!, 2); expect(page2.items.map((r) => r.id)).toEqual(["r3", "r4"]); const page3 = paginateRows(rows, page2.cursor!, 2); expect(page3.items.map((r) => r.id)).toEqual(["r5"]); expect(page3.cursor).toBeNull(); }); it("cursor decode is safe against garbage", () => { expect(decodeCursor("!!!not-base64!!!")).toBeNull(); expect(decodeCursor(Buffer.from("nope").toString("base64url"))).toBeNull(); const c = encodeCursor({ position: "a0", id: "r1" }); expect(decodeCursor(c)).toEqual({ position: "a0", id: "r1" }); }); }); describe("runRecordQuery pipeline", () => { it("excludes archived by default, includes with flag", () => { const base = runRecordQuery({ records: rows, properties: props, pageSize: 100 }); expect(base.total).toBe(4); const all = runRecordQuery({ records: rows, properties: props, pageSize: 100, includeArchived: true, }); expect(all.total).toBe(5); }); it("filter → sort → paginate in one pass", () => { const res = runRecordQuery({ records: rows, properties: props, filter: { op: "and", conditions: [{ propertyId: "p-status", operator: "=", value: "todo" }] }, sort: [{ propertyId: "p-num", direction: "asc" }], pageSize: 100, }); expect(res.records.map((r) => r.id)).toEqual(["r1", "r3"]); }); it("grouped query returns groups + full record set", () => { const res = runRecordQuery({ records: rows, properties: props, group: [{ propertyId: "p-status", direction: "asc" }], pageSize: 100, }); expect(res.groups).not.toBeNull(); // 2 groups (todo, done): runRecordQuery drops the archived row (r5) BEFORE // grouping, and r5 is the only status-less row — so no empty group here. // (The 3rd "empty" group only appears when grouping the FULL row set via // groupRecords directly, as the "select grouping" test above pins.) expect(res.groups!.length).toBe(2); expect(res.total).toBe(4); }); it("visiblePropertyIds masks values", () => { const res = runRecordQuery({ records: rows, properties: props, pageSize: 100, visiblePropertyIds: ["p-title"], }); expect(Object.keys(res.records[0]!.values)).toEqual(["p-title"]); }); }); describe("EAV pivot", () => { it("pivots flat rows into record→property→value (codec-normalized)", () => { const piv = pivotValues( [ { recordId: "r1", propertyId: "p-num", value: "7" }, // string on wire → number via codec { recordId: "r1", propertyId: "p-status", value: "done" }, { recordId: "r2", propertyId: "p-num", value: 3 }, ], map, ); expect(piv.get("r1")!["p-num"]).toBe(7); expect(piv.get("r1")!["p-status"]).toBe("done"); expect(piv.get("r2")!["p-num"]).toBe(3); }); it("assembles rows with title from the title property", () => { const piv = pivotValues([{ recordId: "r1", propertyId: "p-title", value: "Hello" }], map); const out = assembleRows( [ { id: "r1", pageId: "pg", position: "a0", createdTime: 1, lastEditedTime: 1, archivedAt: null, }, ], piv, "p-title", ); expect(out[0]!.title).toBe("Hello"); }); });