@lotics/ui 20.0.1 → 20.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +39 -0
- package/package.json +5 -3
- package/src/agent_transform.test.ts +0 -96
- package/src/calendar/layout.test.ts +0 -103
- package/src/colors.test.ts +0 -45
- package/src/date_filter_presets.test.ts +0 -62
- package/src/date_picker_value.test.ts +0 -204
- package/src/date_range_selection.test.ts +0 -60
- package/src/date_segments.test.ts +0 -335
- package/src/file_badge_fit.test.ts +0 -49
- package/src/file_intake.test.ts +0 -124
- package/src/format_date.test.ts +0 -138
- package/src/format_date_negative_zone.test.ts +0 -26
- package/src/gantt/scale.test.ts +0 -47
- package/src/inline_focus.test.ts +0 -49
- package/src/matrix.test.ts +0 -54
- package/src/pending_commits.test.ts +0 -68
- package/src/popover_layers.test.ts +0 -113
- package/src/primitives_purity.test.ts +0 -73
- package/src/remark_gfm_safe.test.ts +0 -33
- package/src/share_or_download.test.ts +0 -92
- package/src/table.test.ts +0 -90
- package/src/task_metrics.test.ts +0 -48
- package/src/use_list_keyboard_nav.test.ts +0 -184
- package/src/use_option_list.test.ts +0 -232
- package/src/use_section_nav.test.ts +0 -171
- package/src/use_selection.test.ts +0 -66
- package/src/vite.test.ts +0 -41
package/src/gantt/scale.test.ts
DELETED
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { axisRange, barGeometry, buildTicks, pxPerDay } from "./scale";
|
|
3
|
-
import type { GanttTask } from "./types";
|
|
4
|
-
|
|
5
|
-
const AXIS = new Date(2026, 0, 1); // Jan 1 2026
|
|
6
|
-
const task = (id: string, s: [number, number], e?: [number, number]): GanttTask => ({
|
|
7
|
-
id,
|
|
8
|
-
label: id,
|
|
9
|
-
start: new Date(2026, s[0], s[1]),
|
|
10
|
-
end: e ? new Date(2026, e[0], e[1]) : null,
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
describe("gantt scale", () => {
|
|
14
|
-
it("zoom widths shrink as the scale widens", () => {
|
|
15
|
-
expect(pxPerDay("day")).toBeGreaterThan(pxPerDay("week"));
|
|
16
|
-
expect(pxPerDay("week")).toBeGreaterThan(pxPerDay("month"));
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
it("positions a bar by day offset and inclusive span", () => {
|
|
20
|
-
// Jan 3 → Jan 5 inclusive = 3 days, 2 days after the axis start.
|
|
21
|
-
const b = barGeometry(task("a", [0, 3], [0, 5]), AXIS, "day");
|
|
22
|
-
expect(b.left).toBe(2 * 40);
|
|
23
|
-
expect(b.width).toBe(3 * 40);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it("gives a single-day task one day of width (floored)", () => {
|
|
27
|
-
const b = barGeometry(task("m", [0, 10]), AXIS, "day");
|
|
28
|
-
expect(b.width).toBe(40);
|
|
29
|
-
const tiny = barGeometry(task("m", [0, 10]), AXIS, "month"); // 1 * 5px → floored to 8
|
|
30
|
-
expect(tiny.width).toBe(8);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it("derives a padded axis window from the tasks", () => {
|
|
34
|
-
const { start, end } = axisRange([task("a", [0, 5], [0, 8]), task("b", [0, 3], [0, 20])], new Date(2026, 0, 1), 3);
|
|
35
|
-
expect(start).toEqual(new Date(2026, 0, 0)); // Jan 3 - 3 = Dec 31 (= Jan 0)
|
|
36
|
-
expect(end).toEqual(new Date(2026, 0, 23)); // Jan 20 + 3
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it("emits one day-tick per day and flags month starts", () => {
|
|
40
|
-
const ticks = buildTicks(new Date(2026, 0, 30), new Date(2026, 1, 2), "day");
|
|
41
|
-
expect(ticks.map((t) => t.label)).toEqual(["30", "31", "1", "2"]);
|
|
42
|
-
expect(ticks.find((t) => t.label === "1")?.monthStart).toBe(true);
|
|
43
|
-
expect(ticks.find((t) => t.label === "30")?.monthStart).toBe(false);
|
|
44
|
-
expect(ticks[0].left).toBe(0);
|
|
45
|
-
expect(ticks[1].left).toBe(40); // one day across at day scale
|
|
46
|
-
});
|
|
47
|
-
});
|
package/src/inline_focus.test.ts
DELETED
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
import { describe, it, expect } from "vitest";
|
|
3
|
-
import { ensureModalityListeners, getInteractionModality } from "./interaction_modality";
|
|
4
|
-
import { FOCUS_OPEN_SUPPRESS_MS, shouldOpenOnFocus, shouldRestoreFocusOnClose } from "./inline_focus";
|
|
5
|
-
|
|
6
|
-
describe("shouldRestoreFocusOnClose — no scroll jump on a pointer close", () => {
|
|
7
|
-
it("restores focus on a keyboard close (Enter/Escape) — preserves Tab order", () => {
|
|
8
|
-
expect(shouldRestoreFocusOnClose("keyboard")).toBe(true);
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
it("does NOT restore on a pointer close (click ✓/✕ or click away) — a bare focus() would scroll the page", () => {
|
|
12
|
-
expect(shouldRestoreFocusOnClose("pointer")).toBe(false);
|
|
13
|
-
});
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
describe("shouldOpenOnFocus — the inline keyboard-entry gate", () => {
|
|
17
|
-
it("keyboard focus opens the editor", () => {
|
|
18
|
-
expect(shouldOpenOnFocus("keyboard", null, 1_000)).toBe(true);
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it("pointer focus never opens the editor — the click path stays press-driven", () => {
|
|
22
|
-
expect(shouldOpenOnFocus("pointer", null, 1_000)).toBe(false);
|
|
23
|
-
// …even outside any suppression window.
|
|
24
|
-
expect(shouldOpenOnFocus("pointer", 100, 100_000)).toBe(false);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it("a programmatic focus restore inside the suppression window does not re-open", () => {
|
|
28
|
-
const restoredAt = 5_000;
|
|
29
|
-
expect(shouldOpenOnFocus("keyboard", restoredAt, restoredAt)).toBe(false);
|
|
30
|
-
expect(shouldOpenOnFocus("keyboard", restoredAt, restoredAt + FOCUS_OPEN_SUPPRESS_MS - 1)).toBe(false);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it("suppression expires — a later Tab arrival opens again", () => {
|
|
34
|
-
const restoredAt = 5_000;
|
|
35
|
-
expect(shouldOpenOnFocus("keyboard", restoredAt, restoredAt + FOCUS_OPEN_SUPPRESS_MS)).toBe(true);
|
|
36
|
-
});
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
describe("interaction modality tracker", () => {
|
|
40
|
-
it("records keyboard on keydown and pointer on mousedown (Tab-vs-click focus)", () => {
|
|
41
|
-
ensureModalityListeners();
|
|
42
|
-
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab" }));
|
|
43
|
-
expect(getInteractionModality()).toBe("keyboard");
|
|
44
|
-
document.dispatchEvent(new MouseEvent("mousedown"));
|
|
45
|
-
expect(getInteractionModality()).toBe("pointer");
|
|
46
|
-
document.dispatchEvent(new KeyboardEvent("keydown", { key: "a" }));
|
|
47
|
-
expect(getInteractionModality()).toBe("keyboard");
|
|
48
|
-
});
|
|
49
|
-
});
|
package/src/matrix.test.ts
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { matrixTotals, type MatrixAxisItem } from "./matrix_totals";
|
|
3
|
-
|
|
4
|
-
const rows: MatrixAxisItem[] = [
|
|
5
|
-
{ key: "msc", label: "MSC" },
|
|
6
|
-
{ key: "one", label: "ONE" },
|
|
7
|
-
];
|
|
8
|
-
const cols: MatrixAxisItem[] = [
|
|
9
|
-
{ key: "20", label: "20ft" },
|
|
10
|
-
{ key: "40", label: "40ft" },
|
|
11
|
-
];
|
|
12
|
-
|
|
13
|
-
// MSC: 20→5, 40→3 ; ONE: 20→0, 40→7
|
|
14
|
-
const grid: Record<string, Record<string, number>> = {
|
|
15
|
-
msc: { "20": 5, "40": 3 },
|
|
16
|
-
one: { "20": 0, "40": 7 },
|
|
17
|
-
};
|
|
18
|
-
const value = (r: string, c: string) => grid[r]?.[c] ?? 0;
|
|
19
|
-
|
|
20
|
-
describe("matrixTotals", () => {
|
|
21
|
-
it("sums each row across its columns", () => {
|
|
22
|
-
const { rowTotals } = matrixTotals(rows, cols, value);
|
|
23
|
-
expect(rowTotals.get("msc")).toBe(8);
|
|
24
|
-
expect(rowTotals.get("one")).toBe(7);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it("sums each column across its rows", () => {
|
|
28
|
-
const { colTotals } = matrixTotals(rows, cols, value);
|
|
29
|
-
expect(colTotals.get("20")).toBe(5);
|
|
30
|
-
expect(colTotals.get("40")).toBe(10);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it("grand total equals the sum of every cell", () => {
|
|
34
|
-
expect(matrixTotals(rows, cols, value).grandTotal).toBe(15);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it("max is the largest single cell, for the heat scale", () => {
|
|
38
|
-
expect(matrixTotals(rows, cols, value).max).toBe(7);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it("is all-zero for an empty axis", () => {
|
|
42
|
-
const r = matrixTotals([], cols, value);
|
|
43
|
-
expect(r.grandTotal).toBe(0);
|
|
44
|
-
expect(r.max).toBe(0);
|
|
45
|
-
expect(r.colTotals.get("20")).toBeUndefined();
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it("counts a fully-empty grid as zero without dividing by it", () => {
|
|
49
|
-
const zero = matrixTotals(rows, cols, () => 0);
|
|
50
|
-
expect(zero.grandTotal).toBe(0);
|
|
51
|
-
expect(zero.max).toBe(0);
|
|
52
|
-
expect(zero.rowTotals.get("msc")).toBe(0);
|
|
53
|
-
});
|
|
54
|
-
});
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { pendingCommits, trackCommit } from "./pending_commits";
|
|
3
|
-
|
|
4
|
-
/** A field write that lands in `stored` after `ms`, like a save round trip. */
|
|
5
|
-
const commit = (stored: { value: string }, next: string, ms: number) =>
|
|
6
|
-
new Promise<void>((resolve) =>
|
|
7
|
-
setTimeout(() => {
|
|
8
|
-
stored.value = next;
|
|
9
|
-
resolve();
|
|
10
|
-
}, ms),
|
|
11
|
-
);
|
|
12
|
-
|
|
13
|
-
/** Let the registry's own settle callback run before asserting on it. */
|
|
14
|
-
const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
15
|
-
|
|
16
|
-
describe("pendingCommits", () => {
|
|
17
|
-
it("is null with nothing in flight — a press keeps its synchronous path", () => {
|
|
18
|
-
expect(pendingCommits()).toBeNull();
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it("holds until a commit still in flight has landed", async () => {
|
|
22
|
-
const stored = { value: "BKG CŨ: " };
|
|
23
|
-
void trackCommit(commit(stored, "BKG CŨ: SGNA52926200", 20));
|
|
24
|
-
const gate = pendingCommits();
|
|
25
|
-
expect(gate).not.toBeNull();
|
|
26
|
-
await gate;
|
|
27
|
-
expect(stored.value).toBe("BKG CŨ: SGNA52926200");
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it("holds for EVERY commit in flight, not just the first", async () => {
|
|
31
|
-
const landed: string[] = [];
|
|
32
|
-
const land = (name: string, ms: number) =>
|
|
33
|
-
new Promise<void>((resolve) =>
|
|
34
|
-
setTimeout(() => {
|
|
35
|
-
landed.push(name);
|
|
36
|
-
resolve();
|
|
37
|
-
}, ms),
|
|
38
|
-
);
|
|
39
|
-
void trackCommit(land("slow", 30));
|
|
40
|
-
void trackCommit(land("fast", 5));
|
|
41
|
-
await pendingCommits();
|
|
42
|
-
expect([...landed].sort()).toEqual(["fast", "slow"]);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it("releases again once the commits have landed", async () => {
|
|
46
|
-
await trackCommit(Promise.resolve());
|
|
47
|
-
await tick();
|
|
48
|
-
expect(pendingCommits()).toBeNull();
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it("is released by a FAILED commit too — a stuck gate would wedge every action", async () => {
|
|
52
|
-
const failed = trackCommit(Promise.reject(new Error("Save failed")));
|
|
53
|
-
// The caller owns the error: the inline editor keeps the edit and shows it.
|
|
54
|
-
await expect(failed).rejects.toThrow("Save failed");
|
|
55
|
-
await expect(pendingCommits() ?? Promise.resolve()).resolves.toBeUndefined();
|
|
56
|
-
await tick();
|
|
57
|
-
expect(pendingCommits()).toBeNull();
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it("ignores a commit started AFTER the gate was taken — it holds the press for the edit that caused it, not later work", async () => {
|
|
61
|
-
const stored = { value: "before" };
|
|
62
|
-
void trackCommit(commit(stored, "first", 5));
|
|
63
|
-
const gate = pendingCommits();
|
|
64
|
-
void trackCommit(commit(stored, "later", 40));
|
|
65
|
-
await gate;
|
|
66
|
-
expect(stored.value).toBe("first");
|
|
67
|
-
});
|
|
68
|
-
});
|
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
import { describe, expect, test, beforeEach } from "vitest";
|
|
3
|
-
import {
|
|
4
|
-
hasModalLayerAbove,
|
|
5
|
-
isInLayerAbove,
|
|
6
|
-
snapshotOpenModalLayers,
|
|
7
|
-
} from "./popover_layers";
|
|
8
|
-
|
|
9
|
-
function addModal(): HTMLElement {
|
|
10
|
-
const modal = document.createElement("div");
|
|
11
|
-
modal.setAttribute("aria-modal", "true");
|
|
12
|
-
const inner = document.createElement("button");
|
|
13
|
-
modal.appendChild(inner);
|
|
14
|
-
document.body.appendChild(modal);
|
|
15
|
-
return modal;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function addPopover(level: number): HTMLElement {
|
|
19
|
-
const popover = document.createElement("div");
|
|
20
|
-
popover.setAttribute("data-popover", "true");
|
|
21
|
-
popover.setAttribute("data-popover-level", String(level));
|
|
22
|
-
const inner = document.createElement("button");
|
|
23
|
-
popover.appendChild(inner);
|
|
24
|
-
document.body.appendChild(popover);
|
|
25
|
-
return popover;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
beforeEach(() => {
|
|
29
|
-
document.body.innerHTML = "";
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
describe("isInLayerAbove", () => {
|
|
33
|
-
test("plain page node is not above", () => {
|
|
34
|
-
const el = document.createElement("div");
|
|
35
|
-
document.body.appendChild(el);
|
|
36
|
-
expect(isInLayerAbove(el, 0, new Set())).toBe(false);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
test("document itself (page scroll target) is not above", () => {
|
|
40
|
-
expect(isInLayerAbove(document, 0, new Set())).toBe(false);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
test("text node resolves through its parent element", () => {
|
|
44
|
-
const modal = addModal();
|
|
45
|
-
const text = document.createTextNode("hello");
|
|
46
|
-
modal.appendChild(text);
|
|
47
|
-
expect(isInLayerAbove(text, 0, new Set())).toBe(true);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test("node inside a deeper nested popover is above", () => {
|
|
51
|
-
const nested = addPopover(1);
|
|
52
|
-
expect(isInLayerAbove(nested.firstChild as Node, 0, new Set())).toBe(true);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
test("node inside a same-level popover is not above", () => {
|
|
56
|
-
const sibling = addPopover(0);
|
|
57
|
-
expect(isInLayerAbove(sibling.firstChild as Node, 0, new Set())).toBe(false);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
test("node inside a modal opened AFTER the popover is above (file preview)", () => {
|
|
61
|
-
const snapshot = snapshotOpenModalLayers(document);
|
|
62
|
-
const preview = addModal();
|
|
63
|
-
expect(isInLayerAbove(preview.firstChild as Node, 0, snapshot)).toBe(true);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
test("node inside the modal the popover LIVES IN is not above (drawer scroll dismisses)", () => {
|
|
67
|
-
const drawer = addModal();
|
|
68
|
-
const snapshot = snapshotOpenModalLayers(document);
|
|
69
|
-
expect(isInLayerAbove(drawer.firstChild as Node, 0, snapshot)).toBe(false);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
test("stacked modals: only the one opened after the popover is above", () => {
|
|
73
|
-
const drawer = addModal();
|
|
74
|
-
const snapshot = snapshotOpenModalLayers(document);
|
|
75
|
-
const preview = addModal();
|
|
76
|
-
expect(isInLayerAbove(drawer.firstChild as Node, 0, snapshot)).toBe(false);
|
|
77
|
-
expect(isInLayerAbove(preview.firstChild as Node, 0, snapshot)).toBe(true);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
test("null snapshot treats any modal ancestor as above", () => {
|
|
81
|
-
const modal = addModal();
|
|
82
|
-
expect(isInLayerAbove(modal.firstChild as Node, 0, null)).toBe(true);
|
|
83
|
-
});
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
describe("hasModalLayerAbove", () => {
|
|
87
|
-
test("false with no modals", () => {
|
|
88
|
-
expect(hasModalLayerAbove(document, new Set())).toBe(false);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
test("false while only pre-existing modals are open (popover in a drawer)", () => {
|
|
92
|
-
addModal();
|
|
93
|
-
const snapshot = snapshotOpenModalLayers(document);
|
|
94
|
-
expect(hasModalLayerAbove(document, snapshot)).toBe(false);
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
test("true while a modal opened after the popover is open, false after it closes", () => {
|
|
98
|
-
addModal();
|
|
99
|
-
const snapshot = snapshotOpenModalLayers(document);
|
|
100
|
-
const preview = addModal();
|
|
101
|
-
expect(hasModalLayerAbove(document, snapshot)).toBe(true);
|
|
102
|
-
preview.remove();
|
|
103
|
-
expect(hasModalLayerAbove(document, snapshot)).toBe(false);
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
test("a reopened modal is a NEW element and counts as above", () => {
|
|
107
|
-
const snapshot = snapshotOpenModalLayers(document);
|
|
108
|
-
const first = addModal();
|
|
109
|
-
first.remove();
|
|
110
|
-
addModal();
|
|
111
|
-
expect(hasModalLayerAbove(document, snapshot)).toBe(true);
|
|
112
|
-
});
|
|
113
|
-
});
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import * as fs from "node:fs";
|
|
3
|
-
import * as path from "node:path";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Primitives purity test.
|
|
7
|
-
*
|
|
8
|
-
* `@lotics/ui` is the public, published, universal-rendering primitives
|
|
9
|
-
* package. Every file in `src/` is a primitive. It must not import any
|
|
10
|
-
* dependency that ties it to a specific Lotics runtime context: no chat
|
|
11
|
-
* SDK, no analytics, no i18n, and no Lotics domain types (which live in
|
|
12
|
-
* `@lotics/shared`). Lotics-coupled UI is in the workspace-private
|
|
13
|
-
* `@lotics/ui-internal` package, which may import from here and from
|
|
14
|
-
* `@lotics/shared` freely.
|
|
15
|
-
*
|
|
16
|
-
* Markdown (react-markdown/remark) is the one heavy third-party dependency
|
|
17
|
-
* the kit carries on purpose — it's the single canonical renderer for chat,
|
|
18
|
-
* apps, and `AgentRun`. It's generic, not a Lotics coupling; it stays
|
|
19
|
-
* universal via the `markdown.web.tsx` (DOM) / `markdown.tsx` (native
|
|
20
|
-
* plain-text) split and is tree-shaken out of any consumer that doesn't
|
|
21
|
-
* import `./markdown`. So it's allowed — the guards below remain for the
|
|
22
|
-
* genuine context couplings.
|
|
23
|
-
*
|
|
24
|
-
* The ONE allowed `ai` coupling: the `AgentRun` family renders the ai-sdk
|
|
25
|
-
* `UIMessage.parts` shape directly — that IS its contract (both chat and app
|
|
26
|
-
* agents emit it), so there is no bespoke transcript type to keep in sync.
|
|
27
|
-
* `agent_transform.ts` (the single place that folds parts into the render
|
|
28
|
-
* timeline) `import type`s from `ai` — erased at runtime, so no `ai` enters any
|
|
29
|
-
* consumer's bundle. That file alone is exempt from the `ai`-package guard; every
|
|
30
|
-
* other primitive stays ai-free, and the `@ai-sdk/*` runtime packages are never
|
|
31
|
-
* allowed anywhere.
|
|
32
|
-
*
|
|
33
|
-
* The boundary is enforced by this test — without it primitives drift
|
|
34
|
-
* back into the parent app's contexts over months.
|
|
35
|
-
*/
|
|
36
|
-
|
|
37
|
-
const AI_PACKAGE_PATTERN = /from\s+["']ai["']/;
|
|
38
|
-
const FORBIDDEN_PATTERNS = [
|
|
39
|
-
/from\s+["']posthog-js/,
|
|
40
|
-
/from\s+["']@lingui\//,
|
|
41
|
-
/from\s+["']@ai-sdk\//,
|
|
42
|
-
AI_PACKAGE_PATTERN,
|
|
43
|
-
/from\s+["']@lotics\/shared/,
|
|
44
|
-
];
|
|
45
|
-
|
|
46
|
-
// The sole file permitted to `import type` from the `ai` package (see above).
|
|
47
|
-
const AI_PACKAGE_ALLOWED = new Set(["agent_transform.ts"]);
|
|
48
|
-
|
|
49
|
-
const SRC_DIR = path.resolve(__dirname);
|
|
50
|
-
|
|
51
|
-
function listTopLevelSourceFiles(): string[] {
|
|
52
|
-
const entries = fs.readdirSync(SRC_DIR, { withFileTypes: true });
|
|
53
|
-
return entries
|
|
54
|
-
.filter((e) => e.isFile() && (e.name.endsWith(".ts") || e.name.endsWith(".tsx")))
|
|
55
|
-
.filter((e) => !e.name.endsWith(".test.ts") && !e.name.endsWith(".test.tsx"))
|
|
56
|
-
.map((e) => path.join(SRC_DIR, e.name));
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
describe("packages/ui primitives — purity", () => {
|
|
60
|
-
it("no primitive imports posthog, lingui, ai-sdk, or @lotics/shared", () => {
|
|
61
|
-
const violations: string[] = [];
|
|
62
|
-
for (const file of listTopLevelSourceFiles()) {
|
|
63
|
-
const src = fs.readFileSync(file, "utf-8");
|
|
64
|
-
for (const pattern of FORBIDDEN_PATTERNS) {
|
|
65
|
-
if (pattern === AI_PACKAGE_PATTERN && AI_PACKAGE_ALLOWED.has(path.basename(file))) continue;
|
|
66
|
-
if (pattern.test(src)) {
|
|
67
|
-
violations.push(`${path.basename(file)} matches ${pattern}`);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
expect(violations).toEqual([]);
|
|
72
|
-
});
|
|
73
|
-
});
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { unified } from "unified";
|
|
3
|
-
import remarkParse from "remark-parse";
|
|
4
|
-
import { remarkGfmSafe } from "./remark_gfm_safe";
|
|
5
|
-
|
|
6
|
-
/** Parse markdown to an mdast tree and serialize it, so tests assert on the
|
|
7
|
-
* resulting document structure without depending on mdast node typings. */
|
|
8
|
-
function parse(markdown: string): string {
|
|
9
|
-
return JSON.stringify(unified().use(remarkParse).use(remarkGfmSafe).parse(markdown));
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
describe("remarkGfmSafe", () => {
|
|
13
|
-
it("autolinks a bare URL in normal text (micromark tokenizer, no lookbehind)", () => {
|
|
14
|
-
expect(parse("see https://example.com now")).toContain('"url":"https://example.com"');
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
it("autolinks a bare email in normal text (the case the removed sweep also handled)", () => {
|
|
18
|
-
expect(parse("contact me at a@b.com please")).toContain('"url":"mailto:a@b.com"');
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it("autolinks a bare www link", () => {
|
|
22
|
-
expect(parse("visit www.example.com today")).toContain('"url":"http://www.example.com"');
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it("still parses GFM tables", () => {
|
|
26
|
-
expect(parse("| a | b |\n| --- | --- |\n| 1 | 2 |")).toContain('"type":"table"');
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it("still parses strikethrough and task lists", () => {
|
|
30
|
-
expect(parse("~~gone~~")).toContain('"type":"delete"');
|
|
31
|
-
expect(parse("- [x] done")).toContain('"checked":true');
|
|
32
|
-
});
|
|
33
|
-
});
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
-
import { shareOrDownloadFiles, prepareShareFiles, shareFiles } from "./share_or_download";
|
|
3
|
-
|
|
4
|
-
afterEach(() => {
|
|
5
|
-
vi.restoreAllMocks();
|
|
6
|
-
vi.unstubAllGlobals();
|
|
7
|
-
});
|
|
8
|
-
|
|
9
|
-
describe("shareOrDownloadFiles", () => {
|
|
10
|
-
it("is a no-op (download/0) when there are no usable files", async () => {
|
|
11
|
-
expect(await shareOrDownloadFiles([])).toEqual({ delivered: "download", count: 0 });
|
|
12
|
-
expect(await shareOrDownloadFiles([{ url: "", filename: "x.png" }])).toEqual({ delivered: "download", count: 0 });
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
it("hands the BYTES (not the URL) to the OS share sheet when files can be shared", async () => {
|
|
16
|
-
vi.stubGlobal("fetch", vi.fn(async () => new Response(new Blob(["img"], { type: "image/png" }))));
|
|
17
|
-
// navigator.share is a true external boundary — assert WHAT crosses it. Typing
|
|
18
|
-
// the mock's param makes mock.calls[0][0] the shared payload.
|
|
19
|
-
const share = vi.fn(async (_data: { files: File[]; title?: string }) => undefined);
|
|
20
|
-
vi.stubGlobal("navigator", { canShare: () => true, share });
|
|
21
|
-
|
|
22
|
-
const res = await shareOrDownloadFiles([{ url: "https://r2/presigned", filename: "a.png", mimeType: "image/png" }], { title: "Ảnh" });
|
|
23
|
-
|
|
24
|
-
expect(res).toEqual({ delivered: "share", count: 1 });
|
|
25
|
-
const shared = share.mock.calls[0][0];
|
|
26
|
-
expect(shared.files).toHaveLength(1);
|
|
27
|
-
expect(shared.files[0]).toBeInstanceOf(File); // bytes, not a link → no expiry problem
|
|
28
|
-
expect(shared.files[0].name).toBe("a.png");
|
|
29
|
-
expect(shared.title).toBe("Ảnh");
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it("rides the given credentials on the blob fetch so an auth-gated proxy URL authorizes", async () => {
|
|
33
|
-
// Type the init param so mock.calls[0][1] is the RequestInit we assert on.
|
|
34
|
-
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => new Response(new Blob(["img"], { type: "image/png" })));
|
|
35
|
-
vi.stubGlobal("fetch", fetchMock);
|
|
36
|
-
vi.stubGlobal("navigator", { canShare: () => true, share: vi.fn(async () => undefined) });
|
|
37
|
-
|
|
38
|
-
await shareOrDownloadFiles([{ url: "https://api.example/v1/files/k", filename: "a.png" }], { credentials: "include" });
|
|
39
|
-
|
|
40
|
-
expect(fetchMock.mock.calls[0][1]).toMatchObject({ credentials: "include" });
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it("treats a cancelled share sheet (AbortError) as done — never falls back to downloading", async () => {
|
|
44
|
-
vi.stubGlobal("fetch", vi.fn(async () => new Response(new Blob(["img"], { type: "image/png" }))));
|
|
45
|
-
const share = vi.fn(async () => { throw new DOMException("cancelled", "AbortError"); });
|
|
46
|
-
vi.stubGlobal("navigator", { canShare: () => true, share });
|
|
47
|
-
|
|
48
|
-
expect(await shareOrDownloadFiles([{ url: "u", filename: "a.png" }])).toEqual({ delivered: "share", count: 0 });
|
|
49
|
-
});
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
describe("prepareShareFiles + shareFiles (iOS: fetch ahead of the gesture, share synchronously)", () => {
|
|
53
|
-
it("prepareShareFiles fetches the bytes into File[] with the given credentials", async () => {
|
|
54
|
-
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => new Response(new Blob(["img"], { type: "image/png" })));
|
|
55
|
-
vi.stubGlobal("fetch", fetchMock);
|
|
56
|
-
vi.stubGlobal("navigator", { canShare: () => true, share: vi.fn() });
|
|
57
|
-
|
|
58
|
-
const files = await prepareShareFiles([{ url: "https://api/v1/files/k", filename: "a.png" }], { credentials: "include" });
|
|
59
|
-
|
|
60
|
-
expect(files).not.toBeNull();
|
|
61
|
-
expect(files?.[0]).toBeInstanceOf(File);
|
|
62
|
-
expect(files?.[0].name).toBe("a.png");
|
|
63
|
-
expect(fetchMock.mock.calls[0][1]).toMatchObject({ credentials: "include" });
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it("prepareShareFiles returns null when Web Share (files) is unsupported", async () => {
|
|
67
|
-
vi.stubGlobal("fetch", vi.fn(async () => new Response(new Blob(["img"]))));
|
|
68
|
-
vi.stubGlobal("navigator", {}); // no canShare / share
|
|
69
|
-
expect(await prepareShareFiles([{ url: "u", filename: "a.png" }])).toBeNull();
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it("prepareShareFiles THROWS on a fetch failure (caller logs it, not a silent download)", async () => {
|
|
73
|
-
vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 401 })));
|
|
74
|
-
vi.stubGlobal("navigator", { canShare: () => true, share: vi.fn() });
|
|
75
|
-
await expect(prepareShareFiles([{ url: "u", filename: "a.png" }])).rejects.toThrow(/401/);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it("shareFiles shares the SAME pre-fetched File (no re-fetch) and maps cancel/unsupported", async () => {
|
|
79
|
-
const share = vi.fn(async (_d: { files: File[] }) => undefined);
|
|
80
|
-
vi.stubGlobal("navigator", { canShare: () => true, share });
|
|
81
|
-
const file = new File(["x"], "a.png", { type: "image/png" });
|
|
82
|
-
|
|
83
|
-
expect(await shareFiles([file], { title: "T" })).toBe("shared");
|
|
84
|
-
expect(share.mock.calls[0][0].files[0]).toBe(file); // the exact pre-fetched File crosses the boundary
|
|
85
|
-
|
|
86
|
-
vi.stubGlobal("navigator", { share: vi.fn(async () => { throw new DOMException("x", "AbortError"); }) });
|
|
87
|
-
expect(await shareFiles([file])).toBe("cancelled");
|
|
88
|
-
|
|
89
|
-
vi.stubGlobal("navigator", {}); // no share
|
|
90
|
-
expect(await shareFiles([file])).toBe("unsupported");
|
|
91
|
-
});
|
|
92
|
-
});
|
package/src/table.test.ts
DELETED
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { computeTableFit, type TableFitColumn } from "./table_fit";
|
|
3
|
-
|
|
4
|
-
// The canonical register's columns (tpl_item_list): a fixed identity column,
|
|
5
|
-
// a flexible name column, then fixed data columns — plus a 24px leading
|
|
6
|
-
// checkbox gutter and a 132px trailing actions gutter.
|
|
7
|
-
const REGISTER: TableFitColumn[] = [
|
|
8
|
-
{ key: "ma", width: 112 },
|
|
9
|
-
{ key: "khach" }, // flex
|
|
10
|
-
{ key: "trangThai", width: 112 },
|
|
11
|
-
{ key: "viec", width: 82 },
|
|
12
|
-
{ key: "phi", width: 116 },
|
|
13
|
-
{ key: "ngayNhan", width: 72 },
|
|
14
|
-
];
|
|
15
|
-
|
|
16
|
-
function visible(fit: ReturnType<typeof computeTableFit>): string[] {
|
|
17
|
-
return REGISTER.filter((c) => fit.visibleKeys.has(c.key)).map((c) => c.key);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
describe("computeTableFit", () => {
|
|
21
|
-
it("keeps every column when the container fits them all", () => {
|
|
22
|
-
const fit = computeTableFit(REGISTER, 24, 132, 1040);
|
|
23
|
-
expect(fit.stacked).toBe(false);
|
|
24
|
-
expect(visible(fit)).toEqual(["ma", "khach", "trangThai", "viec", "phi", "ngayNhan"]);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it("drops columns right-to-left by default, one at a time, until the rest fit", () => {
|
|
28
|
-
// 700px: the full set (908px) and the set minus "ngayNhan" (822px) overflow;
|
|
29
|
-
// minus "phi" too (692px) fits.
|
|
30
|
-
const fit = computeTableFit(REGISTER, 24, 132, 700);
|
|
31
|
-
expect(fit.stacked).toBe(false);
|
|
32
|
-
expect(visible(fit)).toEqual(["ma", "khach", "trangThai", "viec"]);
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it("keeps a high-priority column alive while lower-priority ones drop", () => {
|
|
36
|
-
const withPriority = REGISTER.map((c) => (c.key === "phi" ? { ...c, priority: 1 } : c));
|
|
37
|
-
const fit = computeTableFit(withPriority, 24, 132, 700);
|
|
38
|
-
expect(fit.stacked).toBe(false);
|
|
39
|
-
// "phi" (priority 1) outlives "trangThai"/"viec"/"ngayNhan" (default = index).
|
|
40
|
-
expect(fit.visibleKeys.has("phi")).toBe(true);
|
|
41
|
-
expect(fit.visibleKeys.has("trangThai")).toBe(false);
|
|
42
|
-
expect(fit.visibleKeys.has("viec")).toBe(false);
|
|
43
|
-
expect(fit.visibleKeys.has("ngayNhan")).toBe(false);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it("never drops the first column", () => {
|
|
47
|
-
// Wide enough for exactly the identity + flex pair, no more.
|
|
48
|
-
const fit = computeTableFit(REGISTER, 0, 0, 320);
|
|
49
|
-
expect(fit.stacked).toBe(false);
|
|
50
|
-
expect(fit.visibleKeys.has("ma")).toBe(true);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it("never drops the first column even when it carries the worst priority", () => {
|
|
54
|
-
const hostile = REGISTER.map((c) => (c.key === "ma" ? { ...c, priority: 99 } : c));
|
|
55
|
-
const fit = computeTableFit(hostile, 24, 132, 700);
|
|
56
|
-
expect(fit.stacked).toBe(false);
|
|
57
|
-
expect(fit.visibleKeys.has("ma")).toBe(true);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it("stacks when even the minimum column set overflows (the phone case)", () => {
|
|
61
|
-
// A 390px phone minus the page padding: the canonical register's identity +
|
|
62
|
-
// customer + gutters still need ~470px.
|
|
63
|
-
const fit = computeTableFit(REGISTER, 24, 132, 334);
|
|
64
|
-
expect(fit.stacked).toBe(true);
|
|
65
|
-
// Stacked mode shows every column — vertical space is free.
|
|
66
|
-
expect(visible(fit)).toEqual(["ma", "khach", "trangThai", "viec", "phi", "ngayNhan"]);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("counts the leading/trailing gutters and gaps in the fit math", () => {
|
|
70
|
-
// Two fixed columns: 40 padding + 100 + 100 + 1 gap (14) = 254 exactly.
|
|
71
|
-
const two: TableFitColumn[] = [
|
|
72
|
-
{ key: "a", width: 100 },
|
|
73
|
-
{ key: "b", width: 100 },
|
|
74
|
-
];
|
|
75
|
-
expect(computeTableFit(two, 0, 0, 254).stacked).toBe(false);
|
|
76
|
-
expect(computeTableFit(two, 0, 0, 253).stacked).toBe(true);
|
|
77
|
-
// Adding a 24px leading gutter adds the gutter and one more gap.
|
|
78
|
-
expect(computeTableFit(two, 24, 0, 292).stacked).toBe(false);
|
|
79
|
-
expect(computeTableFit(two, 24, 0, 291).stacked).toBe(true);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it("a single-column table fits until the gutters overflow, then stacks", () => {
|
|
83
|
-
const one: TableFitColumn[] = [{ key: "a" }];
|
|
84
|
-
// 40 padding + 120 flex minimum = 160.
|
|
85
|
-
expect(computeTableFit(one, 0, 0, 200).stacked).toBe(false);
|
|
86
|
-
const tiny = computeTableFit(one, 0, 0, 100);
|
|
87
|
-
expect(tiny.stacked).toBe(true);
|
|
88
|
-
expect(tiny.visibleKeys.has("a")).toBe(true);
|
|
89
|
-
});
|
|
90
|
-
});
|
package/src/task_metrics.test.ts
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { TASK_ROW_BAND, TASK_ROW_GAP, TASK_TITLE_LINE, taskGutter, taskTitleSlack } from "./task_metrics";
|
|
3
|
-
|
|
4
|
-
describe("task row geometry", () => {
|
|
5
|
-
it("puts the control's centre and the title's first text line on the SAME y, at every density", () => {
|
|
6
|
-
for (const band of Object.values(TASK_ROW_BAND)) {
|
|
7
|
-
// The control centres in the band; the title's first line centres at its slack + half a
|
|
8
|
-
// line. A density whose band breaks this equality sags the control off its own title.
|
|
9
|
-
expect(taskTitleSlack(band) + TASK_TITLE_LINE / 2).toBe(band / 2);
|
|
10
|
-
}
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
it("pins the first line on any band the family builds, not only the density table's", () => {
|
|
14
|
-
// A sub-field's line is the TALLER of the row band and the control it holds (a 40px inline
|
|
15
|
-
// editor does not fit a `dense` 32 row), so the law has to hold on bands the density table
|
|
16
|
-
// never names.
|
|
17
|
-
for (let band = TASK_TITLE_LINE; band <= 64; band += 2) {
|
|
18
|
-
expect(taskTitleSlack(band) + TASK_TITLE_LINE / 2).toBe(band / 2);
|
|
19
|
-
}
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it("lands every density on whole pixels", () => {
|
|
23
|
-
for (const band of Object.values(TASK_ROW_BAND)) {
|
|
24
|
-
expect(Number.isInteger(taskTitleSlack(band))).toBe(true);
|
|
25
|
-
}
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it("never pulls a title above its row when the band is shorter than a line of text", () => {
|
|
29
|
-
expect(taskTitleSlack(TASK_TITLE_LINE - 8)).toBe(0);
|
|
30
|
-
});
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
describe("task row gutters", () => {
|
|
34
|
-
it("gives a pinned control its own width plus the gap that separates it from the content", () => {
|
|
35
|
-
// ONE rule for BOTH edges: the leading ring's column and the trailing ⋯'s column are the same
|
|
36
|
-
// arithmetic, which is what lets a row reserve them as plain padding and leave a content box
|
|
37
|
-
// every line — title, caption, sub-row, detail, nested list — can span exactly.
|
|
38
|
-
for (const width of [20, 24, 28, 40]) {
|
|
39
|
-
expect(taskGutter(width)).toBe(width + TASK_ROW_GAP);
|
|
40
|
-
}
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it("charges NOTHING for a gutter a list declines", () => {
|
|
44
|
-
// A list whose rows carry no ⋯ passes `actionWidth={0}` and its content must run to the
|
|
45
|
-
// container's edge. Leaving the gap behind would bill it 12px for a column it never uses.
|
|
46
|
-
expect(taskGutter(0)).toBe(0);
|
|
47
|
-
});
|
|
48
|
-
});
|