diff --git a/web/packages/agenta-entities/.gitignore b/web/packages/agenta-entities/.gitignore new file mode 100644 index 0000000000..96d253c48e --- /dev/null +++ b/web/packages/agenta-entities/.gitignore @@ -0,0 +1,3 @@ +# Generated by Vitest — do not commit +test-results/ +coverage/ diff --git a/web/packages/agenta-entities/package.json b/web/packages/agenta-entities/package.json index caf5e8ba04..0a5feb6b66 100644 --- a/web/packages/agenta-entities/package.json +++ b/web/packages/agenta-entities/package.json @@ -13,7 +13,10 @@ "types:check": "tsc --noEmit", "lint": "eslint --config ../eslint.config.mjs src/ --max-warnings 0", "lint:fix": "eslint --config ../eslint.config.mjs src/ --max-warnings 0 --fix", - "test": "pnpm run types:check && pnpm run lint" + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "check": "pnpm run types:check && pnpm run lint" }, "exports": { ".": "./src/index.ts", @@ -81,7 +84,11 @@ "lexical": "^0.40.0", "prismjs": ">=1.30.0", "typescript": "5.8.3", - "usehooks-ts": "^3.0.0" + "usehooks-ts": "^3.0.0", + "@vitest/coverage-v8": "^4.1.4", + "vitest": "^4.1.4", + "jotai": "^2.16.1", + "jotai-family": "^1.0.1" }, "peerDependencies": { "@ant-design/icons": ">=5.0.0", diff --git a/web/packages/agenta-entities/tests/__mocks__/agenta-ui.ts b/web/packages/agenta-entities/tests/__mocks__/agenta-ui.ts new file mode 100644 index 0000000000..f8b9698b91 --- /dev/null +++ b/web/packages/agenta-entities/tests/__mocks__/agenta-ui.ts @@ -0,0 +1,18 @@ +/** + * Lightweight stub for @agenta/ui used in Vitest node-env tests. + * + * The real @agenta/ui pulls in antd which is enormous and causes the Vitest + * transformer to time out. Our entity tests only exercise Jotai atoms — they + * never render React components — so returning no-op stubs here is safe. + */ + +export const InitialsAvatar = () => null + +// Add additional no-op exports here if other @agenta/ui symbols are imported +// by entity source files in the future. +export const cn = (...args: unknown[]) => args.filter(Boolean).join(" ") +export const textColors = {} +export const bgColors = {} +export const EnhancedModal = () => null +export const ModalContent = () => null +export const ModalFooter = () => null diff --git a/web/packages/agenta-entities/tests/unit/__smoke__/entity-imports.test.ts b/web/packages/agenta-entities/tests/unit/__smoke__/entity-imports.test.ts new file mode 100644 index 0000000000..f3ee73a04a --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/__smoke__/entity-imports.test.ts @@ -0,0 +1,28 @@ +/** + * Import smoke test — verifies that entity molecules load without error in Node. + * Remove this file once all entities have real unit tests. + */ +import {describe, it, expect} from "vitest" + +describe("entity molecule imports (Node env smoke)", () => { + it("testset molecule imports without throwing", async () => { + const mod = await import("../../../src/testset/index") + expect(mod.testsetMolecule).toBeDefined() + expect(mod.revisionMolecule).toBeDefined() + }) + + it("testcase molecule imports without throwing", async () => { + const mod = await import("../../../src/testcase/index") + expect(mod.testcaseMolecule).toBeDefined() + }) + + it("trace molecule imports without throwing", async () => { + const mod = await import("../../../src/trace/index") + expect(mod.traceSpanMolecule).toBeDefined() + }) + + it("environment molecule imports without throwing", async () => { + const mod = await import("../../../src/environment/index") + expect(mod.environmentMolecule).toBeDefined() + }) +}) diff --git a/web/packages/agenta-entities/tests/unit/draft-state.test.ts b/web/packages/agenta-entities/tests/unit/draft-state.test.ts new file mode 100644 index 0000000000..7b5d94df54 --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/draft-state.test.ts @@ -0,0 +1,263 @@ +/** + * Unit tests for createEntityDraftState + * + * This factory is the foundation of how every entity in @agenta/entities + * tracks local edits. It manages four things: + * - draftAtomFamily — stores the pending local edit (null = no edit) + * - withDraftAtomFamily — merges draft over server state + * - isDirtyAtomFamily — true when the draft differs from server + * - hasDraftAtomFamily — true when any draft exists (even if identical) + * + * Tests run in isolation using Jotai's createStore() — no API calls, no React. + */ + +import {describe, it, expect, beforeEach} from "vitest" +import {atom, createStore} from "jotai" +import type {PrimitiveAtom} from "jotai" + +import {createEntityDraftState} from "../../src/shared/molecule/createEntityDraftState" + +// ── Fixture type ────────────────────────────────────────────────────────────── + +type Note = { + id: string + title: string + body: string +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** + * Build a minimal entity atom family backed by a plain map of primitive atoms. + * This avoids the jotai-family peer dep in test setup while still satisfying + * the EntityDraftStateConfig interface. + */ +function makeEntityAtomFamily(initial: Record = {}) { + const cache: Record> = {} + + function entityAtomFamily(id: string): PrimitiveAtom { + if (!cache[id]) { + cache[id] = atom(initial[id] ?? null) + } + return cache[id] + } + + return {entityAtomFamily, cache} +} + +function makeDraftState(initial: Record = {}) { + const {entityAtomFamily} = makeEntityAtomFamily(initial) + const store = createStore() + + const draftState = createEntityDraftState({ + entityAtomFamily, + getDraftableData: (note) => note, + mergeDraft: (note, draft) => ({...note, ...draft}), + }) + + return {store, draftState, entityAtomFamily} +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("createEntityDraftState", () => { + const serverNote: Note = {id: "note-1", title: "Hello", body: "World"} + + // ── Initial state ───────────────────────────────────────────────────────── + + describe("initial state (no draft)", () => { + it("hasDraft is false when no draft has been set", () => { + const {store, draftState} = makeDraftState({"note-1": serverNote}) + expect(store.get(draftState.hasDraftAtomFamily("note-1"))).toBe(false) + }) + + it("isDirty is false when no draft has been set", () => { + const {store, draftState} = makeDraftState({"note-1": serverNote}) + expect(store.get(draftState.isDirtyAtomFamily("note-1"))).toBe(false) + }) + + it("withDraft returns the server entity when no draft exists", () => { + const {store, draftState} = makeDraftState({"note-1": serverNote}) + expect(store.get(draftState.withDraftAtomFamily("note-1"))).toEqual(serverNote) + }) + + it("withDraft returns null when the entity does not exist", () => { + const {store, draftState} = makeDraftState({}) + expect(store.get(draftState.withDraftAtomFamily("missing"))).toBeNull() + }) + }) + + // ── Applying an update ──────────────────────────────────────────────────── + + describe("after applying an update", () => { + let store: ReturnType + let draftState: ReturnType> + + beforeEach(() => { + ;({store, draftState} = makeDraftState({"note-1": serverNote})) + store.set(draftState.updateAtom, "note-1", {title: "Updated title"}) + }) + + it("hasDraft becomes true", () => { + expect(store.get(draftState.hasDraftAtomFamily("note-1"))).toBe(true) + }) + + it("isDirty becomes true", () => { + expect(store.get(draftState.isDirtyAtomFamily("note-1"))).toBe(true) + }) + + it("withDraft returns the merged entity (draft wins)", () => { + const merged = store.get(draftState.withDraftAtomFamily("note-1")) + expect(merged?.title).toBe("Updated title") + expect(merged?.body).toBe("World") + }) + + it("the raw draft atom holds the full merged draftable data", () => { + const draft = store.get(draftState.draftAtomFamily("note-1")) + expect(draft?.title).toBe("Updated title") + expect(draft?.body).toBe("World") + }) + }) + + // ── Smart draft clearing ────────────────────────────────────────────────── + + describe("smart draft clearing", () => { + it("clears the draft automatically when updates bring values back to server state", () => { + const {store, draftState} = makeDraftState({"note-1": serverNote}) + + // Change the title + store.set(draftState.updateAtom, "note-1", {title: "Changed"}) + expect(store.get(draftState.isDirtyAtomFamily("note-1"))).toBe(true) + + // Revert the title back to original + store.set(draftState.updateAtom, "note-1", {title: "Hello"}) + + // Draft should be cleared — we're back to the server state + expect(store.get(draftState.isDirtyAtomFamily("note-1"))).toBe(false) + expect(store.get(draftState.hasDraftAtomFamily("note-1"))).toBe(false) + }) + }) + + // ── Discard draft ───────────────────────────────────────────────────────── + + describe("discarding a draft", () => { + it("clears the draft and restores server state", () => { + const {store, draftState} = makeDraftState({"note-1": serverNote}) + + store.set(draftState.updateAtom, "note-1", {title: "Pending change"}) + store.set(draftState.discardDraftAtom, "note-1") + + expect(store.get(draftState.hasDraftAtomFamily("note-1"))).toBe(false) + expect(store.get(draftState.isDirtyAtomFamily("note-1"))).toBe(false) + expect(store.get(draftState.withDraftAtomFamily("note-1"))).toEqual(serverNote) + }) + + it("discard is a no-op when there is no draft", () => { + const {store, draftState} = makeDraftState({"note-1": serverNote}) + + store.set(draftState.discardDraftAtom, "note-1") + + expect(store.get(draftState.hasDraftAtomFamily("note-1"))).toBe(false) + expect(store.get(draftState.withDraftAtomFamily("note-1"))).toEqual(serverNote) + }) + }) + + // ── Update with no entity ───────────────────────────────────────────────── + + describe("update when entity is null", () => { + it("does nothing when the entity does not exist in the store", () => { + const {store, draftState} = makeDraftState({}) + + // Should not throw + store.set(draftState.updateAtom, "ghost", {title: "Ghost"}) + + expect(store.get(draftState.hasDraftAtomFamily("ghost"))).toBe(false) + }) + }) + + // ── excludeFields ───────────────────────────────────────────────────────── + + describe("excludeFields", () => { + it("does not count excluded fields when checking isDirty", () => { + const {entityAtomFamily} = makeEntityAtomFamily({"note-1": serverNote}) + const store = createStore() + + const draftState = createEntityDraftState({ + entityAtomFamily, + getDraftableData: (note) => note, + mergeDraft: (note, draft) => ({...note, ...draft}), + excludeFields: new Set(["id"]), + }) + + // Update only the excluded 'id' field + store.set(draftState.updateAtom, "note-1", {id: "note-999"}) + + // Should not be considered dirty since 'id' is excluded + expect(store.get(draftState.isDirtyAtomFamily("note-1"))).toBe(false) + }) + + it("still detects dirty when a non-excluded field changes", () => { + const {entityAtomFamily} = makeEntityAtomFamily({"note-1": serverNote}) + const store = createStore() + + const draftState = createEntityDraftState({ + entityAtomFamily, + getDraftableData: (note) => note, + mergeDraft: (note, draft) => ({...note, ...draft}), + excludeFields: new Set(["id"]), + }) + + store.set(draftState.updateAtom, "note-1", {title: "Changed title"}) + + expect(store.get(draftState.isDirtyAtomFamily("note-1"))).toBe(true) + }) + }) + + // ── Custom isDirty ──────────────────────────────────────────────────────── + + describe("custom isDirty function", () => { + it("uses the provided isDirty function instead of the default", () => { + const {entityAtomFamily} = makeEntityAtomFamily({"note-1": serverNote}) + const store = createStore() + + // Always reports not dirty — ignores all changes + const draftState = createEntityDraftState({ + entityAtomFamily, + getDraftableData: (note) => note, + mergeDraft: (note, draft) => ({...note, ...draft}), + isDirty: () => false, + }) + + store.set(draftState.updateAtom, "note-1", {title: "Anything"}) + + // Custom isDirty says "not dirty", so draft should have been cleared + expect(store.get(draftState.isDirtyAtomFamily("note-1"))).toBe(false) + }) + }) + + // ── Partial field update ────────────────────────────────────────────────── + + describe("partial field update", () => { + it("only changes the specified fields, leaving others intact", () => { + const {store, draftState} = makeDraftState({"note-1": serverNote}) + + store.set(draftState.updateAtom, "note-1", {body: "New body"}) + + const merged = store.get(draftState.withDraftAtomFamily("note-1")) + expect(merged?.title).toBe("Hello") + expect(merged?.body).toBe("New body") + expect(merged?.id).toBe("note-1") + }) + + it("accumulates updates across multiple calls", () => { + const {store, draftState} = makeDraftState({"note-1": serverNote}) + + store.set(draftState.updateAtom, "note-1", {title: "First change"}) + store.set(draftState.updateAtom, "note-1", {body: "Second change"}) + + const merged = store.get(draftState.withDraftAtomFamily("note-1")) + expect(merged?.title).toBe("First change") + expect(merged?.body).toBe("Second change") + }) + }) +}) diff --git a/web/packages/agenta-entities/tests/unit/environment-molecule.test.ts b/web/packages/agenta-entities/tests/unit/environment-molecule.test.ts new file mode 100644 index 0000000000..34fcc22ad8 --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/environment-molecule.test.ts @@ -0,0 +1,219 @@ +/** + * Unit tests for environmentMolecule. + * + * The environment molecule follows the same createMolecule + extendMolecule pattern + * as the testset molecule. All write operations make API calls (archive, commit, + * deploy) so those are out of scope here. We test the pure Jotai layer: + * + * • Molecule shape — name, atoms, actions, invalidate, revisionsList + * • isNewEntity — always false (no local creation flow) + * • Draft operations — update/discard reducers change draft and isDirty + * • Null-safe selectors — queryOptional / dataOptional with null/undefined + * • revisionsList shape — atoms / reducers / get exposed correctly + * • invalidate shape — list / detail / revisions exposed as functions + */ + +import {describe, it, expect} from "vitest" +import {createStore} from "jotai" + +import {environmentMolecule} from "../../src/environment/state/environmentMolecule" + +// ── helpers ─────────────────────────────────────────────────────────────────── + +function freshStore() { + return createStore() +} + +// ── Molecule shape ──────────────────────────────────────────────────────────── + +describe("environmentMolecule shape", () => { + it("exposes 'environment' as the molecule name", () => { + expect(environmentMolecule.name).toBe("environment") + }) + + it("exposes atoms namespace", () => { + expect(typeof environmentMolecule.atoms.data).toBe("function") + expect(typeof environmentMolecule.atoms.isDirty).toBe("function") + expect(typeof environmentMolecule.atoms.draft).toBe("function") + expect(typeof environmentMolecule.atoms.serverData).toBe("function") + expect(typeof environmentMolecule.atoms.query).toBe("function") + }) + + it("exposes extended atoms for deployments", () => { + expect(typeof environmentMolecule.atoms.revisionDeployment).toBe("function") + expect(typeof environmentMolecule.atoms.bySlug).toBe("function") + expect(typeof environmentMolecule.atoms.appDeployments).toBe("function") + expect(typeof environmentMolecule.atoms.appDeploymentsBySlug).toBe("function") + expect(typeof environmentMolecule.atoms.appDeploymentInEnvironment).toBe("function") + }) + + it("exposes top-level data / query / isDirty aliases", () => { + expect(typeof environmentMolecule.data).toBe("function") + expect(environmentMolecule.query).toBeDefined() + expect(typeof environmentMolecule.isDirty).toBe("function") + }) + + it("exposes null-safe queryOptional and dataOptional", () => { + expect(typeof environmentMolecule.queryOptional).toBe("function") + expect(typeof environmentMolecule.dataOptional).toBe("function") + }) + + it("exposes actions namespace", () => { + expect(environmentMolecule.actions.update).toBeDefined() + expect(environmentMolecule.actions.discard).toBeDefined() + expect(environmentMolecule.actions.archive).toBeDefined() + expect(environmentMolecule.actions.toggleGuard).toBeDefined() + expect(environmentMolecule.actions.commit).toBeDefined() + expect(environmentMolecule.actions.deploy).toBeDefined() + expect(environmentMolecule.actions.undeploy).toBeDefined() + expect(environmentMolecule.actions.revert).toBeDefined() + expect(environmentMolecule.actions.revertToSnapshot).toBeDefined() + }) + + it("exposes invalidate namespace", () => { + expect(typeof environmentMolecule.invalidate.list).toBe("function") + expect(typeof environmentMolecule.invalidate.detail).toBe("function") + expect(typeof environmentMolecule.invalidate.revisions).toBe("function") + }) + + it("exposes revisionsList namespace", () => { + expect(environmentMolecule.revisionsList).toBeDefined() + expect(environmentMolecule.revisionsList.atoms).toBeDefined() + expect(environmentMolecule.revisionsList.reducers).toBeDefined() + expect(typeof environmentMolecule.revisionsList.get).toBe("function") + }) + + it("exposes imperative get namespace", () => { + expect(typeof environmentMolecule.get.data).toBe("function") + expect(typeof environmentMolecule.get.isDirty).toBe("function") + }) + + it("exposes imperative set namespace", () => { + expect(typeof environmentMolecule.set.update).toBe("function") + expect(typeof environmentMolecule.set.discard).toBe("function") + }) +}) + +// ── isNewEntity ─────────────────────────────────────────────────────────────── + +describe("environmentMolecule isNewEntity", () => { + it("isNew is false for any ID (environments have no local creation flow)", () => { + const store = freshStore() + expect(store.get(environmentMolecule.atoms.isNew("env-1"))).toBe(false) + expect(store.get(environmentMolecule.atoms.isNew("new-env"))).toBe(false) + expect(store.get(environmentMolecule.atoms.isNew("local-env"))).toBe(false) + }) +}) + +// ── Draft operations ────────────────────────────────────────────────────────── + +describe("environmentMolecule draft operations", () => { + it("isDirty is false before any update", () => { + const store = freshStore() + expect(store.get(environmentMolecule.atoms.isDirty("env-1"))).toBe(false) + }) + + it("isDirty is true after calling actions.update", () => { + const store = freshStore() + store.set(environmentMolecule.actions.update, "env-1", {name: "Production"}) + expect(store.get(environmentMolecule.atoms.isDirty("env-1"))).toBe(true) + }) + + it("draft atom reflects staged changes", () => { + const store = freshStore() + store.set(environmentMolecule.actions.update, "env-1", {name: "Staging"}) + expect(store.get(environmentMolecule.atoms.draft("env-1"))).toMatchObject({ + name: "Staging", + }) + }) + + it("actions.update accumulates across multiple calls", () => { + const store = freshStore() + store.set(environmentMolecule.actions.update, "env-1", {name: "First"}) + store.set(environmentMolecule.actions.update, "env-1", {description: "Second"}) + const draft = store.get(environmentMolecule.atoms.draft("env-1")) + expect(draft).toMatchObject({name: "First", description: "Second"}) + }) + + it("actions.discard clears draft and isDirty returns false", () => { + const store = freshStore() + store.set(environmentMolecule.actions.update, "env-1", {name: "Pending"}) + store.set(environmentMolecule.actions.discard, "env-1") + expect(store.get(environmentMolecule.atoms.isDirty("env-1"))).toBe(false) + expect(store.get(environmentMolecule.atoms.draft("env-1"))).toBeNull() + }) + + it("draft for one env ID does not affect another", () => { + const store = freshStore() + store.set(environmentMolecule.actions.update, "env-A", {name: "A"}) + expect(store.get(environmentMolecule.atoms.isDirty("env-B"))).toBe(false) + }) + + it("imperative set.update changes draft", () => { + const store = freshStore() + environmentMolecule.set.update("env-1", {name: "Imperative"}, {store}) + expect(store.get(environmentMolecule.atoms.isDirty("env-1"))).toBe(true) + }) + + it("imperative set.discard reverts draft", () => { + const store = freshStore() + environmentMolecule.set.update("env-1", {name: "Changed"}, {store}) + environmentMolecule.set.discard("env-1", {store}) + expect(store.get(environmentMolecule.atoms.isDirty("env-1"))).toBe(false) + }) +}) + +// ── Null-safe selectors ─────────────────────────────────────────────────────── + +describe("environmentMolecule null-safe selectors", () => { + it("queryOptional(null) returns atom with isPending=false, data=null", () => { + const store = freshStore() + const result = store.get(environmentMolecule.queryOptional(null)) + expect(result.isPending).toBe(false) + expect(result.data).toBeNull() + }) + + it("queryOptional(undefined) returns atom with isPending=false, data=null", () => { + const store = freshStore() + const result = store.get(environmentMolecule.queryOptional(undefined)) + expect(result.isPending).toBe(false) + expect(result.data).toBeNull() + }) + + it("dataOptional(null) returns null", () => { + const store = freshStore() + expect(store.get(environmentMolecule.dataOptional(null))).toBeNull() + }) + + it("dataOptional(undefined) returns null", () => { + const store = freshStore() + expect(store.get(environmentMolecule.dataOptional(undefined))).toBeNull() + }) + + it("queryOptional with a real ID returns a truthy atom (delegates to query family)", () => { + const atom = environmentMolecule.queryOptional("real-env-id") + expect(atom).toBeDefined() + expect(typeof atom).toBe("object") + }) +}) + +// ── Lifecycle ───────────────────────────────────────────────────────────────── + +describe("environmentMolecule lifecycle", () => { + it("lifecycle.isActive is false before any access", () => { + expect(environmentMolecule.lifecycle.isActive("env-lifecycle-1")).toBe(false) + }) + + it("lifecycle.isActive is true after atoms.serverData is first accessed", () => { + const store = freshStore() + store.get(environmentMolecule.atoms.serverData("env-lifecycle-2")) + expect(environmentMolecule.lifecycle.isActive("env-lifecycle-2")).toBe(true) + }) + + it("lifecycle.isActive is false after cleanup.remove", () => { + const store = freshStore() + store.get(environmentMolecule.atoms.serverData("env-lifecycle-3")) + environmentMolecule.cleanup.remove("env-lifecycle-3") + expect(environmentMolecule.lifecycle.isActive("env-lifecycle-3")).toBe(false) + }) +}) diff --git a/web/packages/agenta-entities/tests/unit/loadable-store.test.ts b/web/packages/agenta-entities/tests/unit/loadable-store.test.ts new file mode 100644 index 0000000000..45ef9fc561 --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/loadable-store.test.ts @@ -0,0 +1,167 @@ +/** + * Unit tests for the loadable store atoms + * + * The loadable store is pure Jotai state — no API calls, no entity deps. + * It tracks whether a data source is "local" (manual rows) or "connected" + * (synced to a testset or trace), plus the columns, execution results, + * and output mappings that belong to that loadable instance. + * + * Each test creates a fresh Jotai store so atoms don't bleed between tests. + */ + +import {describe, it, expect} from "vitest" +import {createStore} from "jotai" + +import { + loadableStateAtomFamily, + loadableModeAtomFamily, + loadableColumnsAtomFamily, + loadableExecutionResultsAtomFamily, + loadableConnectedSourceAtomFamily, + loadableLinkedRunnableAtomFamily, + loadableOutputMappingsAtomFamily, +} from "../../src/loadable/store" + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function freshStore() { + return createStore() +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("loadable store", () => { + // ── Default / initial state ─────────────────────────────────────────────── + + describe("default state", () => { + it("mode is 'local' when no source is connected", () => { + const store = freshStore() + expect(store.get(loadableModeAtomFamily("lb-1"))).toBe("local") + }) + + it("columns default to an empty array", () => { + const store = freshStore() + expect(store.get(loadableColumnsAtomFamily("lb-1"))).toEqual([]) + }) + + it("execution results default to an empty object", () => { + const store = freshStore() + expect(store.get(loadableExecutionResultsAtomFamily("lb-1"))).toEqual({}) + }) + + it("connected source defaults to all-null", () => { + const store = freshStore() + expect(store.get(loadableConnectedSourceAtomFamily("lb-1"))).toEqual({ + id: null, + name: null, + type: null, + }) + }) + + it("linked runnable defaults to all-null", () => { + const store = freshStore() + expect(store.get(loadableLinkedRunnableAtomFamily("lb-1"))).toEqual({ + type: null, + id: null, + }) + }) + + it("output mappings default to an empty array", () => { + const store = freshStore() + expect(store.get(loadableOutputMappingsAtomFamily("lb-1"))).toEqual([]) + }) + }) + + // ── Mode switching via state ────────────────────────────────────────────── + + describe("mode", () => { + it("switches to 'connected' when connectedSourceId is set", () => { + const store = freshStore() + const stateAtom = loadableStateAtomFamily("lb-2") + + store.set(stateAtom, (prev) => ({...prev, connectedSourceId: "rev-abc"})) + + expect(store.get(loadableModeAtomFamily("lb-2"))).toBe("connected") + }) + + it("returns to 'local' when connectedSourceId is cleared", () => { + const store = freshStore() + const stateAtom = loadableStateAtomFamily("lb-2") + + store.set(stateAtom, (prev) => ({...prev, connectedSourceId: "rev-abc"})) + store.set(stateAtom, (prev) => ({...prev, connectedSourceId: null})) + + expect(store.get(loadableModeAtomFamily("lb-2"))).toBe("local") + }) + }) + + // ── Connected source ────────────────────────────────────────────────────── + + describe("connected source", () => { + it("reflects updated connected source information", () => { + const store = freshStore() + const stateAtom = loadableStateAtomFamily("lb-3") + + store.set(stateAtom, (prev) => ({ + ...prev, + connectedSourceId: "rev-xyz", + connectedSourceName: "My Testset v2", + connectedSourceType: "testcase" as const, + })) + + expect(store.get(loadableConnectedSourceAtomFamily("lb-3"))).toEqual({ + id: "rev-xyz", + name: "My Testset v2", + type: "testcase", + }) + }) + }) + + // ── Isolation between instances ─────────────────────────────────────────── + + describe("instance isolation", () => { + it("two loadable IDs do not share state", () => { + const store = freshStore() + const stateAtomA = loadableStateAtomFamily("lb-a") + + store.set(stateAtomA, (prev) => ({...prev, connectedSourceId: "rev-only-in-a"})) + + expect(store.get(loadableModeAtomFamily("lb-a"))).toBe("connected") + expect(store.get(loadableModeAtomFamily("lb-b"))).toBe("local") + }) + }) + + // ── Output mappings ─────────────────────────────────────────────────────── + + describe("output mappings", () => { + it("reflects output mappings written to state", () => { + const store = freshStore() + const stateAtom = loadableStateAtomFamily("lb-4") + const mapping = {id: "m-1", outputPath: "data.output", targetColumn: "result"} + + store.set(stateAtom, (prev) => ({...prev, outputMappings: [mapping]})) + + expect(store.get(loadableOutputMappingsAtomFamily("lb-4"))).toEqual([mapping]) + }) + }) + + // ── Linked runnable ─────────────────────────────────────────────────────── + + describe("linked runnable", () => { + it("reflects the linked runnable when set", () => { + const store = freshStore() + const stateAtom = loadableStateAtomFamily("lb-5") + + store.set(stateAtom, (prev) => ({ + ...prev, + linkedRunnableType: "appRevision" as const, + linkedRunnableId: "run-001", + })) + + expect(store.get(loadableLinkedRunnableAtomFamily("lb-5"))).toEqual({ + type: "appRevision", + id: "run-001", + }) + }) + }) +}) diff --git a/web/packages/agenta-entities/tests/unit/local-molecule.test.ts b/web/packages/agenta-entities/tests/unit/local-molecule.test.ts new file mode 100644 index 0000000000..11897624da --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/local-molecule.test.ts @@ -0,0 +1,262 @@ +/** + * Unit tests for createLocalMolecule + * + * A local molecule manages client-only entities — things that exist purely in + * browser memory and have never been saved to the server. It is used for + * draft flows, wizard steps, and temporary entities in multi-step UIs. + * + * Because there is no server query involved, everything is synchronous and + * the full CRUD surface can be exercised using a custom Jotai store. + * + * Tests use a simple "Tag" type to keep fixtures readable. + */ + +import {describe, it, expect, vi} from "vitest" +import {createStore} from "jotai" + +import {createLocalMolecule} from "../../src/shared/molecule/createLocalMolecule" + +// ── Fixture type ────────────────────────────────────────────────────────────── + +type Tag = { + label: string + color: string +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeTagMolecule(overrides?: Partial>[0]>) { + return createLocalMolecule({name: "tag", ...overrides}) +} + +function freshStore() { + return createStore() +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("createLocalMolecule", () => { + // ── Create ──────────────────────────────────────────────────────────────── + + describe("create", () => { + it("returns a local-prefixed ID", () => { + const mol = makeTagMolecule() + const store = freshStore() + const id = mol.set.create({label: "urgent", color: "red"}, {store}) + expect(id.startsWith("local-")).toBe(true) + }) + + it("stores the created entity in the data atom", () => { + const mol = makeTagMolecule() + const store = freshStore() + const id = mol.set.create({label: "urgent", color: "red"}, {store}) + expect(store.get(mol.atoms.data(id))).toEqual({label: "urgent", color: "red"}) + }) + + it("merges provided data with createDefault values", () => { + const mol = makeTagMolecule({ + createDefault: () => ({label: "default", color: "gray"}), + }) + const store = freshStore() + const id = mol.set.create({color: "blue"}, {store}) + // label comes from default, color is overridden + expect(store.get(mol.atoms.data(id))).toEqual({label: "default", color: "blue"}) + }) + + it("tracks the new ID in allIds", () => { + const mol = makeTagMolecule() + const store = freshStore() + const id = mol.set.create({label: "x", color: "y"}, {store}) + expect(store.get(mol.atoms.allIds)).toContain(id) + }) + + it("applies the transform function after creating", () => { + const mol = makeTagMolecule({ + transform: (tag) => ({...tag, label: tag.label.toUpperCase()}), + }) + const store = freshStore() + const id = mol.set.create({label: "urgent", color: "red"}, {store}) + expect(store.get(mol.atoms.data(id))?.label).toBe("URGENT") + }) + + it("calls validate and passes when data is valid", () => { + const validate = vi.fn((tag: Tag) => tag) + const mol = makeTagMolecule({validate}) + const store = freshStore() + mol.set.create({label: "ok", color: "green"}, {store}) + expect(validate).toHaveBeenCalledOnce() + }) + }) + + // ── createWithId ────────────────────────────────────────────────────────── + + describe("createWithId", () => { + it("stores the entity under the given ID", () => { + const mol = makeTagMolecule() + const store = freshStore() + mol.set.createWithId("fixed-id", {label: "pinned", color: "gold"}, {store}) + expect(store.get(mol.atoms.data("fixed-id"))).toEqual({ + label: "pinned", + color: "gold", + }) + }) + }) + + // ── Update ──────────────────────────────────────────────────────────────── + + describe("update", () => { + it("merges partial changes into existing entity", () => { + const mol = makeTagMolecule() + const store = freshStore() + const id = mol.set.create({label: "old", color: "blue"}, {store}) + mol.set.update(id, {label: "new"}, {store}) + expect(store.get(mol.atoms.data(id))).toEqual({label: "new", color: "blue"}) + }) + + it("applies transform after updating", () => { + const mol = makeTagMolecule({ + transform: (tag) => ({...tag, label: tag.label.toUpperCase()}), + }) + const store = freshStore() + const id = mol.set.create({label: "a", color: "b"}, {store}) + mol.set.update(id, {label: "urgent"}, {store}) + expect(store.get(mol.atoms.data(id))?.label).toBe("URGENT") + }) + + it("is a no-op (with a warning) when the entity does not exist", () => { + const mol = makeTagMolecule() + const store = freshStore() + // Should not throw + mol.set.update("ghost", {label: "x"}, {store}) + expect(store.get(mol.atoms.data("ghost"))).toBeNull() + }) + }) + + // ── Delete ──────────────────────────────────────────────────────────────── + + describe("delete", () => { + it("removes the entity from the data atom", () => { + const mol = makeTagMolecule() + const store = freshStore() + const id = mol.set.create({label: "x", color: "y"}, {store}) + mol.set.delete(id, {store}) + expect(store.get(mol.atoms.data(id))).toBeNull() + }) + + it("removes the ID from allIds", () => { + const mol = makeTagMolecule() + const store = freshStore() + const id = mol.set.create({label: "x", color: "y"}, {store}) + mol.set.delete(id, {store}) + expect(store.get(mol.atoms.allIds)).not.toContain(id) + }) + }) + + // ── Clear ───────────────────────────────────────────────────────────────── + + describe("clear", () => { + it("removes all entities", () => { + const mol = makeTagMolecule() + const store = freshStore() + const id1 = mol.set.create({label: "a", color: "red"}, {store}) + const id2 = mol.set.create({label: "b", color: "blue"}, {store}) + mol.set.clear({store}) + expect(store.get(mol.atoms.data(id1))).toBeNull() + expect(store.get(mol.atoms.data(id2))).toBeNull() + expect(store.get(mol.atoms.allIds)).toHaveLength(0) + }) + }) + + // ── Derived atoms ───────────────────────────────────────────────────────── + + describe("derived atoms", () => { + it("isDirty is true when data exists", () => { + const mol = makeTagMolecule() + const store = freshStore() + const id = mol.set.create({label: "x", color: "y"}, {store}) + expect(store.get(mol.atoms.isDirty(id))).toBe(true) + }) + + it("isDirty is false after entity is deleted", () => { + const mol = makeTagMolecule() + const store = freshStore() + const id = mol.set.create({label: "x", color: "y"}, {store}) + mol.set.delete(id, {store}) + expect(store.get(mol.atoms.isDirty(id))).toBe(false) + }) + + it("isNew is always true", () => { + const mol = makeTagMolecule() + const store = freshStore() + const id = mol.set.create({label: "x", color: "y"}, {store}) + expect(store.get(mol.atoms.isNew(id))).toBe(true) + }) + + it("serverData is always null", () => { + const mol = makeTagMolecule() + const store = freshStore() + const id = mol.set.create({label: "x", color: "y"}, {store}) + expect(store.get(mol.atoms.serverData(id))).toBeNull() + }) + + it("query is always successful and non-pending", () => { + const mol = makeTagMolecule() + const store = freshStore() + const id = mol.set.create({label: "x", color: "y"}, {store}) + const query = store.get(mol.atoms.query(id)) + expect(query.isPending).toBe(false) + expect(query.isError).toBe(false) + expect(query.isSuccess).toBe(true) + expect(query.data).toEqual({label: "x", color: "y"}) + }) + + it("allIds is empty initially", () => { + const mol = makeTagMolecule() + const store = freshStore() + expect(store.get(mol.atoms.allIds)).toHaveLength(0) + }) + }) + + // ── Imperative getters ──────────────────────────────────────────────────── + + describe("imperative getters", () => { + it("get.data returns the entity", () => { + const mol = makeTagMolecule() + const store = freshStore() + const id = mol.set.create({label: "x", color: "y"}, {store}) + expect(mol.get.data(id, {store})).toEqual({label: "x", color: "y"}) + }) + + it("get.all returns all entities", () => { + const mol = makeTagMolecule() + const store = freshStore() + mol.set.create({label: "a", color: "red"}, {store}) + mol.set.create({label: "b", color: "blue"}, {store}) + expect(mol.get.all({store})).toHaveLength(2) + }) + + it("get.allIds returns all IDs", () => { + const mol = makeTagMolecule() + const store = freshStore() + const id1 = mol.set.create({label: "a", color: "red"}, {store}) + const id2 = mol.set.create({label: "b", color: "blue"}, {store}) + const ids = mol.get.allIds({store}) + expect(ids).toContain(id1) + expect(ids).toContain(id2) + }) + }) + + // ── molecule metadata ───────────────────────────────────────────────────── + + describe("molecule metadata", () => { + it("exposes the molecule name", () => { + const mol = makeTagMolecule() + expect(mol.name).toBe("tag") + }) + + it("exposes source as 'local'", () => { + const mol = makeTagMolecule() + expect(mol.source).toBe("local") + }) + }) +}) diff --git a/web/packages/agenta-entities/tests/unit/molecule.test.ts b/web/packages/agenta-entities/tests/unit/molecule.test.ts new file mode 100644 index 0000000000..18360eb505 --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/molecule.test.ts @@ -0,0 +1,357 @@ +/** + * Unit tests for createMolecule + * + * createMolecule is the factory behind every server-backed entity (testcase, + * testset, trace, environment, etc.). It wires together a TanStack Query atom + * (the server source of truth) with a draft atom family (local edits) and + * exposes a uniform API: atoms.*, reducers.*, get.*, set.*, lifecycle. + * + * The server query is replaced with a controllable mock so tests are fully + * synchronous — no network, no React, no timers. + * + * Uses a simple "Post" entity type to keep fixtures readable. + */ + +import {describe, it, expect, vi, beforeEach} from "vitest" +import {atom, createStore} from "jotai" +import {atomFamily} from "jotai-family" + +import {createMolecule} from "../../src/shared/molecule/createMolecule" +import type {QueryState} from "../../src/shared/molecule/types" + +// ── Fixture type ────────────────────────────────────────────────────────────── + +type Post = { + id: string + title: string + body: string +} + +type PostDraft = Partial + +// ── Setup helpers ───────────────────────────────────────────────────────────── + +/** + * Build a fresh molecule + store + server data controls for each test. + * + * The mock queryAtomFamily reads from writable serverAtomFamily atoms, so + * tests can seed server state with: + * store.set(serverAtomFamily("post-1"), serverPost) + */ +function makeSetup(overrides?: Partial>[0]>) { + // Writable server data atoms — test controls these directly + const serverAtomFamily = atomFamily((_id: string) => atom(null)) + + // Mock query atom family — reads from serverAtomFamily, always non-pending + const queryAtomFamily = atomFamily((id: string) => + atom>((get) => ({ + data: get(serverAtomFamily(id)), + isPending: false, + isError: false, + error: null, + })), + ) + + // Draft atom family — writable, starts as null (no local edits) + const draftAtomFamily = atomFamily((_id: string) => atom(null)) + + const mol = createMolecule({ + name: "post", + queryAtomFamily, + draftAtomFamily, + ...overrides, + }) + + const store = createStore() + + return {mol, store, serverAtomFamily, draftAtomFamily} +} + +const serverPost: Post = {id: "post-1", title: "Hello", body: "World"} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("createMolecule", () => { + // ── Server data and merge ───────────────────────────────────────────────── + + describe("server data", () => { + it("data atom returns server entity when no draft exists", () => { + const {mol, store, serverAtomFamily} = makeSetup() + store.set(serverAtomFamily("post-1"), serverPost) + expect(store.get(mol.atoms.data("post-1"))).toEqual(serverPost) + }) + + it("data atom returns null when entity is not in server state", () => { + const {mol, store} = makeSetup() + expect(store.get(mol.atoms.data("missing"))).toBeNull() + }) + + it("serverData atom returns the raw server entity", () => { + const {mol, store, serverAtomFamily} = makeSetup() + store.set(serverAtomFamily("post-1"), serverPost) + expect(store.get(mol.atoms.serverData("post-1"))).toEqual(serverPost) + }) + }) + + // ── Transform ───────────────────────────────────────────────────────────── + + describe("transform", () => { + it("applies transform to server data before exposing it", () => { + const {mol, store, serverAtomFamily} = makeSetup({ + transform: (post) => ({...post, title: post.title.toUpperCase()}), + }) + store.set(serverAtomFamily("post-1"), serverPost) + expect(store.get(mol.atoms.data("post-1"))?.title).toBe("HELLO") + }) + }) + + // ── Draft operations ────────────────────────────────────────────────────── + + describe("draft operations", () => { + let store: ReturnType + let mol: ReturnType["mol"] + let serverAtomFamily: ReturnType["serverAtomFamily"] + + beforeEach(() => { + ;({mol, store, serverAtomFamily} = makeSetup()) + store.set(serverAtomFamily("post-1"), serverPost) + }) + + it("update reducer merges changes into draft", () => { + store.set(mol.reducers.update, "post-1", {title: "Updated"}) + expect(store.get(mol.atoms.data("post-1"))?.title).toBe("Updated") + expect(store.get(mol.atoms.data("post-1"))?.body).toBe("World") + }) + + it("update accumulates across multiple calls", () => { + store.set(mol.reducers.update, "post-1", {title: "New title"}) + store.set(mol.reducers.update, "post-1", {body: "New body"}) + const data = store.get(mol.atoms.data("post-1")) + expect(data?.title).toBe("New title") + expect(data?.body).toBe("New body") + }) + + it("isDirty is false before any update", () => { + expect(store.get(mol.atoms.isDirty("post-1"))).toBe(false) + }) + + it("isDirty is true after an update", () => { + store.set(mol.reducers.update, "post-1", {title: "Changed"}) + expect(store.get(mol.atoms.isDirty("post-1"))).toBe(true) + }) + + it("discard reducer clears draft and restores server state", () => { + store.set(mol.reducers.update, "post-1", {title: "Pending"}) + store.set(mol.reducers.discard, "post-1") + expect(store.get(mol.atoms.data("post-1"))).toEqual(serverPost) + expect(store.get(mol.atoms.isDirty("post-1"))).toBe(false) + }) + + it("draft atom is null before any update", () => { + expect(store.get(mol.atoms.draft("post-1"))).toBeNull() + }) + + it("draft atom holds the pending changes after update", () => { + store.set(mol.reducers.update, "post-1", {title: "Staged"}) + expect(store.get(mol.atoms.draft("post-1"))).toMatchObject({title: "Staged"}) + }) + }) + + // ── Custom merge ────────────────────────────────────────────────────────── + + describe("custom merge", () => { + it("uses the provided merge function to combine server + draft", () => { + const {mol, store, serverAtomFamily} = makeSetup({ + merge: (server, draft) => { + if (!server) return null + if (!draft) return server + return {...server, title: `${server.title} [${draft.title}]`} + }, + }) + store.set(serverAtomFamily("post-1"), serverPost) + store.set(mol.reducers.update, "post-1", {title: "Draft"}) + expect(store.get(mol.atoms.data("post-1"))?.title).toBe("Hello [Draft]") + }) + }) + + // ── Soft delete ─────────────────────────────────────────────────────────── + + describe("soft delete", () => { + it("delete reducer marks entity as deleted", () => { + const {mol, store} = makeSetup() + store.set(mol.reducers.delete, "post-1") + expect(store.get(mol.atoms.isDeleted("post-1"))).toBe(true) + }) + + it("deletedIds atom contains the deleted ID", () => { + const {mol, store} = makeSetup() + store.set(mol.reducers.delete, "post-1") + expect(store.get(mol.atoms.deletedIds).has("post-1")).toBe(true) + }) + + it("restore reducer removes entity from deleted set", () => { + const {mol, store} = makeSetup() + store.set(mol.reducers.delete, "post-1") + store.set(mol.reducers.restore, "post-1") + expect(store.get(mol.atoms.isDeleted("post-1"))).toBe(false) + }) + + it("isDeleted is false initially", () => { + const {mol, store} = makeSetup() + expect(store.get(mol.atoms.isDeleted("post-1"))).toBe(false) + }) + }) + + // ── Local entity creation ───────────────────────────────────────────────── + + describe("local entity creation", () => { + it("create reducer adds generated ID to newIds", () => { + const {mol, store} = makeSetup() + store.set(mol.reducers.create, {title: "New post", body: "..."}) + expect(store.get(mol.atoms.newIds)).toHaveLength(1) + }) + + it("generated ID starts with 'new-'", () => { + const {mol, store} = makeSetup() + store.set(mol.reducers.create) + const [id] = store.get(mol.atoms.newIds) + expect(id.startsWith("new-")).toBe(true) + }) + + it("multiple creates each produce a unique ID", () => { + const {mol, store} = makeSetup() + store.set(mol.reducers.create) + store.set(mol.reducers.create) + const ids = store.get(mol.atoms.newIds) + expect(new Set(ids).size).toBe(2) + }) + }) + + // ── isNew detection ─────────────────────────────────────────────────────── + + describe("isNew detection", () => { + it("IDs starting with 'new-' are considered new", () => { + const {mol, store} = makeSetup() + expect(store.get(mol.atoms.isNew("new-123"))).toBe(true) + }) + + it("IDs starting with 'local-' are considered new", () => { + const {mol, store} = makeSetup() + expect(store.get(mol.atoms.isNew("local-abc"))).toBe(true) + }) + + it("regular IDs are not new", () => { + const {mol, store} = makeSetup() + expect(store.get(mol.atoms.isNew("post-1"))).toBe(false) + }) + + it("custom isNewEntity function overrides default", () => { + const {mol, store} = makeSetup({ + isNewEntity: (id) => id.startsWith("draft-"), + }) + expect(store.get(mol.atoms.isNew("draft-abc"))).toBe(true) + expect(store.get(mol.atoms.isNew("new-abc"))).toBe(false) + }) + }) + + // ── Imperative API ──────────────────────────────────────────────────────── + + describe("imperative API", () => { + it("get.data returns entity data", () => { + const {mol, store, serverAtomFamily} = makeSetup() + store.set(serverAtomFamily("post-1"), serverPost) + expect(mol.get.data("post-1", {store})).toEqual(serverPost) + }) + + it("get.isDirty returns false before any update", () => { + const {mol, store, serverAtomFamily} = makeSetup() + store.set(serverAtomFamily("post-1"), serverPost) + expect(mol.get.isDirty("post-1", {store})).toBe(false) + }) + + it("set.update changes data via imperative API", () => { + const {mol, store, serverAtomFamily} = makeSetup() + store.set(serverAtomFamily("post-1"), serverPost) + mol.set.update("post-1", {title: "Imperative"}, {store}) + expect(mol.get.data("post-1", {store})?.title).toBe("Imperative") + expect(mol.get.isDirty("post-1", {store})).toBe(true) + }) + + it("set.discard reverts to server state", () => { + const {mol, store, serverAtomFamily} = makeSetup() + store.set(serverAtomFamily("post-1"), serverPost) + mol.set.update("post-1", {title: "Changed"}, {store}) + mol.set.discard("post-1", {store}) + expect(mol.get.data("post-1", {store})).toEqual(serverPost) + }) + + it("set.create returns the generated ID", () => { + const {mol, store} = makeSetup() + const id = mol.set.create({title: "New"}, {store}) + expect(id.startsWith("new-")).toBe(true) + }) + + it("set.delete marks entity as deleted", () => { + const {mol, store} = makeSetup() + mol.set.delete("post-1", {store}) + expect(store.get(mol.atoms.isDeleted("post-1"))).toBe(true) + }) + + it("set.restore removes entity from deleted set", () => { + const {mol, store} = makeSetup() + mol.set.delete("post-1", {store}) + mol.set.restore("post-1", {store}) + expect(store.get(mol.atoms.isDeleted("post-1"))).toBe(false) + }) + }) + + // ── Lifecycle ───────────────────────────────────────────────────────────── + + describe("lifecycle", () => { + it("fires onMount callback when serverData atom is first accessed", () => { + const onMount = vi.fn() + const {mol, store} = makeSetup({lifecycle: {onMount}}) + // Accessing the serverData atom triggers mount + store.get(mol.atoms.serverData("post-lifecycle")) + expect(onMount).toHaveBeenCalledWith("post-lifecycle") + }) + + it("fires onMount only once per ID, not on repeated access", () => { + const onMount = vi.fn() + const {mol, store} = makeSetup({lifecycle: {onMount}}) + store.get(mol.atoms.serverData("post-lifecycle")) + store.get(mol.atoms.serverData("post-lifecycle")) + expect(onMount).toHaveBeenCalledOnce() + }) + + it("fires onUnmount callback when cleanup.remove is called", () => { + const onUnmount = vi.fn() + const {mol, store} = makeSetup({lifecycle: {onUnmount}}) + store.get(mol.atoms.serverData("post-2")) + mol.cleanup.remove("post-2") + expect(onUnmount).toHaveBeenCalledWith("post-2") + }) + + it("lifecycle.isActive returns true after first access", () => { + const {mol, store} = makeSetup() + store.get(mol.atoms.serverData("post-3")) + expect(mol.lifecycle.isActive("post-3")).toBe(true) + }) + + it("lifecycle.isActive returns false after cleanup.remove", () => { + const {mol, store} = makeSetup() + store.get(mol.atoms.serverData("post-4")) + mol.cleanup.remove("post-4") + expect(mol.lifecycle.isActive("post-4")).toBe(false) + }) + }) + + // ── molecule metadata ───────────────────────────────────────────────────── + + describe("molecule metadata", () => { + it("exposes the molecule name", () => { + const {mol} = makeSetup() + expect(mol.name).toBe("post") + }) + }) +}) diff --git a/web/packages/agenta-entities/tests/unit/normalize.test.ts b/web/packages/agenta-entities/tests/unit/normalize.test.ts new file mode 100644 index 0000000000..de0c6f3115 --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/normalize.test.ts @@ -0,0 +1,119 @@ +/** + * Unit tests for normalizeValueForComparison + * + * This utility is used by createEntityDraftState to determine whether a local + * edit differs from the server value. Getting this right is critical — a bug + * here causes the UI to either show spurious "unsaved" badges or silently drop + * real changes. + */ + +import {describe, it, expect} from "vitest" + +import {normalizeValueForComparison} from "../../src/shared/molecule/createEntityDraftState" + +describe("normalizeValueForComparison", () => { + // ── Empty / nullish ─────────────────────────────────────────────────────── + + describe("empty and nullish values", () => { + it("returns '' for null", () => { + expect(normalizeValueForComparison(null)).toBe("") + }) + + it("returns '' for undefined", () => { + expect(normalizeValueForComparison(undefined)).toBe("") + }) + + it("returns '' for empty string", () => { + expect(normalizeValueForComparison("")).toBe("") + }) + }) + + // ── Plain strings ───────────────────────────────────────────────────────── + + describe("plain strings", () => { + it("returns the string unchanged when it is not valid JSON", () => { + expect(normalizeValueForComparison("hello world")).toBe("hello world") + }) + + it("returns the string unchanged when it contains special characters", () => { + expect(normalizeValueForComparison("hello\nworld")).toBe("hello\nworld") + }) + }) + + // ── JSON strings ────────────────────────────────────────────────────────── + + describe("JSON strings (key-order normalisation)", () => { + it("sorts keys so {b,a} and {a,b} are equal", () => { + const bFirst = JSON.stringify({b: 2, a: 1}) + const aFirst = JSON.stringify({a: 1, b: 2}) + expect(normalizeValueForComparison(bFirst)).toBe(normalizeValueForComparison(aFirst)) + }) + + it("returns canonical JSON with keys sorted alphabetically", () => { + const input = JSON.stringify({z: 3, a: 1, m: 2}) + expect(normalizeValueForComparison(input)).toBe('{"a":1,"m":2,"z":3}') + }) + + it("handles nested objects — sorts keys at every level", () => { + const input = JSON.stringify({outer: {b: 2, a: 1}}) + expect(normalizeValueForComparison(input)).toBe('{"outer":{"a":1,"b":2}}') + }) + + it("handles arrays of objects inside JSON strings", () => { + const input = JSON.stringify([ + {b: 2, a: 1}, + {d: 4, c: 3}, + ]) + expect(normalizeValueForComparison(input)).toBe('[{"a":1,"b":2},{"c":3,"d":4}]') + }) + + it("treats a JSON string and the equivalent object as equal", () => { + const asString = JSON.stringify({b: 2, a: 1}) + const asObject = {a: 1, b: 2} + expect(normalizeValueForComparison(asString)).toBe( + normalizeValueForComparison(asObject), + ) + }) + }) + + // ── Objects ─────────────────────────────────────────────────────────────── + + describe("objects", () => { + it("serializes a plain object with sorted keys", () => { + expect(normalizeValueForComparison({z: 1, a: 2})).toBe('{"a":2,"z":1}') + }) + + it("handles empty object", () => { + expect(normalizeValueForComparison({})).toBe("{}") + }) + + it("handles nested objects", () => { + const obj = {outer: {b: 2, a: 1}, x: 0} + expect(normalizeValueForComparison(obj)).toBe('{"outer":{"a":1,"b":2},"x":0}') + }) + + it("handles arrays", () => { + expect(normalizeValueForComparison([1, 2, 3])).toBe("[1,2,3]") + }) + }) + + // ── Primitives ──────────────────────────────────────────────────────────── + + describe("primitives", () => { + it("converts numbers to string", () => { + expect(normalizeValueForComparison(42)).toBe("42") + }) + + it("converts true to string", () => { + expect(normalizeValueForComparison(true)).toBe("true") + }) + + it("converts false to string", () => { + expect(normalizeValueForComparison(false)).toBe("false") + }) + + it("converts 0 to string", () => { + expect(normalizeValueForComparison(0)).toBe("0") + }) + }) +}) diff --git a/web/packages/agenta-entities/tests/unit/port-helpers.test.ts b/web/packages/agenta-entities/tests/unit/port-helpers.test.ts new file mode 100644 index 0000000000..a08dda6802 --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/port-helpers.test.ts @@ -0,0 +1,323 @@ +/** + * Unit tests for port extraction helpers + * + * These are pure functions — no Jotai, no API. They transform JSON schemas + * and template placeholder strings into RunnablePort arrays and grouped + * variable lists. Correctness here directly affects which input fields the + * playground renders for a given workflow. + */ + +import {describe, it, expect} from "vitest" + +import { + resolveSchemaRef, + resolveSchemaType, + extractLastPathSegment, + formatKeyAsName, + groupTemplateVariables, + extractInputPortsFromSchema, + extractOutputPortsFromSchema, + extractSystemFieldNames, +} from "../../src/runnable/portHelpers" + +// ── resolveSchemaRef ────────────────────────────────────────────────────────── + +describe("resolveSchemaRef", () => { + it("returns the node unchanged when there is no $ref", () => { + const node = {type: "string", title: "Name"} + expect(resolveSchemaRef(node)).toEqual(node) + }) + + it("resolves a $defs reference", () => { + const defs = {MyType: {type: "integer", title: "Count"}} + const node = {$ref: "#/$defs/MyType"} + expect(resolveSchemaRef(node, defs)).toEqual({type: "integer", title: "Count"}) + }) + + it("resolves a #/definitions reference", () => { + const defs = {Score: {type: "number"}} + const node = {$ref: "#/definitions/Score"} + expect(resolveSchemaRef(node, defs)).toEqual({type: "number"}) + }) + + it("returns the node as-is when the ref target is missing", () => { + const node = {$ref: "#/$defs/Missing"} + expect(resolveSchemaRef(node, {})).toEqual(node) + }) + + it("returns empty object for non-object input", () => { + expect(resolveSchemaRef(null)).toEqual({}) + expect(resolveSchemaRef("string")).toEqual({}) + }) +}) + +// ── resolveSchemaType ───────────────────────────────────────────────────────── + +describe("resolveSchemaType", () => { + it("returns the type from a plain schema node", () => { + expect(resolveSchemaType({type: "integer"})).toBe("integer") + }) + + it("resolves the type through a $ref", () => { + const defs = {Score: {type: "number"}} + expect(resolveSchemaType({$ref: "#/$defs/Score"}, defs)).toBe("number") + }) + + it("defaults to 'string' when type is not present", () => { + expect(resolveSchemaType({})).toBe("string") + expect(resolveSchemaType(null)).toBe("string") + }) +}) + +// ── extractLastPathSegment ──────────────────────────────────────────────────── + +describe("extractLastPathSegment", () => { + it("returns a plain name unchanged", () => { + expect(extractLastPathSegment("country")).toBe("country") + }) + + it("extracts the last segment from a JSONPath expression", () => { + expect(extractLastPathSegment("$.inputs.country")).toBe("country") + expect(extractLastPathSegment("$.outputs.score")).toBe("score") + }) + + it("handles JSONPath with array brackets", () => { + expect(extractLastPathSegment("$.inputs['key']")).toBe("key") + }) + + it("extracts the last segment from a JSON Pointer", () => { + expect(extractLastPathSegment("/inputs/country")).toBe("country") + }) + + it("extracts the last segment from dot notation", () => { + expect(extractLastPathSegment("inputs.country")).toBe("country") + expect(extractLastPathSegment("a.b.c")).toBe("c") + }) + + it("returns the key unchanged when there is no path syntax", () => { + expect(extractLastPathSegment("myField")).toBe("myField") + }) + + it("returns the input unchanged for an empty string", () => { + expect(extractLastPathSegment("")).toBe("") + }) +}) + +// ── formatKeyAsName ─────────────────────────────────────────────────────────── + +describe("formatKeyAsName", () => { + it("converts snake_case to Title Case", () => { + expect(formatKeyAsName("user_name")).toBe("User name") + }) + + it("splits camelCase on word boundaries", () => { + expect(formatKeyAsName("firstName")).toBe("First Name") + }) + + it("capitalises the first letter", () => { + expect(formatKeyAsName("country")).toBe("Country") + }) + + it("strips JSONPath prefix before formatting", () => { + expect(formatKeyAsName("$.inputs.user_name")).toBe("User name") + }) + + it("strips JSON Pointer prefix before formatting", () => { + expect(formatKeyAsName("/inputs/score")).toBe("Score") + }) +}) + +// ── groupTemplateVariables ──────────────────────────────────────────────────── + +describe("groupTemplateVariables", () => { + it("returns an empty array for empty input", () => { + expect(groupTemplateVariables([])).toEqual([]) + }) + + it("groups a plain variable into the inputs envelope", () => { + const result = groupTemplateVariables(["country"]) + expect(result).toHaveLength(1) + expect(result[0]).toMatchObject({envelope: "inputs", key: "country", type: "string"}) + }) + + it("groups a JSONPath variable into the correct envelope", () => { + const result = groupTemplateVariables(["$.inputs.city"]) + expect(result[0]).toMatchObject({envelope: "inputs", key: "city", type: "string"}) + }) + + it("groups an output-envelope variable separately", () => { + const result = groupTemplateVariables(["$.outputs.score"]) + expect(result[0]).toMatchObject({envelope: "outputs", key: "score"}) + }) + + it("collapses sub-path references into an object-typed group", () => { + const result = groupTemplateVariables(["$.inputs.address.city", "$.inputs.address.country"]) + expect(result).toHaveLength(1) + expect(result[0]).toMatchObject({ + envelope: "inputs", + key: "address", + type: "object", + }) + expect(result[0].subPaths).toContain("city") + expect(result[0].subPaths).toContain("country") + }) + + it("mixes simple and object variables without merging them", () => { + const result = groupTemplateVariables(["name", "$.inputs.address.city"]) + const keys = result.map((r) => r.key) + expect(keys).toContain("name") + expect(keys).toContain("address") + }) + + it("deduplicates identical variables", () => { + const result = groupTemplateVariables(["country", "country"]) + expect(result).toHaveLength(1) + }) + + it("ignores invalid template variables", () => { + // '$.invalid.x' is not a known envelope slot, so it should be skipped + const result = groupTemplateVariables(["$.invalid.x"]) + expect(result).toHaveLength(0) + }) +}) + +// ── extractInputPortsFromSchema ─────────────────────────────────────────────── + +describe("extractInputPortsFromSchema", () => { + it("returns empty array for null or empty schema", () => { + expect(extractInputPortsFromSchema(null)).toEqual([]) + expect(extractInputPortsFromSchema({})).toEqual([]) + }) + + it("maps each schema property to a port", () => { + const schema = { + type: "object", + properties: { + country: {type: "string"}, + score: {type: "number"}, + }, + } + const ports = extractInputPortsFromSchema(schema) + expect(ports).toHaveLength(2) + expect(ports.map((p) => p.key)).toEqual(expect.arrayContaining(["country", "score"])) + }) + + it("marks required fields", () => { + const schema = { + type: "object", + properties: {country: {type: "string"}}, + required: ["country"], + } + const ports = extractInputPortsFromSchema(schema) + expect(ports[0].required).toBe(true) + }) + + it("uses the schema title as the port name when present", () => { + const schema = { + type: "object", + properties: { + q: {type: "string", title: "Question"}, + }, + } + const ports = extractInputPortsFromSchema(schema) + expect(ports[0].name).toBe("Question") + }) + + it("falls back to formatKeyAsName when title is absent", () => { + const schema = { + type: "object", + properties: {user_name: {type: "string"}}, + } + const ports = extractInputPortsFromSchema(schema) + expect(ports[0].name).toBe("User name") + }) + + it("filters out system fields annotated with x-ag-* markers", () => { + const schema = { + type: "object", + properties: { + country: {type: "string"}, + _context: {"x-ag-context": true, type: "string"}, + }, + } + const ports = extractInputPortsFromSchema(schema) + expect(ports).toHaveLength(1) + expect(ports[0].key).toBe("country") + }) + + it("resolves $ref properties", () => { + const schema = { + type: "object", + properties: { + score: {$ref: "#/$defs/Score"}, + }, + $defs: {Score: {type: "integer"}}, + } + const ports = extractInputPortsFromSchema(schema) + expect(ports[0].type).toBe("integer") + }) +}) + +// ── extractOutputPortsFromSchema ────────────────────────────────────────────── + +describe("extractOutputPortsFromSchema", () => { + it("returns empty array for null input", () => { + expect(extractOutputPortsFromSchema(null)).toEqual([]) + }) + + it("returns a single 'output' port for a simple type schema", () => { + const schema = {type: "string"} + const ports = extractOutputPortsFromSchema(schema) + expect(ports).toHaveLength(1) + expect(ports[0]).toMatchObject({key: "output", type: "string"}) + }) + + it("maps object schema properties to individual ports", () => { + const schema = { + type: "object", + properties: { + result: {type: "string"}, + confidence: {type: "number"}, + }, + } + const ports = extractOutputPortsFromSchema(schema) + expect(ports).toHaveLength(2) + expect(ports.map((p) => p.key)).toEqual(expect.arrayContaining(["result", "confidence"])) + }) + + it("returns a single unknown port for an object schema without properties", () => { + const schema = {type: "object"} + const ports = extractOutputPortsFromSchema(schema) + expect(ports).toHaveLength(1) + expect(ports[0]).toMatchObject({key: "output", type: "unknown"}) + }) +}) + +// ── extractSystemFieldNames ─────────────────────────────────────────────────── + +describe("extractSystemFieldNames", () => { + it("returns an empty set for null input", () => { + expect(extractSystemFieldNames(null).size).toBe(0) + }) + + it("returns an empty set when no properties are system fields", () => { + const schema = { + properties: {country: {type: "string"}}, + } + expect(extractSystemFieldNames(schema).size).toBe(0) + }) + + it("identifies fields annotated with x-ag-* markers", () => { + const schema = { + properties: { + country: {type: "string"}, + _ctx: {"x-ag-context": true}, + _consent: {"x-ag-consent": true}, + }, + } + const names = extractSystemFieldNames(schema) + expect(names.has("_ctx")).toBe(true) + expect(names.has("_consent")).toBe(true) + expect(names.has("country")).toBe(false) + }) +}) diff --git a/web/packages/agenta-entities/tests/unit/revision-table-state.test.ts b/web/packages/agenta-entities/tests/unit/revision-table-state.test.ts new file mode 100644 index 0000000000..82f0f6c080 --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/revision-table-state.test.ts @@ -0,0 +1,339 @@ +/** + * Unit tests for revision table state atoms and reducers. + * + * The revision table state manages pending column and row operations for a + * testset revision before they are committed to the server. This module is + * pure Jotai (no TanStack Query, no network) — all reducers and atoms can be + * exercised with a plain createStore(). + * + * Coverage: + * • pendingColumnOpsAtomFamily — initial state, isolation per revision + * • pendingRowOpsAtomFamily — initial state, isolation per revision + * • addColumnReducer — adds to add[], de-duplication + * • removeColumnReducer — removes pending adds; marks server cols for deletion + * • renameColumnReducer — renames in add[]; appends to rename[] + * • addRowReducer — adds to add[], returns the row ID + * • removeRowReducer — cancels pending adds; marks server rows for deletion + * • removeRowsReducer — batch remove + * • clearPendingOpsReducer — resets all pending state + * • hasPendingChangesAtomFamily— false initially, true after any op + */ + +import {describe, it, expect} from "vitest" +import {createStore} from "jotai" + +import { + pendingColumnOpsAtomFamily, + pendingRowOpsAtomFamily, + hasPendingChangesAtomFamily, + addColumnReducer, + removeColumnReducer, + renameColumnReducer, + addRowReducer, + removeRowReducer, + removeRowsReducer, + clearPendingOpsReducer, +} from "../../src/testset/state/revisionTableState" + +// ── helpers ─────────────────────────────────────────────────────────────────── + +function freshStore() { + return createStore() +} + +const REV = "rev-1" + +// ── Initial state ───────────────────────────────────────────────────────────── + +describe("initial state", () => { + it("pendingColumnOps starts with empty add/remove/rename", () => { + const store = freshStore() + const ops = store.get(pendingColumnOpsAtomFamily(REV)) + expect(ops.add).toEqual([]) + expect(ops.remove).toEqual([]) + expect(ops.rename).toEqual([]) + }) + + it("pendingRowOps starts with empty add/remove", () => { + const store = freshStore() + const ops = store.get(pendingRowOpsAtomFamily(REV)) + expect(ops.add).toEqual([]) + expect(ops.remove).toEqual([]) + }) + + it("hasPendingChanges is false initially", () => { + const store = freshStore() + expect(store.get(hasPendingChangesAtomFamily(REV))).toBe(false) + }) + + it("different revision IDs are isolated", () => { + const store = freshStore() + store.set(addColumnReducer, {revisionId: "rev-A", columnKey: "col"}) + const opsB = store.get(pendingColumnOpsAtomFamily("rev-B")) + expect(opsB.add).toHaveLength(0) + }) +}) + +// ── addColumnReducer ────────────────────────────────────────────────────────── + +describe("addColumnReducer", () => { + it("adds a column key to the add list", () => { + const store = freshStore() + store.set(addColumnReducer, {revisionId: REV, columnKey: "score"}) + expect(store.get(pendingColumnOpsAtomFamily(REV)).add).toContain("score") + }) + + it("hasPendingChanges becomes true after adding a column", () => { + const store = freshStore() + store.set(addColumnReducer, {revisionId: REV, columnKey: "score"}) + expect(store.get(hasPendingChangesAtomFamily(REV))).toBe(true) + }) + + it("does not add a duplicate key", () => { + const store = freshStore() + store.set(addColumnReducer, {revisionId: REV, columnKey: "score"}) + store.set(addColumnReducer, {revisionId: REV, columnKey: "score"}) + expect(store.get(pendingColumnOpsAtomFamily(REV)).add).toHaveLength(1) + }) + + it("un-removes a previously removed column instead of adding", () => { + const store = freshStore() + // Simulate a server column "notes" that was pending removal + store.set(pendingColumnOpsAtomFamily(REV), { + add: [], + remove: ["notes"], + rename: [], + }) + store.set(addColumnReducer, {revisionId: REV, columnKey: "notes"}) + const ops = store.get(pendingColumnOpsAtomFamily(REV)) + expect(ops.remove).not.toContain("notes") + expect(ops.add).not.toContain("notes") + }) + + it("accumulates multiple column adds", () => { + const store = freshStore() + store.set(addColumnReducer, {revisionId: REV, columnKey: "alpha"}) + store.set(addColumnReducer, {revisionId: REV, columnKey: "beta"}) + const ops = store.get(pendingColumnOpsAtomFamily(REV)) + expect(ops.add).toEqual(expect.arrayContaining(["alpha", "beta"])) + }) +}) + +// ── removeColumnReducer ─────────────────────────────────────────────────────── + +describe("removeColumnReducer", () => { + it("removes a pending-add column from the add list (no remove entry)", () => { + const store = freshStore() + store.set(addColumnReducer, {revisionId: REV, columnKey: "temp"}) + store.set(removeColumnReducer, {revisionId: REV, columnKey: "temp"}) + const ops = store.get(pendingColumnOpsAtomFamily(REV)) + expect(ops.add).not.toContain("temp") + expect(ops.remove).not.toContain("temp") + }) + + it("marks a server (non-pending) column for removal", () => { + const store = freshStore() + store.set(removeColumnReducer, {revisionId: REV, columnKey: "country"}) + expect(store.get(pendingColumnOpsAtomFamily(REV)).remove).toContain("country") + }) + + it("does not duplicate a column in the remove list", () => { + const store = freshStore() + store.set(removeColumnReducer, {revisionId: REV, columnKey: "country"}) + store.set(removeColumnReducer, {revisionId: REV, columnKey: "country"}) + expect(store.get(pendingColumnOpsAtomFamily(REV)).remove).toHaveLength(1) + }) +}) + +// ── renameColumnReducer ─────────────────────────────────────────────────────── + +describe("renameColumnReducer", () => { + it("renames a pending-add column in the add list", () => { + const store = freshStore() + store.set(addColumnReducer, {revisionId: REV, columnKey: "old_name"}) + store.set(renameColumnReducer, {revisionId: REV, oldKey: "old_name", newKey: "new_name"}) + const ops = store.get(pendingColumnOpsAtomFamily(REV)) + expect(ops.add).toContain("new_name") + expect(ops.add).not.toContain("old_name") + expect(ops.rename).toHaveLength(0) // rename goes to add[], not rename[] + }) + + it("appends a rename entry for a server column", () => { + const store = freshStore() + store.set(renameColumnReducer, {revisionId: REV, oldKey: "score", newKey: "rating"}) + const ops = store.get(pendingColumnOpsAtomFamily(REV)) + expect(ops.rename).toContainEqual({oldKey: "score", newKey: "rating"}) + }) + + it("updates an existing rename entry instead of duplicating", () => { + const store = freshStore() + store.set(renameColumnReducer, {revisionId: REV, oldKey: "score", newKey: "rating"}) + store.set(renameColumnReducer, {revisionId: REV, oldKey: "score", newKey: "final_score"}) + const ops = store.get(pendingColumnOpsAtomFamily(REV)) + expect(ops.rename).toHaveLength(1) + expect(ops.rename[0]).toEqual({oldKey: "score", newKey: "final_score"}) + }) +}) + +// ── addRowReducer ───────────────────────────────────────────────────────────── + +describe("addRowReducer", () => { + it("adds a provided row ID to pending adds", () => { + const store = freshStore() + store.set(addRowReducer, {revisionId: REV, rowId: "local-row-1"}) + expect(store.get(pendingRowOpsAtomFamily(REV)).add).toContain("local-row-1") + }) + + it("returns the row ID that was added", () => { + const store = freshStore() + const id = store.set(addRowReducer, {revisionId: REV, rowId: "local-row-abc"}) + expect(id).toBe("local-row-abc") + }) + + it("generates a new- prefixed ID when no rowId is provided", () => { + const store = freshStore() + const id = store.set(addRowReducer, {revisionId: REV}) + expect(id.startsWith("new-")).toBe(true) + }) + + it("hasPendingChanges is true after adding a row", () => { + const store = freshStore() + store.set(addRowReducer, {revisionId: REV, rowId: "r1"}) + expect(store.get(hasPendingChangesAtomFamily(REV))).toBe(true) + }) + + it("does not duplicate an already-pending add", () => { + const store = freshStore() + store.set(addRowReducer, {revisionId: REV, rowId: "r1"}) + store.set(addRowReducer, {revisionId: REV, rowId: "r1"}) + expect(store.get(pendingRowOpsAtomFamily(REV)).add).toHaveLength(1) + }) +}) + +// ── removeRowReducer ────────────────────────────────────────────────────────── + +describe("removeRowReducer", () => { + it("cancels a pending-add row (removes from add[], no remove entry)", () => { + const store = freshStore() + store.set(addRowReducer, {revisionId: REV, rowId: "local-r1"}) + store.set(removeRowReducer, {revisionId: REV, rowId: "local-r1"}) + const ops = store.get(pendingRowOpsAtomFamily(REV)) + expect(ops.add).not.toContain("local-r1") + expect(ops.remove).not.toContain("local-r1") + }) + + it("marks a server row for deletion", () => { + const store = freshStore() + store.set(removeRowReducer, {revisionId: REV, rowId: "server-row-1"}) + expect(store.get(pendingRowOpsAtomFamily(REV)).remove).toContain("server-row-1") + }) + + it("does not duplicate a server row in remove list", () => { + const store = freshStore() + store.set(removeRowReducer, {revisionId: REV, rowId: "server-row-1"}) + store.set(removeRowReducer, {revisionId: REV, rowId: "server-row-1"}) + expect(store.get(pendingRowOpsAtomFamily(REV)).remove).toHaveLength(1) + }) +}) + +// ── removeRowsReducer ───────────────────────────────────────────────────────── + +describe("removeRowsReducer", () => { + it("removes multiple rows at once", () => { + const store = freshStore() + store.set(removeRowsReducer, {revisionId: REV, rowIds: ["r1", "r2", "r3"]}) + const ops = store.get(pendingRowOpsAtomFamily(REV)) + expect(ops.remove).toEqual(expect.arrayContaining(["r1", "r2", "r3"])) + }) + + it("cancels pending-add rows without adding to remove list", () => { + const store = freshStore() + store.set(addRowReducer, {revisionId: REV, rowId: "local-1"}) + store.set(addRowReducer, {revisionId: REV, rowId: "local-2"}) + store.set(removeRowsReducer, {revisionId: REV, rowIds: ["local-1", "server-1"]}) + const ops = store.get(pendingRowOpsAtomFamily(REV)) + expect(ops.add).not.toContain("local-1") + expect(ops.add).toContain("local-2") + expect(ops.remove).toContain("server-1") + expect(ops.remove).not.toContain("local-1") + }) +}) + +// ── clearPendingOpsReducer ──────────────────────────────────────────────────── + +describe("clearPendingOpsReducer", () => { + it("resets all column ops to empty", () => { + const store = freshStore() + store.set(addColumnReducer, {revisionId: REV, columnKey: "col"}) + store.set(removeColumnReducer, {revisionId: REV, columnKey: "other"}) + store.set(clearPendingOpsReducer, REV) + const ops = store.get(pendingColumnOpsAtomFamily(REV)) + expect(ops.add).toEqual([]) + expect(ops.remove).toEqual([]) + expect(ops.rename).toEqual([]) + }) + + it("resets all row ops to empty", () => { + const store = freshStore() + store.set(addRowReducer, {revisionId: REV, rowId: "r1"}) + store.set(removeRowReducer, {revisionId: REV, rowId: "server-r1"}) + store.set(clearPendingOpsReducer, REV) + const ops = store.get(pendingRowOpsAtomFamily(REV)) + expect(ops.add).toEqual([]) + expect(ops.remove).toEqual([]) + }) + + it("hasPendingChanges is false after clearing", () => { + const store = freshStore() + store.set(addColumnReducer, {revisionId: REV, columnKey: "col"}) + store.set(clearPendingOpsReducer, REV) + expect(store.get(hasPendingChangesAtomFamily(REV))).toBe(false) + }) + + it("clear for one revision does not affect another", () => { + const store = freshStore() + store.set(addColumnReducer, {revisionId: "rev-A", columnKey: "col"}) + store.set(addColumnReducer, {revisionId: "rev-B", columnKey: "col"}) + store.set(clearPendingOpsReducer, "rev-A") + expect(store.get(hasPendingChangesAtomFamily("rev-B"))).toBe(true) + }) +}) + +// ── hasPendingChangesAtomFamily ─────────────────────────────────────────────── + +describe("hasPendingChangesAtomFamily", () => { + it("is false with no operations", () => { + const store = freshStore() + expect(store.get(hasPendingChangesAtomFamily(REV))).toBe(false) + }) + + it("is true with a pending column add", () => { + const store = freshStore() + store.set(addColumnReducer, {revisionId: REV, columnKey: "x"}) + expect(store.get(hasPendingChangesAtomFamily(REV))).toBe(true) + }) + + it("is true with a pending column remove", () => { + const store = freshStore() + store.set(removeColumnReducer, {revisionId: REV, columnKey: "x"}) + expect(store.get(hasPendingChangesAtomFamily(REV))).toBe(true) + }) + + it("is true with a pending column rename", () => { + const store = freshStore() + store.set(renameColumnReducer, {revisionId: REV, oldKey: "a", newKey: "b"}) + expect(store.get(hasPendingChangesAtomFamily(REV))).toBe(true) + }) + + it("is true with a pending row add", () => { + const store = freshStore() + store.set(addRowReducer, {revisionId: REV, rowId: "r1"}) + expect(store.get(hasPendingChangesAtomFamily(REV))).toBe(true) + }) + + it("is true with a pending row remove", () => { + const store = freshStore() + store.set(removeRowReducer, {revisionId: REV, rowId: "r1"}) + expect(store.get(hasPendingChangesAtomFamily(REV))).toBe(true) + }) +}) diff --git a/web/packages/agenta-entities/tests/unit/testcase-molecule.test.ts b/web/packages/agenta-entities/tests/unit/testcase-molecule.test.ts new file mode 100644 index 0000000000..9d380b36f5 --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/testcase-molecule.test.ts @@ -0,0 +1,328 @@ +/** + * Unit tests for testcaseMolecule. + * + * Covers the parts that work without a TanStack QueryClient or running backend: + * + * • Molecule shape — exported properties exist + * • ID tracking atoms — ids, newIds, deletedIds (plain atoms) + * • Revision context — currentRevisionIdAtom + * • actions.add — creates a local testcase, adds to newIds, initializes draft + * • actions.delete — removes local (new-*) vs soft-deletes server entities + * • atoms.displayRowIds — derived: new first, server excluding deleted + * • atoms.hasUnsavedChanges — derived: true when new/dirty/deleted entities + * • Selection draft — setSelectionDraft / commitSelectionDraft / discardSelectionDraft + * • actions.create — batch creation of testcases with options + * • actions.append — batch append from row data + * + * Each test uses a fresh createStore() for full isolation. + */ + +import {describe, it, expect} from "vitest" +import {createStore} from "jotai" + +import {testcaseMolecule} from "../../src/testcase/state/molecule" + +// ── helpers ─────────────────────────────────────────────────────────────────── + +function freshStore() { + return createStore() +} + +// ── Molecule shape ──────────────────────────────────────────────────────────── + +describe("testcaseMolecule shape", () => { + it("exposes 'testcase' as the molecule name", () => { + expect(testcaseMolecule.name).toBe("testcase") + }) + + it("exposes atoms namespace", () => { + expect(testcaseMolecule.atoms).toBeDefined() + expect(typeof testcaseMolecule.atoms.data).toBe("function") + expect(typeof testcaseMolecule.atoms.isDirty).toBe("function") + expect(typeof testcaseMolecule.atoms.draft).toBe("function") + }) + + it("exposes actions namespace", () => { + expect(testcaseMolecule.actions).toBeDefined() + expect(testcaseMolecule.actions.add).toBeDefined() + expect(testcaseMolecule.actions.delete).toBeDefined() + expect(testcaseMolecule.actions.update).toBeDefined() + expect(testcaseMolecule.actions.discard).toBeDefined() + expect(testcaseMolecule.actions.create).toBeDefined() + expect(testcaseMolecule.actions.append).toBeDefined() + }) + + it("exposes top-level id tracking atoms", () => { + expect(testcaseMolecule.ids).toBeDefined() + expect(testcaseMolecule.newIds).toBeDefined() + expect(testcaseMolecule.deletedIds).toBeDefined() + }) + + it("exposes loadable capability namespace", () => { + expect(testcaseMolecule.loadable).toBeDefined() + expect(typeof testcaseMolecule.loadable.rows).toBe("function") + expect(typeof testcaseMolecule.loadable.columns).toBe("function") + expect(typeof testcaseMolecule.loadable.hasChanges).toBe("function") + }) + + it("exposes get namespace with imperative getters", () => { + expect(typeof testcaseMolecule.get.data).toBe("function") + expect(typeof testcaseMolecule.get.ids).toBe("function") + expect(typeof testcaseMolecule.get.newIds).toBe("function") + expect(typeof testcaseMolecule.get.deletedIds).toBe("function") + }) +}) + +// ── ID tracking atoms ───────────────────────────────────────────────────────── + +describe("ID tracking atoms", () => { + it("ids starts empty", () => { + const store = freshStore() + expect(store.get(testcaseMolecule.ids)).toEqual([]) + }) + + it("newIds starts empty", () => { + const store = freshStore() + expect(store.get(testcaseMolecule.newIds)).toEqual([]) + }) + + it("deletedIds starts as empty Set", () => { + const store = freshStore() + expect(store.get(testcaseMolecule.deletedIds).size).toBe(0) + }) + + it("atoms.displayRowIds is empty initially", () => { + const store = freshStore() + expect(store.get(testcaseMolecule.atoms.displayRowIds)).toEqual([]) + }) + + it("atoms.hasUnsavedChanges is false initially", () => { + const store = freshStore() + expect(store.get(testcaseMolecule.atoms.hasUnsavedChanges)).toBe(false) + }) + + it("atoms.revisionId is null initially", () => { + const store = freshStore() + expect(store.get(testcaseMolecule.atoms.revisionId)).toBeNull() + }) +}) + +// ── actions.add ─────────────────────────────────────────────────────────────── + +describe("actions.add", () => { + it("returns a result with an ID", () => { + const store = freshStore() + const result = store.set(testcaseMolecule.actions.add, {data: {input: "hello"}}) + expect(result).not.toBeNull() + expect(typeof result?.id).toBe("string") + }) + + it("returns a result with a new- prefixed ID", () => { + const store = freshStore() + const result = store.set(testcaseMolecule.actions.add, {data: {input: "hi"}}) + expect(result?.id.startsWith("new-")).toBe(true) + }) + + it("adds the entity ID to newIds", () => { + const store = freshStore() + const result = store.set(testcaseMolecule.actions.add, {data: {col: "val"}}) + expect(store.get(testcaseMolecule.newIds)).toContain(result?.id) + }) + + it("hasUnsavedChanges is true after adding a testcase", () => { + const store = freshStore() + store.set(testcaseMolecule.actions.add, {data: {}}) + expect(store.get(testcaseMolecule.atoms.hasUnsavedChanges)).toBe(true) + }) + + it("the added ID appears in displayRowIds", () => { + const store = freshStore() + const result = store.set(testcaseMolecule.actions.add, {data: {col: "val"}}) + expect(store.get(testcaseMolecule.atoms.displayRowIds)).toContain(result?.id) + }) + + it("multiple adds produce unique IDs", () => { + const store = freshStore() + const r1 = store.set(testcaseMolecule.actions.add, {data: {col: "a"}}) + const r2 = store.set(testcaseMolecule.actions.add, {data: {col: "b"}}) + expect(r1?.id).not.toBe(r2?.id) + }) + + it("stored data includes the provided fields", () => { + const store = freshStore() + const result = store.set(testcaseMolecule.actions.add, { + data: {country: "USA", score: 42}, + }) + const draft = store.get(testcaseMolecule.atoms.draft(result!.id)) + expect(draft?.data).toMatchObject({country: "USA", score: 42}) + }) + + it("adding with no data succeeds with empty data object", () => { + const store = freshStore() + const result = store.set(testcaseMolecule.actions.add) + expect(result).not.toBeNull() + expect(result?.id).toBeDefined() + }) +}) + +// ── actions.delete ──────────────────────────────────────────────────────────── + +describe("actions.delete", () => { + it("removes a local (new-*) testcase from newIds completely", () => { + const store = freshStore() + const result = store.set(testcaseMolecule.actions.add, {data: {}}) + const id = result!.id + store.set(testcaseMolecule.actions.delete, id) + expect(store.get(testcaseMolecule.newIds)).not.toContain(id) + }) + + it("clears the draft when removing a local testcase", () => { + const store = freshStore() + const result = store.set(testcaseMolecule.actions.add, {data: {col: "x"}}) + const id = result!.id + store.set(testcaseMolecule.actions.delete, id) + expect(store.get(testcaseMolecule.atoms.draft(id))).toBeNull() + }) + + it("removes deleted local testcase from displayRowIds", () => { + const store = freshStore() + const result = store.set(testcaseMolecule.actions.add, {data: {}}) + const id = result!.id + store.set(testcaseMolecule.actions.delete, id) + expect(store.get(testcaseMolecule.atoms.displayRowIds)).not.toContain(id) + }) + + it("accepts an array of IDs for batch delete", () => { + const store = freshStore() + const r1 = store.set(testcaseMolecule.actions.add, {data: {}}) + const r2 = store.set(testcaseMolecule.actions.add, {data: {}}) + store.set(testcaseMolecule.actions.delete, [r1!.id, r2!.id]) + expect(store.get(testcaseMolecule.newIds)).toHaveLength(0) + }) + + it("soft-deletes a server entity (adds to deletedIds)", () => { + const store = freshStore() + // Simulate a server entity by putting it in deletedIds via markDeleted + const serverId = "server-tc-123" + store.set(testcaseMolecule.actions.delete, serverId) + expect(store.get(testcaseMolecule.deletedIds).has(serverId)).toBe(true) + }) +}) + +// ── displayRowIds and hasUnsavedChanges ─────────────────────────────────────── + +describe("displayRowIds and hasUnsavedChanges", () => { + it("displayRowIds includes new entities", () => { + const store = freshStore() + const r1 = store.set(testcaseMolecule.actions.add, {data: {}}) + const r2 = store.set(testcaseMolecule.actions.add, {data: {}}) + const rowIds = store.get(testcaseMolecule.atoms.displayRowIds) + expect(rowIds).toContain(r1!.id) + expect(rowIds).toContain(r2!.id) + }) + + it("displayRowIds excludes deleted local entities", () => { + const store = freshStore() + const result = store.set(testcaseMolecule.actions.add, {data: {}}) + const id = result!.id + store.set(testcaseMolecule.actions.delete, id) + expect(store.get(testcaseMolecule.atoms.displayRowIds)).not.toContain(id) + }) + + it("hasUnsavedChanges returns false when no entities present", () => { + const store = freshStore() + expect(store.get(testcaseMolecule.atoms.hasUnsavedChanges)).toBe(false) + }) + + it("hasUnsavedChanges returns false after deleting the only added entity", () => { + const store = freshStore() + const result = store.set(testcaseMolecule.actions.add, {data: {}}) + store.set(testcaseMolecule.actions.delete, result!.id) + // After removing the only new entity, no unsaved changes remain + expect(store.get(testcaseMolecule.atoms.hasUnsavedChanges)).toBe(false) + }) +}) + +// ── actions.create (batch with options) ─────────────────────────────────────── + +describe("actions.create", () => { + it("creates multiple testcases from rows", () => { + const store = freshStore() + const result = store.set(testcaseMolecule.actions.create, { + rows: [{col1: "a"}, {col1: "b"}], + }) + expect(result.count).toBe(2) + expect(result.ids).toHaveLength(2) + }) + + it("all created IDs are unique", () => { + const store = freshStore() + const result = store.set(testcaseMolecule.actions.create, { + rows: [{x: 1}, {x: 2}, {x: 3}], + }) + expect(new Set(result.ids).size).toBe(3) + }) + + it("created IDs appear in newIds", () => { + const store = freshStore() + const result = store.set(testcaseMolecule.actions.create, { + rows: [{field: "val"}], + }) + expect(store.get(testcaseMolecule.newIds)).toContain(result.ids[0]) + }) + + it("returns zero count for empty rows array", () => { + const store = freshStore() + const result = store.set(testcaseMolecule.actions.create, {rows: []}) + expect(result.count).toBe(0) + expect(result.ids).toHaveLength(0) + }) +}) + +// ── Selection draft operations ──────────────────────────────────────────────── + +describe("selection draft", () => { + const REV = "rev-for-selection-test" + + it("selection draft starts as null", () => { + const store = freshStore() + expect(store.get(testcaseMolecule.atoms.selectionDraft(REV))).toBeNull() + }) + + it("setSelectionDraft populates the draft with provided IDs", () => { + const store = freshStore() + store.set(testcaseMolecule.actions.setSelectionDraft, REV, ["tc-1", "tc-2"]) + const draft = store.get(testcaseMolecule.atoms.selectionDraft(REV)) + expect(draft?.has("tc-1")).toBe(true) + expect(draft?.has("tc-2")).toBe(true) + }) + + it("discardSelectionDraft clears the draft back to null", () => { + const store = freshStore() + store.set(testcaseMolecule.actions.setSelectionDraft, REV, ["tc-1"]) + store.set(testcaseMolecule.actions.discardSelectionDraft, REV) + expect(store.get(testcaseMolecule.atoms.selectionDraft(REV))).toBeNull() + }) + + it("commitSelectionDraft updates testcaseIdsAtom to the selected set", () => { + const store = freshStore() + store.set(testcaseMolecule.actions.setSelectionDraft, REV, ["tc-a", "tc-b"]) + store.set(testcaseMolecule.actions.commitSelectionDraft, REV) + const ids = store.get(testcaseMolecule.ids) + expect(ids).toContain("tc-a") + expect(ids).toContain("tc-b") + }) + + it("commitSelectionDraft clears the draft", () => { + const store = freshStore() + store.set(testcaseMolecule.actions.setSelectionDraft, REV, ["tc-1"]) + store.set(testcaseMolecule.actions.commitSelectionDraft, REV) + expect(store.get(testcaseMolecule.atoms.selectionDraft(REV))).toBeNull() + }) + + it("drafts for different revisions are isolated", () => { + const store = freshStore() + store.set(testcaseMolecule.actions.setSelectionDraft, "rev-A", ["tc-1"]) + expect(store.get(testcaseMolecule.atoms.selectionDraft("rev-B"))).toBeNull() + }) +}) diff --git a/web/packages/agenta-entities/tests/unit/testset-molecule.test.ts b/web/packages/agenta-entities/tests/unit/testset-molecule.test.ts new file mode 100644 index 0000000000..e18819296d --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/testset-molecule.test.ts @@ -0,0 +1,260 @@ +/** + * Unit tests for testsetMolecule and its filter / draft atoms. + * + * These tests target the parts of the testset molecule that can be exercised + * without a running backend or a TanStack QueryClient: + * + * • Filter atoms — plain Jotai atoms (no query dependency) + * • Draft operations — update / discard reducers write to draftAtomFamily + * • isDirty atom — reads draft, not server data, so query is not needed + * • isNew detection — pure function + createStore + * • Null-safe selectors — queryOptional / dataOptional with null IDs + * • Molecule shape — exported properties exist + * + * The TanStack Query-backed atoms (testsetQueryAtomFamily) are NOT exercised + * here — those require a live QueryClient and belong in integration tests. + */ + +import {describe, it, expect} from "vitest" +import {createStore} from "jotai" + +import {testsetMolecule} from "../../src/testset/state/testsetMolecule" +import {isNewTestsetId} from "../../src/testset/core" + +// ── helpers ─────────────────────────────────────────────────────────────────── + +function freshStore() { + return createStore() +} + +// ── Molecule shape ──────────────────────────────────────────────────────────── + +describe("testsetMolecule shape", () => { + it("exposes 'testset' as the molecule name", () => { + expect(testsetMolecule.name).toBe("testset") + }) + + it("exposes atoms namespace", () => { + expect(testsetMolecule.atoms).toBeDefined() + expect(typeof testsetMolecule.atoms.data).toBe("function") + expect(typeof testsetMolecule.atoms.isDirty).toBe("function") + expect(typeof testsetMolecule.atoms.draft).toBe("function") + expect(typeof testsetMolecule.atoms.serverData).toBe("function") + }) + + it("exposes actions namespace", () => { + expect(testsetMolecule.actions).toBeDefined() + expect(testsetMolecule.actions.update).toBeDefined() + expect(testsetMolecule.actions.discard).toBeDefined() + expect(testsetMolecule.actions.save).toBeDefined() + expect(testsetMolecule.actions.delete).toBeDefined() + }) + + it("exposes get namespace with imperative read functions", () => { + expect(typeof testsetMolecule.get.data).toBe("function") + expect(typeof testsetMolecule.get.isDirty).toBe("function") + }) + + it("exposes set namespace with imperative write functions", () => { + expect(typeof testsetMolecule.set.update).toBe("function") + expect(typeof testsetMolecule.set.discard).toBe("function") + expect(typeof testsetMolecule.set.create).toBe("function") + }) + + it("exposes filters namespace", () => { + expect(testsetMolecule.filters).toBeDefined() + expect(testsetMolecule.filters.searchTerm).toBeDefined() + expect(testsetMolecule.filters.exportFormat).toBeDefined() + expect(testsetMolecule.filters.dateCreated).toBeDefined() + expect(testsetMolecule.filters.dateModified).toBeDefined() + }) + + it("exposes paginated namespace", () => { + expect(testsetMolecule.paginated).toBeDefined() + expect(testsetMolecule.paginated.store).toBeDefined() + expect(testsetMolecule.paginated.refreshAtom).toBeDefined() + }) + + it("exposes latestRevision namespace", () => { + expect(testsetMolecule.latestRevision).toBeDefined() + expect(typeof testsetMolecule.latestRevision.selectors.data).toBe("function") + expect(typeof testsetMolecule.latestRevision.selectors.stateful).toBe("function") + expect(typeof testsetMolecule.latestRevision.get).toBe("function") + }) + + it("exposes invalidate namespace", () => { + expect(typeof testsetMolecule.invalidate.list).toBe("function") + expect(typeof testsetMolecule.invalidate.detail).toBe("function") + }) + + it("exposes lifecycle namespace", () => { + expect(typeof testsetMolecule.lifecycle.archive).toBe("function") + expect(typeof testsetMolecule.lifecycle.unarchive).toBe("function") + }) +}) + +// ── isNewTestsetId ──────────────────────────────────────────────────────────── + +describe("isNewTestsetId", () => { + it("returns true for new- prefixed IDs", () => { + expect(isNewTestsetId("new-abc")).toBe(true) + }) + + it("returns true for local- prefixed IDs", () => { + expect(isNewTestsetId("local-123")).toBe(true) + }) + + it("returns false for regular UUID-like IDs", () => { + expect(isNewTestsetId("550e8400-e29b-41d4-a716-446655440000")).toBe(false) + }) + + it("returns false for null", () => { + expect(isNewTestsetId(null)).toBe(false) + }) + + it("returns false for undefined", () => { + expect(isNewTestsetId(undefined)).toBe(false) + }) +}) + +// ── Filter atoms ────────────────────────────────────────────────────────────── + +describe("testset filter atoms", () => { + it("searchTerm atom starts as empty string", () => { + const store = freshStore() + expect(store.get(testsetMolecule.filters.searchTerm)).toBe("") + }) + + it("searchTerm can be written and read back", () => { + const store = freshStore() + store.set(testsetMolecule.filters.searchTerm, "my-search") + expect(store.get(testsetMolecule.filters.searchTerm)).toBe("my-search") + }) + + it("dateCreated atom starts as null", () => { + const store = freshStore() + expect(store.get(testsetMolecule.filters.dateCreated)).toBeNull() + }) + + it("dateCreated can be written and read back", () => { + const store = freshStore() + const range = {start: "2024-01-01", end: "2024-12-31"} + store.set(testsetMolecule.filters.dateCreated, range) + expect(store.get(testsetMolecule.filters.dateCreated)).toEqual(range) + }) + + it("dateModified atom starts as null", () => { + const store = freshStore() + expect(store.get(testsetMolecule.filters.dateModified)).toBeNull() + }) + + it("different store instances are isolated", () => { + const storeA = freshStore() + const storeB = freshStore() + storeA.set(testsetMolecule.filters.searchTerm, "only-in-A") + expect(storeB.get(testsetMolecule.filters.searchTerm)).toBe("") + }) +}) + +// ── Draft operations ────────────────────────────────────────────────────────── + +describe("testset draft operations", () => { + it("isDirty is false before any update", () => { + const store = freshStore() + expect(store.get(testsetMolecule.atoms.isDirty("ts-1"))).toBe(false) + }) + + it("isDirty is true after calling actions.update", () => { + const store = freshStore() + store.set(testsetMolecule.actions.update, "ts-1", {name: "New Name"}) + expect(store.get(testsetMolecule.atoms.isDirty("ts-1"))).toBe(true) + }) + + it("draft atom reflects the staged changes", () => { + const store = freshStore() + store.set(testsetMolecule.actions.update, "ts-1", {name: "Staged"}) + const draft = store.get(testsetMolecule.atoms.draft("ts-1")) + expect(draft).toMatchObject({name: "Staged"}) + }) + + it("actions.update accumulates across multiple calls", () => { + const store = freshStore() + store.set(testsetMolecule.actions.update, "ts-1", {name: "First"}) + store.set(testsetMolecule.actions.update, "ts-1", {description: "Second"}) + const draft = store.get(testsetMolecule.atoms.draft("ts-1")) + expect(draft).toMatchObject({name: "First", description: "Second"}) + }) + + it("actions.discard clears the draft and isDirty becomes false", () => { + const store = freshStore() + store.set(testsetMolecule.actions.update, "ts-1", {name: "Pending"}) + store.set(testsetMolecule.actions.discard, "ts-1") + expect(store.get(testsetMolecule.atoms.isDirty("ts-1"))).toBe(false) + expect(store.get(testsetMolecule.atoms.draft("ts-1"))).toBeNull() + }) + + it("changes to one ID do not affect another", () => { + const store = freshStore() + store.set(testsetMolecule.actions.update, "ts-A", {name: "A"}) + expect(store.get(testsetMolecule.atoms.isDirty("ts-B"))).toBe(false) + }) +}) + +// ── Local entity creation ───────────────────────────────────────────────────── + +describe("testset local entity creation", () => { + it("set.create returns a new- prefixed ID", () => { + const store = freshStore() + const id = testsetMolecule.set.create({name: "Draft Testset"}, {store}) + expect(id.startsWith("new-")).toBe(true) + }) + + it("multiple creates return unique IDs", () => { + const store = freshStore() + const id1 = testsetMolecule.set.create({name: "A"}, {store}) + const id2 = testsetMolecule.set.create({name: "B"}, {store}) + expect(id1).not.toBe(id2) + }) + + it("created ID is recognized as new by isNewTestsetId", () => { + const store = freshStore() + const id = testsetMolecule.set.create({name: "New"}, {store}) + expect(isNewTestsetId(id)).toBe(true) + }) +}) + +// ── Null-safe selectors ─────────────────────────────────────────────────────── + +describe("testset null-safe selectors", () => { + it("queryOptional(null) returns an atom with isPending=false and data=null", () => { + const store = freshStore() + const result = store.get(testsetMolecule.queryOptional(null)) + expect(result.isPending).toBe(false) + expect(result.data).toBeNull() + }) + + it("queryOptional(undefined) returns an atom with isPending=false and data=null", () => { + const store = freshStore() + const result = store.get(testsetMolecule.queryOptional(undefined)) + expect(result.isPending).toBe(false) + expect(result.data).toBeNull() + }) + + it("dataOptional(null) returns null", () => { + const store = freshStore() + expect(store.get(testsetMolecule.dataOptional(null))).toBeNull() + }) + + it("dataOptional(undefined) returns null", () => { + const store = freshStore() + expect(store.get(testsetMolecule.dataOptional(undefined))).toBeNull() + }) + + it("queryOptional with a valid ID returns an atom (delegates to query family)", () => { + // The atom exists — we can't assert data without a QueryClient, but we can + // verify that a truthy atom is returned (not the null sentinel). + const atom = testsetMolecule.queryOptional("real-id") + expect(atom).toBeDefined() + expect(typeof atom).toBe("object") + }) +}) diff --git a/web/packages/agenta-entities/tests/unit/trace-span-molecule.test.ts b/web/packages/agenta-entities/tests/unit/trace-span-molecule.test.ts new file mode 100644 index 0000000000..aef3fe8871 --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/trace-span-molecule.test.ts @@ -0,0 +1,338 @@ +/** + * Unit tests for traceSpanMolecule. + * + * The trace span molecule has a special feature: `local.set(spanId, data)` seeds a + * span directly into an in-memory atom (localDataAtomFamily) so that the combined + * query atom returns it without any network call. This lets us test the full merge, + * dirty detection, and derived atoms (inputs/outputs/agData) in Node. + * + * Coverage: + * • Molecule shape — name, atoms, selectors, local, drillIn, getAgDataPath + * • local data API — set / clear / dataAtom + * • atoms.data — returns local span when seeded + * • Derived atoms — inputs / outputs / agData derived from seeded span + * • Draft operations — update merges into attributes; isDirty; discard + * • Custom merge — only attributes are draftable, rest of span unchanged + * • Custom isDirty — deep comparison via normalizeValueForComparison + * • isNew detection — inline-* and local-* prefixes + * • Lifecycle — isActive tracks first access; cleanup.remove clears it + */ + +import {describe, it, expect} from "vitest" +import {createStore} from "jotai" + +import {traceSpanMolecule} from "../../src/trace/state/molecule" +import type {TraceSpan} from "../../src/trace/core" + +// ── helpers ─────────────────────────────────────────────────────────────────── + +function freshStore() { + return createStore() +} + +/** Minimal TraceSpan fixture with ag.data inputs/outputs */ +function makeSpan(overrides?: Partial): TraceSpan { + return { + trace_id: "trace-1", + span_id: "span-1", + attributes: { + "ag.data": { + inputs: {prompt: "Hello"}, + outputs: "World", + }, + }, + ...overrides, + } +} + +// ── Molecule shape ──────────────────────────────────────────────────────────── + +describe("traceSpanMolecule shape", () => { + it("exposes 'traceSpan' as the molecule name", () => { + expect(traceSpanMolecule.name).toBe("traceSpan") + }) + + it("exposes atoms namespace with data/isDirty/serverData/query", () => { + expect(typeof traceSpanMolecule.atoms.data).toBe("function") + expect(typeof traceSpanMolecule.atoms.isDirty).toBe("function") + expect(typeof traceSpanMolecule.atoms.serverData).toBe("function") + expect(typeof traceSpanMolecule.atoms.query).toBe("function") + }) + + it("exposes derived atoms inputs/outputs/agData", () => { + expect(typeof traceSpanMolecule.atoms.inputs).toBe("function") + expect(typeof traceSpanMolecule.atoms.outputs).toBe("function") + expect(typeof traceSpanMolecule.atoms.agData).toBe("function") + }) + + it("exposes selectors namespace mirroring atoms", () => { + expect(typeof traceSpanMolecule.selectors.data).toBe("function") + expect(typeof traceSpanMolecule.selectors.inputs).toBe("function") + expect(typeof traceSpanMolecule.selectors.outputs).toBe("function") + }) + + it("exposes reducers namespace with update and discard", () => { + expect(traceSpanMolecule.reducers.update).toBeDefined() + expect(traceSpanMolecule.reducers.discard).toBeDefined() + }) + + it("exposes local namespace for seeding inline spans", () => { + expect(typeof traceSpanMolecule.local.set).toBe("function") + expect(typeof traceSpanMolecule.local.clear).toBe("function") + expect(typeof traceSpanMolecule.local.clearAll).toBe("function") + expect(typeof traceSpanMolecule.local.dataAtom).toBe("function") + }) + + it("exposes drillIn namespace with path helpers", () => { + expect(typeof traceSpanMolecule.drillIn.getValueAtPath).toBe("function") + expect(typeof traceSpanMolecule.drillIn.getRootItems).toBe("function") + expect(typeof traceSpanMolecule.drillIn.getChangesFromPath).toBe("function") + }) + + it("exposes getAgDataPath helper", () => { + expect(typeof traceSpanMolecule.getAgDataPath).toBe("function") + }) + + it("exposes imperative get namespace", () => { + expect(typeof traceSpanMolecule.get.data).toBe("function") + expect(typeof traceSpanMolecule.get.isDirty).toBe("function") + expect(typeof traceSpanMolecule.get.inputs).toBe("function") + expect(typeof traceSpanMolecule.get.outputs).toBe("function") + }) +}) + +// ── local data API ──────────────────────────────────────────────────────────── + +describe("traceSpanMolecule local data", () => { + it("local.dataAtom starts as null", () => { + const store = freshStore() + expect(store.get(traceSpanMolecule.local.dataAtom("span-1"))).toBeNull() + }) + + it("local.set seeds span data into the store", () => { + const store = freshStore() + const span = makeSpan() + traceSpanMolecule.local.set("span-1", span, {store}) + expect(store.get(traceSpanMolecule.local.dataAtom("span-1"))).toEqual(span) + }) + + it("local.clear removes seeded data", () => { + const store = freshStore() + traceSpanMolecule.local.set("span-1", makeSpan(), {store}) + traceSpanMolecule.local.clear("span-1", {store}) + expect(store.get(traceSpanMolecule.local.dataAtom("span-1"))).toBeNull() + }) + + it("local.clearAll removes data for multiple spans", () => { + const store = freshStore() + traceSpanMolecule.local.set("span-A", makeSpan({span_id: "span-A"}), {store}) + traceSpanMolecule.local.set("span-B", makeSpan({span_id: "span-B"}), {store}) + traceSpanMolecule.local.clearAll(["span-A", "span-B"], {store}) + expect(store.get(traceSpanMolecule.local.dataAtom("span-A"))).toBeNull() + expect(store.get(traceSpanMolecule.local.dataAtom("span-B"))).toBeNull() + }) + + it("different stores are fully isolated", () => { + const storeA = freshStore() + const storeB = freshStore() + traceSpanMolecule.local.set("span-1", makeSpan(), {store: storeA}) + expect(storeB.get(traceSpanMolecule.local.dataAtom("span-1"))).toBeNull() + }) +}) + +// ── atoms.data (seeded via local) ───────────────────────────────────────────── + +describe("traceSpanMolecule atoms.data with local span", () => { + it("returns null when no local data is seeded", () => { + const store = freshStore() + // Without a server query or local data, data should be null + // (combinedQueryAtomFamily falls through to server query which is pending/null) + const data = store.get(traceSpanMolecule.atoms.data("span-unknown")) + expect(data).toBeNull() + }) + + it("returns the seeded span data", () => { + const store = freshStore() + const span = makeSpan() + traceSpanMolecule.local.set("span-1", span, {store}) + expect(store.get(traceSpanMolecule.atoms.data("span-1"))).toMatchObject({ + trace_id: "trace-1", + span_id: "span-1", + }) + }) + + it("returns merged data when draft is applied", () => { + const store = freshStore() + const span = makeSpan() + traceSpanMolecule.local.set("span-1", span, {store}) + store.set(traceSpanMolecule.reducers.update, "span-1", { + "ag.data": {inputs: {prompt: "Updated"}, outputs: "New"}, + }) + const merged = store.get(traceSpanMolecule.atoms.data("span-1")) + expect((merged?.attributes as Record)?.["ag.data"]).toMatchObject({ + inputs: {prompt: "Updated"}, + }) + }) +}) + +// ── Derived atoms: inputs / outputs / agData ────────────────────────────────── + +describe("traceSpanMolecule derived atoms", () => { + it("atoms.inputs returns empty object when no span is seeded", () => { + const store = freshStore() + expect(store.get(traceSpanMolecule.atoms.inputs("span-none"))).toEqual({}) + }) + + it("atoms.inputs extracts inputs from ag.data", () => { + const store = freshStore() + traceSpanMolecule.local.set("span-1", makeSpan(), {store}) + expect(store.get(traceSpanMolecule.atoms.inputs("span-1"))).toEqual({prompt: "Hello"}) + }) + + it("atoms.outputs extracts outputs from ag.data", () => { + const store = freshStore() + traceSpanMolecule.local.set("span-1", makeSpan(), {store}) + expect(store.get(traceSpanMolecule.atoms.outputs("span-1"))).toBe("World") + }) + + it("atoms.agData extracts the full ag.data block", () => { + const store = freshStore() + traceSpanMolecule.local.set("span-1", makeSpan(), {store}) + const agData = store.get(traceSpanMolecule.atoms.agData("span-1")) + expect(agData).toMatchObject({ + inputs: {prompt: "Hello"}, + outputs: "World", + }) + }) + + it("atoms.inputs returns empty object for a span with no ag.data", () => { + const store = freshStore() + traceSpanMolecule.local.set("span-bare", makeSpan({attributes: {status: "ok"}}), {store}) + expect(store.get(traceSpanMolecule.atoms.inputs("span-bare"))).toEqual({}) + }) +}) + +// ── Draft operations ────────────────────────────────────────────────────────── + +describe("traceSpanMolecule draft operations", () => { + it("isDirty is false before any update", () => { + const store = freshStore() + traceSpanMolecule.local.set("span-1", makeSpan(), {store}) + expect(store.get(traceSpanMolecule.atoms.isDirty("span-1"))).toBe(false) + }) + + it("isDirty is true after calling reducers.update with a changed attribute", () => { + const store = freshStore() + traceSpanMolecule.local.set("span-1", makeSpan(), {store}) + store.set(traceSpanMolecule.reducers.update, "span-1", { + "ag.data": {inputs: {prompt: "New"}, outputs: "New"}, + }) + expect(store.get(traceSpanMolecule.atoms.isDirty("span-1"))).toBe(true) + }) + + it("isDirty is false when draft restores identical attributes", () => { + const store = freshStore() + const span = makeSpan() + traceSpanMolecule.local.set("span-1", span, {store}) + // Update to same value — should not be dirty after normalization + store.set(traceSpanMolecule.reducers.update, "span-1", { + ...span.attributes, + }) + expect(store.get(traceSpanMolecule.atoms.isDirty("span-1"))).toBe(false) + }) + + it("reducers.discard clears draft and isDirty returns false", () => { + const store = freshStore() + traceSpanMolecule.local.set("span-1", makeSpan(), {store}) + store.set(traceSpanMolecule.reducers.update, "span-1", {"custom-attr": "x"}) + store.set(traceSpanMolecule.reducers.discard, "span-1") + expect(store.get(traceSpanMolecule.atoms.isDirty("span-1"))).toBe(false) + }) + + it("custom merge keeps non-attribute fields from server data", () => { + const store = freshStore() + const span = makeSpan({span_name: "my-span"}) + traceSpanMolecule.local.set("span-1", span, {store}) + store.set(traceSpanMolecule.reducers.update, "span-1", {"new-attr": "val"}) + const merged = store.get(traceSpanMolecule.atoms.data("span-1")) + // span_name should be preserved from server data + expect(merged?.span_name).toBe("my-span") + }) + + it("draft for one span does not affect another", () => { + const store = freshStore() + traceSpanMolecule.local.set("span-A", makeSpan({span_id: "span-A"}), {store}) + traceSpanMolecule.local.set("span-B", makeSpan({span_id: "span-B"}), {store}) + store.set(traceSpanMolecule.reducers.update, "span-A", {"only-A": true}) + expect(store.get(traceSpanMolecule.atoms.isDirty("span-B"))).toBe(false) + }) +}) + +// ── isNew detection ─────────────────────────────────────────────────────────── + +describe("traceSpanMolecule isNew detection", () => { + it("IDs starting with 'inline-' are considered new", () => { + const store = freshStore() + expect(store.get(traceSpanMolecule.atoms.isNew("inline-abc"))).toBe(true) + }) + + it("IDs starting with 'local-' are considered new", () => { + const store = freshStore() + expect(store.get(traceSpanMolecule.atoms.isNew("local-xyz"))).toBe(true) + }) + + it("server span IDs are not new", () => { + const store = freshStore() + expect(store.get(traceSpanMolecule.atoms.isNew("span-server-1"))).toBe(false) + expect(store.get(traceSpanMolecule.atoms.isNew("550e8400-e29b-41d4-a716"))).toBe(false) + }) +}) + +// ── getAgDataPath ───────────────────────────────────────────────────────────── + +describe("getAgDataPath", () => { + it("returns the flat key path when ag.data is a flat key", () => { + const span = makeSpan() + const path = traceSpanMolecule.getAgDataPath(span) + expect(path).toEqual(["attributes", "ag.data"]) + }) + + it("returns nested path when ag is a nested object", () => { + const span = makeSpan({attributes: {ag: {data: {inputs: {}}}}}) + const path = traceSpanMolecule.getAgDataPath(span) + expect(path).toEqual(["attributes", "ag", "data"]) + }) + + it("returns ['attributes'] fallback for a span with no ag data", () => { + const span = makeSpan({attributes: {other: "value"}}) + const path = traceSpanMolecule.getAgDataPath(span) + expect(path).toEqual(["attributes"]) + }) + + it("returns ['attributes'] for a null span", () => { + const path = traceSpanMolecule.getAgDataPath(null) + expect(path).toEqual(["attributes"]) + }) +}) + +// ── Lifecycle ───────────────────────────────────────────────────────────────── + +describe("traceSpanMolecule lifecycle", () => { + it("lifecycle.isActive is false before any access", () => { + expect(traceSpanMolecule.lifecycle.isActive("lifecycle-span-1")).toBe(false) + }) + + it("lifecycle.isActive is true after atoms.serverData is accessed", () => { + const store = freshStore() + // Accessing serverData triggers the onMount lifecycle hook + store.get(traceSpanMolecule.atoms.serverData("lifecycle-span-2")) + expect(traceSpanMolecule.lifecycle.isActive("lifecycle-span-2")).toBe(true) + }) + + it("lifecycle.isActive is false after cleanup.remove", () => { + const store = freshStore() + store.get(traceSpanMolecule.atoms.serverData("lifecycle-span-3")) + traceSpanMolecule.cleanup.remove("lifecycle-span-3") + expect(traceSpanMolecule.lifecycle.isActive("lifecycle-span-3")).toBe(false) + }) +}) diff --git a/web/packages/agenta-entities/vitest.config.ts b/web/packages/agenta-entities/vitest.config.ts new file mode 100644 index 0000000000..6edee7ebcd --- /dev/null +++ b/web/packages/agenta-entities/vitest.config.ts @@ -0,0 +1,28 @@ +import path from "path" + +import {defineConfig} from "vitest/config" + +export default defineConfig({ + resolve: { + alias: { + // Stub @agenta/ui so Vitest doesn't transform the entire antd tree. + // Entity tests only exercise Jotai atoms — no React rendering needed. + "@agenta/ui": path.resolve(__dirname, "tests/__mocks__/agenta-ui.ts"), + }, + }, + test: { + include: ["tests/**/*.test.ts"], + environment: "node", + reporters: ["default", "junit"], + outputFile: { + junit: "./test-results/junit.xml", + }, + coverage: { + provider: "v8", + include: ["src/**/*.ts"], + exclude: ["src/**/index.ts"], + reporter: ["text", "lcov", "json-summary"], + reportsDirectory: "./coverage", + }, + }, +}) diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 5bf174f480..b92acf4082 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -787,12 +787,6 @@ importers: immer: specifier: '>=10.0.0' version: 10.2.0 - jotai: - specifier: '>=2.0.0' - version: 2.20.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.6) - jotai-family: - specifier: '>=0.1.0' - version: 1.0.1(jotai@2.20.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.6)) jotai-scheduler: specifier: ^0.0.5 version: 0.0.5(jotai@2.20.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) @@ -878,9 +872,18 @@ importers: '@types/react-dom': specifier: ^19.0.0 version: 19.2.3(@types/react@19.2.14) + '@vitest/coverage-v8': + specifier: ^4.1.4 + version: 4.1.6(vitest@4.1.6) ajv: specifier: ^8.18.0 version: 8.20.0 + jotai: + specifier: ^2.16.1 + version: 2.20.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.6) + jotai-family: + specifier: ^1.0.1 + version: 1.0.1(jotai@2.20.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.6)) js-yaml: specifier: ^4.1.1 version: 4.1.1 @@ -899,6 +902,9 @@ importers: usehooks-ts: specifier: ^3.0.0 version: 3.1.1(react@19.2.6) + vitest: + specifier: ^4.1.4 + version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.6)(vite@8.0.12(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.47.0)(tsx@4.21.0)(yaml@2.8.4)) packages/agenta-entity-ui: dependencies: @@ -1521,6 +1527,10 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@braintree/sanitize-url@7.1.2': resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} @@ -2165,6 +2175,12 @@ packages: '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + '@next/bundle-analyzer@15.5.18': resolution: {integrity: sha512-v5/UNFwYbBlRQg/Bt+wU65XuxCxPu1AeCOI6s4s6Cludsj7FdVO9E9uzr7GIj8OykSrYtGuEQAUX0Ulje8W2yw==} @@ -2372,6 +2388,9 @@ packages: resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==} engines: {node: '>=14'} + '@oxc-project/types@0.129.0': + resolution: {integrity: sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==} + '@phosphor-icons/react@2.1.10': resolution: {integrity: sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==} engines: {node: '>=10'} @@ -2745,6 +2764,104 @@ packages: react-redux: optional: true + '@rolldown/binding-android-arm64@1.0.0': + resolution: {integrity: sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0': + resolution: {integrity: sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0': + resolution: {integrity: sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0': + resolution: {integrity: sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0': + resolution: {integrity: sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0': + resolution: {integrity: sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.0': + resolution: {integrity: sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0': + resolution: {integrity: sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.0': + resolution: {integrity: sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.0': + resolution: {integrity: sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.0': + resolution: {integrity: sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.0': + resolution: {integrity: sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0': + resolution: {integrity: sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0': + resolution: {integrity: sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0': + resolution: {integrity: sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0': + resolution: {integrity: sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==} + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -2995,6 +3112,9 @@ packages: '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} @@ -3088,6 +3208,9 @@ packages: '@types/d3@7.4.3': resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/diff@5.2.3': resolution: {integrity: sha512-K0Oqlrq3kQMaO2RhfrNQX5trmt+XLyom88zS0u84nnIcLvFnRUMRRHmrGny5GSM+kNO9IZLARsdQHDzkhAgmrQ==} @@ -3372,6 +3495,44 @@ packages: '@upsetjs/venn.js@2.0.0': resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + '@vitest/coverage-v8@4.1.6': + resolution: {integrity: sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==} + peerDependencies: + '@vitest/browser': 4.1.6 + vitest: 4.1.6 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.1.6': + resolution: {integrity: sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==} + + '@vitest/mocker@4.1.6': + resolution: {integrity: sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.6': + resolution: {integrity: sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==} + + '@vitest/runner@4.1.6': + resolution: {integrity: sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==} + + '@vitest/snapshot@4.1.6': + resolution: {integrity: sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==} + + '@vitest/spy@4.1.6': + resolution: {integrity: sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==} + + '@vitest/utils@4.1.6': + resolution: {integrity: sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==} + '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -3580,9 +3741,16 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + ast-v8-to-istanbul@1.0.0: + resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==} + async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} @@ -3679,6 +3847,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@1.1.3: resolution: {integrity: sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==} engines: {node: '>=0.10.0'} @@ -4335,6 +4507,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -4349,6 +4524,10 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -4853,6 +5032,18 @@ packages: isomorphic.js@0.2.5: resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + iterator.prototype@1.1.5: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} @@ -4947,6 +5138,9 @@ packages: resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} engines: {node: '>=14'} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -5080,6 +5274,80 @@ packages: engines: {node: '>=16'} hasBin: true + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -5147,6 +5415,16 @@ packages: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + make-error@1.3.6: resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} @@ -5422,6 +5700,9 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -5902,6 +6183,11 @@ packages: robust-predicates@3.0.3: resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + rolldown@1.0.0: + resolution: {integrity: sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + roughjs@4.6.6: resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} @@ -5999,6 +6285,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -6031,6 +6320,12 @@ packages: stable-hash@0.0.6: resolution: {integrity: sha512-0afH4mobqTybYZsXImQRLOjHV4gvOW+92HdUIax9t7a8d9v54KWykEuMVIcXhD9BCi+w3kS4x7O6fmZQ3JlG/g==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -6243,6 +6538,9 @@ packages: tiny-warning@1.0.3: resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@1.1.2: resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} engines: {node: '>=18'} @@ -6251,6 +6549,10 @@ packages: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -6435,6 +6737,90 @@ packages: victory-vendor@37.3.6: resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + vite@8.0.12: + resolution: {integrity: sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.6: + resolution: {integrity: sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.6 + '@vitest/browser-preview': 4.1.6 + '@vitest/browser-webdriverio': 4.1.6 + '@vitest/coverage-istanbul': 4.1.6 + '@vitest/coverage-v8': 4.1.6 + '@vitest/ui': 4.1.6 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vscode-jsonrpc@8.2.0: resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} engines: {node: '>=14.0.0'} @@ -6512,6 +6898,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -6763,6 +7154,8 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@bcoe/v8-coverage@1.0.2': {} + '@braintree/sanitize-url@7.1.2': {} '@chevrotain/cst-dts-gen@12.0.0': @@ -7419,6 +7812,13 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + '@next/bundle-analyzer@15.5.18': dependencies: webpack-bundle-analyzer: 4.10.1 @@ -7574,6 +7974,8 @@ snapshots: '@opentelemetry/semantic-conventions@1.40.0': {} + '@oxc-project/types@0.129.0': {} + '@phosphor-icons/react@2.1.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: react: 19.2.6 @@ -7999,6 +8401,57 @@ snapshots: react: 19.2.6 react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1) + '@rolldown/binding-android-arm64@1.0.0': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0': + optional: true + + '@rolldown/pluginutils@1.0.0': {} + '@rtsao/scc@1.1.0': {} '@rushstack/eslint-patch@1.16.1': {} @@ -8217,6 +8670,11 @@ snapshots: tslib: 2.8.1 optional: true + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/d3-array@3.2.2': {} '@types/d3-axis@3.0.6': @@ -8334,6 +8792,8 @@ snapshots: '@types/d3-transition': 3.0.9 '@types/d3-zoom': 3.0.8 + '@types/deep-eql@4.0.2': {} + '@types/diff@5.2.3': {} '@types/eslint-scope@3.7.7': @@ -8620,6 +9080,61 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) + '@vitest/coverage-v8@4.1.6(vitest@4.1.6)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.6 + ast-v8-to-istanbul: 1.0.0 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.1 + std-env: 4.1.0 + tinyrainbow: 3.1.0 + vitest: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.6)(vite@8.0.12(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.47.0)(tsx@4.21.0)(yaml@2.8.4)) + + '@vitest/expect@4.1.6': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.6(vite@8.0.12(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.47.0)(tsx@4.21.0)(yaml@2.8.4))': + dependencies: + '@vitest/spy': 4.1.6 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.12(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.47.0)(tsx@4.21.0)(yaml@2.8.4) + + '@vitest/pretty-format@4.1.6': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.6': + dependencies: + '@vitest/utils': 4.1.6 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.6': + dependencies: + '@vitest/pretty-format': 4.1.6 + '@vitest/utils': 4.1.6 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.6': {} + + '@vitest/utils@4.1.6': + dependencies: + '@vitest/pretty-format': 4.1.6 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + '@webassemblyjs/ast@1.14.1': dependencies: '@webassemblyjs/helper-numbers': 1.13.2 @@ -8913,8 +9428,16 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + assertion-error@2.0.1: {} + ast-types-flow@0.0.8: {} + ast-v8-to-istanbul@1.0.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + async-function@1.0.0: {} asynckit@0.4.0: {} @@ -9009,6 +9532,8 @@ snapshots: ccount@2.0.1: {} + chai@6.2.2: {} + chalk@1.1.3: dependencies: ansi-styles: 2.2.1 @@ -9391,8 +9916,7 @@ snapshots: dequal@2.0.3: {} - detect-libc@2.1.2: - optional: true + detect-libc@2.1.2: {} devlop@1.1.0: dependencies: @@ -9877,6 +10401,10 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + esutils@2.0.3: {} eventemitter3@4.0.7: {} @@ -9885,6 +10413,8 @@ snapshots: events@3.3.0: {} + expect-type@1.3.0: {} + fast-deep-equal@3.1.3: {} fast-diff@1.3.0: {} @@ -10381,6 +10911,19 @@ snapshots: isomorphic.js@0.2.5: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 @@ -10464,6 +11007,8 @@ snapshots: js-cookie@3.0.5: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-yaml@4.1.1: @@ -10635,6 +11180,55 @@ snapshots: dependencies: isomorphic.js: 0.2.5 + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -10689,6 +11283,20 @@ snapshots: lz-string@1.5.0: {} + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.0 + make-error@1.3.6: {} marked@16.4.2: {} @@ -10968,6 +11576,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + obug@2.1.1: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -11500,6 +12110,27 @@ snapshots: robust-predicates@3.0.3: {} + rolldown@1.0.0: + dependencies: + '@oxc-project/types': 0.129.0 + '@rolldown/pluginutils': 1.0.0 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0 + '@rolldown/binding-darwin-arm64': 1.0.0 + '@rolldown/binding-darwin-x64': 1.0.0 + '@rolldown/binding-freebsd-x64': 1.0.0 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0 + '@rolldown/binding-linux-arm64-gnu': 1.0.0 + '@rolldown/binding-linux-arm64-musl': 1.0.0 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0 + '@rolldown/binding-linux-s390x-gnu': 1.0.0 + '@rolldown/binding-linux-x64-gnu': 1.0.0 + '@rolldown/binding-linux-x64-musl': 1.0.0 + '@rolldown/binding-openharmony-arm64': 1.0.0 + '@rolldown/binding-wasm32-wasi': 1.0.0 + '@rolldown/binding-win32-arm64-msvc': 1.0.0 + '@rolldown/binding-win32-x64-msvc': 1.0.0 + roughjs@4.6.6: dependencies: hachure-fill: 0.5.2 @@ -11656,6 +12287,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@4.1.0: {} sirv@2.0.4: @@ -11681,6 +12314,10 @@ snapshots: stable-hash@0.0.6: {} + stackback@0.0.2: {} + + std-env@4.1.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -11933,6 +12570,8 @@ snapshots: tiny-warning@1.0.3: {} + tinybench@2.9.0: {} + tinyexec@1.1.2: {} tinyglobby@0.2.16: @@ -11940,6 +12579,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyrainbow@3.1.0: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -12201,6 +12842,51 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 + vite@8.0.12(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.47.0)(tsx@4.21.0)(yaml@2.8.4): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.14 + rolldown: 1.0.0 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 20.19.39 + esbuild: 0.27.7 + fsevents: 2.3.3 + jiti: 2.7.0 + terser: 5.47.0 + tsx: 4.21.0 + yaml: 2.8.4 + + vitest@4.1.6(@opentelemetry/api@1.9.1)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.6)(vite@8.0.12(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.47.0)(tsx@4.21.0)(yaml@2.8.4)): + dependencies: + '@vitest/expect': 4.1.6 + '@vitest/mocker': 4.1.6(vite@8.0.12(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.47.0)(tsx@4.21.0)(yaml@2.8.4)) + '@vitest/pretty-format': 4.1.6 + '@vitest/runner': 4.1.6 + '@vitest/snapshot': 4.1.6 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.2 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 8.0.12(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.47.0)(tsx@4.21.0)(yaml@2.8.4) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 20.19.39 + '@vitest/coverage-v8': 4.1.6(vitest@4.1.6) + transitivePeerDependencies: + - msw + vscode-jsonrpc@8.2.0: {} vscode-languageserver-protocol@3.17.5: @@ -12337,6 +13023,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} wrap-ansi@7.0.0: