From f36acdc6b9b9a1fa202002f3dfb13a90f2f76ecd Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Jul 2026 23:46:33 -0400 Subject: [PATCH] chore(quark): sync tests with standalone --- packages/quark/test/keyed.test.ts | 226 +++++++++++++++----- packages/quark/test/layout.test.ts | 320 +++++++++++++++++++++++++++-- packages/quark/test/solid.test.ts | 85 ++++++++ 3 files changed, 563 insertions(+), 68 deletions(-) create mode 100644 packages/quark/test/solid.test.ts diff --git a/packages/quark/test/keyed.test.ts b/packages/quark/test/keyed.test.ts index 91bed6d37f..7fcd839e42 100644 --- a/packages/quark/test/keyed.test.ts +++ b/packages/quark/test/keyed.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from "bun:test" -import { Computed, Keyed } from "../src" +import { Keyed } from "../src/keyed" +import { Computed } from "../src/reactivity" -type Item = { +interface Item { readonly id: number readonly label: string } @@ -9,108 +10,238 @@ type Item = { const item = (id: number, label: string): Item => ({ id, label }) describe("Keyed", () => { - it("keeps slots stable while publishing value and structural changes separately", () => { + it("keeps slot identity across value updates and reorders", () => { + const keyed = Keyed.make({ key: (value) => value.id }) + keyed.set([item(1, "one"), item(2, "two"), item(3, "three")]) + const [one, two, three] = keyed.slots() + + keyed.set([item(3, "THREE"), item(1, "ONE"), item(2, "TWO")]) + + expect(keyed.slots()).toEqual([three, one, two]) + expect(keyed.slots()[0]).toBe(three) + expect(keyed.slots()[1]).toBe(one) + expect(keyed.slots()[2]).toBe(two) + expect(one()).toEqual(item(1, "ONE")) + expect(two()).toEqual(item(2, "TWO")) + expect(three()).toEqual(item(3, "THREE")) + }) + + it("does not publish structural changes for value-only updates or identical sets", () => { const keyed = Keyed.make({ key: (value) => value.id }) keyed.set([item(1, "one"), item(2, "two")]) - const [one, two] = keyed.slots() - const structure = keyed.slots() - const structures: number[][] = [] - const values: Item[][] = [] - const disposeSlots = keyed.slots.subscribe((slots) => structures.push(slots.map((slot) => slot().id))) - const disposeValues = keyed.values.subscribe((next) => values.push([...next])) + const initial = keyed.slots() + const structures: Array = [] + const dispose = keyed.slots.subscribe((slots) => structures.push(slots.map((slot) => slot().id))) keyed.set([item(1, "ONE"), item(2, "TWO")]) + expect(keyed.slots()).toBe(initial) - expect(keyed.slots()).toBe(structure) - expect(keyed.slots()).toEqual([one, two]) - expect(values).toEqual([[item(1, "ONE"), item(2, "TWO")]]) - expect(structures).toEqual([]) + keyed.set([item(1, "ONE"), item(2, "TWO")]) + expect(keyed.slots()).toBe(initial) keyed.set([item(2, "TWO"), item(1, "ONE")]) - expect(keyed.slots()).toEqual([two, one]) expect(structures).toEqual([[2, 1]]) - disposeSlots() - disposeValues() + dispose() + }) + + it("reacts to aggregate value updates while preserving unchanged aggregate snapshots", () => { + const keyed = Keyed.make({ key: (value) => value.id }) + const one = item(1, "one") + const two = item(2, "two") + keyed.set([one, two]) + const initial = keyed.values() + const snapshots: Array = [] + const dispose = keyed.values.subscribe((values) => snapshots.push(values)) + + keyed.set([one, item(2, "TWO")]) + const updated = keyed.values() + expect(updated).not.toBe(initial) + expect(updated).toEqual([one, item(2, "TWO")]) + + keyed.set([one, updated[1]]) + + expect(keyed.values()).toBe(updated) + expect(snapshots).toEqual([[one, item(2, "TWO")]]) + dispose() }) it("uses custom equivalence to cut off slot and aggregate updates", () => { const keyed = Keyed.make({ key: (value) => value.id, - equivalent: (left, right) => left.label.toLowerCase() === right.label.toLowerCase(), + equivalent: (left, right) => left.id === right.id && left.label.toLowerCase() === right.label.toLowerCase(), }) const original = item(1, "one") keyed.set([original]) const slot = keyed.slots()[0] const aggregate = keyed.values() + const slotValues: Item[] = [] + const aggregateValues: Array = [] + const disposeSlot = slot.subscribe((value) => slotValues.push(value)) + const disposeAggregate = keyed.values.subscribe((values) => aggregateValues.push(values)) keyed.set([item(1, "ONE")]) expect(slot()).toBe(original) expect(keyed.values()).toBe(aggregate) + expect(slotValues).toEqual([]) + expect(aggregateValues).toEqual([]) + + keyed.set([item(1, "changed")]) + + expect(slot()).toEqual(item(1, "changed")) + expect(slotValues).toEqual([item(1, "changed")]) + expect(aggregateValues).toEqual([[item(1, "changed")]]) + disposeSlot() + disposeAggregate() }) - it("creates a fresh slot after removal and reinsertion", () => { + it("uses SameValueZero semantics for primitive slot values", () => { + const keyed = Keyed.make({ key: () => "value" }) + keyed.set([-0]) + + expect(keyed.update(0)).toBe(false) + expect(Object.is(keyed.get("value")?.(), -0)).toBe(true) + }) + + it("creates slots for inserts and fresh slots after remove and reinsert", () => { const keyed = Keyed.make({ key: (value) => value.id }) - keyed.set([item(1, "one"), item(2, "two")]) - const [removed, retained] = keyed.slots() + keyed.set([item(1, "one")]) + const removed = keyed.slots()[0] + const removedValues: Item[] = [] + const dispose = removed.subscribe((value) => removedValues.push(value)) keyed.set([item(2, "two")]) - keyed.set([item(1, "new"), item(2, "TWO")]) + const inserted = keyed.slots()[0] + keyed.set([item(1, "new one"), item(2, "new two")]) + const [reinserted, retained] = keyed.slots() - expect(keyed.slots()[0]).not.toBe(removed) - expect(keyed.slots()[1]).toBe(retained) - expect(retained()).toEqual(item(2, "TWO")) + expect(reinserted).not.toBe(removed) + expect(reinserted()).toEqual(item(1, "new one")) + expect(retained).toBe(inserted) + expect(retained()).toEqual(item(2, "new two")) + expect(removed()).toEqual(item(1, "one")) + expect(removedValues).toEqual([]) + dispose() }) - it("rejects duplicate keys without partially updating", () => { + it("rejects duplicate keys before making any partial update", () => { const keyed = Keyed.make({ key: (value) => value.id }) keyed.set([item(1, "one"), item(2, "two")]) const slots = keyed.slots() const values = keyed.values() + const notifications: string[] = [] + const disposeOne = slots[0].subscribe(() => notifications.push("one")) + const disposeTwo = slots[1].subscribe(() => notifications.push("two")) + const disposeSlots = keyed.slots.subscribe(() => notifications.push("slots")) + const disposeValues = keyed.values.subscribe(() => notifications.push("values")) expect(() => keyed.set([item(1, "changed"), item(1, "duplicate")])).toThrow("Keyed values must have unique keys") + expect(keyed.slots()).toBe(slots) expect(keyed.values()).toBe(values) + expect(keyed.values()).toEqual([item(1, "one"), item(2, "two")]) + expect(notifications).toEqual([]) + disposeOne() + disposeTwo() + disposeSlots() + disposeValues() }) - it("uses SameValueZero key equality", () => { + it("uses SameValueZero equality for zero keys", () => { const keyed = Keyed.make({ key: (value) => value.id }) - keyed.set([item(-0, "zero"), item(Number.NaN, "nan")]) - const [zero, nan] = keyed.slots() + keyed.set([item(-0, "negative zero")]) + const zero = keyed.slots()[0] - keyed.set([item(0, "ZERO"), item(Number.NaN, "NAN")]) + keyed.set([item(0, "zero")]) - expect(keyed.slots()).toEqual([zero, nan]) - expect(() => keyed.set([item(0, "zero"), item(-0, "duplicate")])).toThrow("Keyed values must have unique keys") - expect(() => keyed.set([item(Number.NaN, "nan"), item(Number.NaN, "duplicate")])).toThrow( + expect(keyed.slots()[0]).toBe(zero) + expect(zero()).toEqual(item(0, "zero")) + expect(() => keyed.set([item(0, "zero"), item(-0, "negative zero")])).toThrow("Keyed values must have unique keys") + expect(keyed.slots()).toEqual([zero]) + expect(keyed.values()).toEqual([item(0, "zero")]) + }) + + it("uses SameValueZero equality for NaN keys", () => { + const keyed = Keyed.make({ key: (value) => value.id }) + keyed.set([item(Number.NaN, "first")]) + const nan = keyed.slots()[0] + + keyed.set([item(Number.NaN, "updated")]) + + expect(keyed.slots()[0]).toBe(nan) + expect(nan()).toEqual(item(Number.NaN, "updated")) + expect(() => keyed.set([item(Number.NaN, "one"), item(Number.NaN, "two")])).toThrow( "Keyed values must have unique keys", ) + expect(keyed.slots()).toEqual([nan]) + expect(keyed.values()).toEqual([item(Number.NaN, "updated")]) }) - it("publishes one settled aggregate when values and structure change together", () => { + it("publishes one glitch-free aggregate when slots and structure change together", () => { const keyed = Keyed.make({ key: (value) => value.id }) - keyed.set([item(1, "one"), item(2, "two")]) + keyed.set([item(1, "one"), item(2, "two"), item(3, "three")]) const observations: string[] = [] const summary = Computed.make(() => { - const slots = keyed + const slotKeys = keyed .slots() - .map((slot) => `${slot().id}:${slot().label}`) + .map((slot) => slot().id) .join(",") const values = keyed .values() .map((value) => `${value.id}:${value.label}`) .join(",") - return `${slots}|${values}` + return `${slotKeys}|${values}` }) const dispose = summary.subscribe((value) => observations.push(value)) - keyed.set([item(2, "TWO"), item(3, "three")]) + keyed.set([item(3, "THREE"), item(2, "TWO"), item(4, "four")]) - expect(observations).toEqual(["2:TWO,3:three|2:TWO,3:three"]) + expect(observations).toEqual(["3,2,4|3:THREE,2:TWO,4:four"]) dispose() }) + it("stops slot, structural, and aggregate notifications after disposal", () => { + const keyed = Keyed.make({ key: (value) => value.id }) + keyed.set([item(1, "one")]) + const notifications: string[] = [] + const disposeSlot = keyed.slots()[0].subscribe(() => notifications.push("slot")) + const disposeSlots = keyed.slots.subscribe(() => notifications.push("slots")) + const disposeValues = keyed.values.subscribe(() => notifications.push("values")) + + disposeSlot() + disposeSlots() + disposeValues() + keyed.set([item(2, "two")]) + keyed.set([item(2, "TWO")]) + + expect(notifications).toEqual([]) + }) + + it("supports empty sets and repeated clears without redundant publication", () => { + const keyed = Keyed.make({ key: (value) => value.id }) + expect(keyed.slots()).toEqual([]) + expect(keyed.values()).toEqual([]) + const structures: Array = [] + const aggregates: Array = [] + const disposeSlots = keyed.slots.subscribe((slots) => structures.push(slots)) + const disposeValues = keyed.values.subscribe((values) => aggregates.push(values)) + + keyed.set([]) + keyed.set([item(1, "one")]) + keyed.set([]) + const clearedSlots = keyed.slots() + const clearedValues = keyed.values() + keyed.set([]) + + expect(keyed.slots()).toBe(clearedSlots) + expect(keyed.values()).toBe(clearedValues) + expect(structures.map((slots) => slots.length)).toEqual([1, 0]) + expect(aggregates).toEqual([[item(1, "one")], []]) + disposeSlots() + disposeValues() + }) + it("updates one existing slot without publishing structure", () => { const keyed = Keyed.make({ key: (value) => value.id }) keyed.set([item(1, "one"), item(2, "two")]) @@ -182,6 +313,9 @@ describe("Keyed", () => { expect(keyed.remove(2)).toBe(true) expect(keyed.remove(2)).toBe(false) expect(keyed.slots()).toEqual([one, four, three]) + expect(() => keyed.insert(item(1, "duplicate"))).toThrow("Keyed value already exists: 1") + expect(() => keyed.insert(item(2, "two"), { before: 5 })).toThrow("Keyed value does not exist: 5") + expect(keyed.move(5)).toBe(false) }) it("counts publications and equivalence suppressions when instrumented", () => { @@ -202,18 +336,4 @@ describe("Keyed", () => { equivalenceSuppressions: 1, }) }) - - it("publishes nothing for a full unchanged rebuild", () => { - const metrics = Keyed.metrics() - const keyed = Keyed.make({ key: (value) => value.id, metrics }) - const values = [item(1, "one"), item(2, "two")] - keyed.set(values) - const before = { ...metrics } - - keyed.set(values) - - expect(metrics.slotPublications).toBe(before.slotPublications) - expect(metrics.structuralPublications).toBe(before.structuralPublications) - expect(metrics.equivalenceSuppressions).toBe(before.equivalenceSuppressions + values.length) - }) }) diff --git a/packages/quark/test/layout.test.ts b/packages/quark/test/layout.test.ts index 53a70b33c3..91e5f66ad6 100644 --- a/packages/quark/test/layout.test.ts +++ b/packages/quark/test/layout.test.ts @@ -13,8 +13,8 @@ const Job = Layout.struct({ }) const Jobs = Layout.collection(Job, ({ members, first }) => ({ - labels: members((job) => job.labels), - nextRetry: first((job) => job.status === "retrying"), + labels: members(["labels"], (job) => job.labels), + nextRetry: first(["status"], (job) => job.status === "retrying"), })) describe("Layout", () => { @@ -37,12 +37,17 @@ describe("Layout", () => { "Keyed layout must declare exactly one key field", ) expect(() => - Layout.compile(Layout.struct({ left: Layout.key(Layout.number), right: Layout.key(Layout.number) })), + Layout.compile( + Layout.struct({ + left: Layout.key(Layout.number), + right: Layout.key(Layout.number), + }), + ), ).toThrow("Keyed layout must declare exactly one key field") }) it("generates the same trusted equivalence as the closure backend", () => { - const closure = Layout.compile(Item) + const closure = Layout.compile(Item, { backend: "closure" }) const generated = Layout.compile(Item, { backend: "generated" }) const values = [ { id: 1, label: "one" }, @@ -53,12 +58,70 @@ describe("Layout", () => { values.forEach((left) => { values.forEach((right) => { expect(generated.equivalent(left, right)).toBe(closure.equivalent(left, right)) + expect(generated.diff(left, right) === 0).toBe(generated.equivalent(left, right)) + expect(closure.diff(left, right) === 0).toBe(closure.equivalent(left, right)) }) }) }) + it("honors custom primitive comparators in generated plans", () => { + const CaseInsensitive = { + ...Layout.string, + foldCase: true, + equivalent(left: string, right: string) { + return this.foldCase ? left.toLowerCase() === right.toLowerCase() : left === right + }, + } + const Value = Layout.struct({ id: Layout.key(Layout.number), value: CaseInsensitive }) + const closure = Layout.compile(Value, { backend: "closure" }) + const generated = Layout.compile(Value, { backend: "generated" }) + const left = { id: 1, value: "same" } + const right = { id: 1, value: "SAME" } + + expect(closure.equivalent(left, right)).toBe(true) + expect(generated.equivalent(left, right)).toBe(true) + expect(generated.diff(left, right)).toBe(0) + }) + + it("keeps generated nested keyed unions independent when they share variants", () => { + const variants = { + yes: Layout.struct({ value: Layout.string }), + no: Layout.struct({ value: Layout.string }), + } + const Left = Layout.keyedUnion({ + key: Layout.key("leftID", Layout.number), + tag: "leftType", + variants, + }) + const Right = Layout.keyedUnion({ + key: Layout.key("rightID", Layout.number), + tag: "rightType", + variants, + }) + const Root = Layout.struct({ + id: Layout.key(Layout.number), + left: Left, + right: Right, + }) + const value: Layout.Type = { + id: 1, + left: { leftID: 1, leftType: "yes", value: "same" }, + right: { rightID: 1, rightType: "yes", value: "same" }, + } + const copy = structuredClone(value) + const closure = Layout.compile(Root, { backend: "closure" }) + const generated = Layout.compile(Root, { backend: "generated" }) + + expect(closure.equivalent(value, copy)).toBe(true) + expect(generated.equivalent(value, copy)).toBe(true) + expect(generated.diff(value, copy)).toBe(0) + }) + it("compiles nested discriminated unions and skips immutable fields", () => { - const Ref = Layout.struct({ messageID: Layout.string, partID: Layout.string }) + const Ref = Layout.struct({ + messageID: Layout.string, + partID: Layout.string, + }) const Row = Layout.keyedUnion({ key: Layout.key("id", Layout.string), tag: "type", @@ -82,7 +145,7 @@ describe("Layout", () => { }), }, }) - const plan = Layout.compile(Row) + const plan = Layout.compile(Row, { backend: "closure" }) const generated = Layout.compile(Row, { backend: "generated" }) const group = { id: "group-1", @@ -94,9 +157,19 @@ describe("Layout", () => { completed: false, } - expect(plan.equivalent(group, { ...group, origin: { messageID: "ignored", partID: "ignored" } })).toBe(true) + expect( + plan.equivalent(group, { + ...group, + origin: { messageID: "ignored", partID: "ignored" }, + }), + ).toBe(true) expect(plan.equivalent(group, { ...group, completed: true })).toBe(false) - expect(plan.equivalent(group, { ...group, pending: [{ messageID: "assistant-1", partID: "read-1" }] })).toBe(false) + expect( + plan.equivalent(group, { + ...group, + pending: [{ messageID: "assistant-1", partID: "read-1" }], + }), + ).toBe(false) expect( plan.equivalent(group, { id: "group-1", @@ -127,8 +200,14 @@ describe("Layout", () => { }) it("includes nested keys in structural equivalence", () => { - const Child = Layout.struct({ id: Layout.key(Layout.number), value: Layout.string }) - const Parent = Layout.struct({ id: Layout.key(Layout.number), child: Child }) + const Child = Layout.struct({ + id: Layout.key(Layout.number), + value: Layout.string, + }) + const Parent = Layout.struct({ + id: Layout.key(Layout.number), + child: Child, + }) const parent = Layout.compile(Parent) expect( @@ -184,6 +263,158 @@ describe("Layout", () => { expect(jobs.first("nextRetry")?.().id).toBe("four") }) + it("skips index projection when the change is disjoint from its declared fields", () => { + let extractions = 0 + let matchChecks = 0 + const IndexedJobs = Layout.collection(Job, ({ members, first }) => ({ + labels: members(["labels"], (job) => { + extractions++ + return job.labels + }), + nextRetry: first(["status"], (job) => { + matchChecks++ + return job.status === "retrying" + }), + })) + const jobs = IndexedJobs.make([{ id: "one", labels: ["billing"], status: "running" }]) + const labels = jobs.get("one")!().labels + const baseline = { extractions, matchChecks } + + // Status-only change: labels extraction reads only `labels`, so it skips. + jobs.update({ id: "one", labels, status: "retrying" }) + expect(extractions).toBe(baseline.extractions) + expect(matchChecks).toBe(baseline.matchChecks + 1) + expect(jobs.first("nextRetry")?.().id).toBe("one") + + // Labels-only change: the first-match check reads only `status`, so it skips. + jobs.update({ id: "one", labels: ["urgent"], status: "retrying" }) + expect(extractions).toBe(baseline.extractions + 1) + expect(matchChecks).toBe(baseline.matchChecks + 1) + expect(jobs.hasMember("labels", "urgent")).toBe(true) + expect(jobs.hasMember("labels", "billing")).toBe(false) + + // Equivalent update: nothing runs. + jobs.update({ id: "one", labels: ["urgent"], status: "retrying" }) + expect(extractions).toBe(baseline.extractions + 1) + expect(matchChecks).toBe(baseline.matchChecks + 1) + }) + + it("normalizes numeric dependency names", () => { + let extractions = 0 + const Numeric = Layout.collection( + Layout.struct({ id: Layout.key(Layout.string), 0: Layout.string, other: Layout.string }), + ({ members }) => ({ + zero: members([0], (value) => { + extractions++ + return [value[0]] + }), + }), + ) + const values = Numeric.make([{ id: "one", 0: "zero", other: "before" }]) + const baseline = extractions + + values.update({ id: "one", 0: "zero", other: "after" }) + + expect(extractions).toBe(baseline) + expect(values.hasMember("zero", "zero")).toBe(true) + }) + + it("reuses precomputed field diffs during direct collection mutations", () => { + let comparisons = 0 + const counted: Layout.Field = { + equivalent(left, right) { + comparisons++ + return left === right + }, + } + const CountedJob = Layout.struct({ + id: Layout.key(Layout.string), + value: counted, + status: Layout.string, + }) + const CountedJobs = Layout.collection(CountedJob, ({ first }) => ({ + changed: first(["value"], (job) => job.value === "after"), + })) + const one = { id: "one", value: "before", status: "idle" } + const two = { id: "two", value: "before", status: "idle" } + const jobs = CountedJobs.make([one, two]) + comparisons = 0 + + jobs.set([ + { ...one }, + { ...two, value: "after" }, + ]) + + expect(comparisons).toBe(4) + expect(jobs.get("one")?.()).toBe(one) + expect(jobs.first("changed")?.().id).toBe("two") + + comparisons = 0 + jobs.update({ ...jobs.get("one")!(), status: "busy" }) + expect(comparisons).toBe(1) + + comparisons = 0 + jobs.modify("two", (job) => ({ ...job, value: "done" })) + expect(comparisons).toBe(1) + expect(jobs.first("changed")).toBeUndefined() + }) + + it("refreshes indexes that read a changed union discriminant", () => { + const Row = Layout.keyedUnion({ + key: Layout.key("id", Layout.number), + tag: "type", + variants: { + waiting: Layout.struct({ value: Layout.string }), + ready: Layout.struct({ value: Layout.string }), + }, + }) + const Rows = Layout.collection(Row, ({ members, first }) => ({ + types: members(["type"], (row) => [row.type]), + firstReady: first(["type"], (row) => row.type === "ready"), + })) + const rows = Rows.make([{ id: 1, type: "waiting", value: "same" }]) + + rows.update({ id: 1, type: "ready", value: "same" }) + + expect(rows.get(1)?.().type).toBe("ready") + expect(rows.hasMember("types", "waiting")).toBe(false) + expect(rows.hasMember("types", "ready")).toBe(true) + expect(rows.first("firstReady")?.().type).toBe("ready") + }) + + it("tracks lazy member iterables while they are consumed", () => { + const LazyJobs = Layout.collection(Job, ({ members }) => ({ + statuses: members(function* (job) { + yield job.status + }), + })) + const jobs = LazyJobs.make([{ id: "one", labels: [], status: "running" }]) + + expect(jobs.hasMember("statuses", "running")).toBe(true) + jobs.update({ id: "one", labels: [], status: "retrying" }) + expect(jobs.hasMember("statuses", "running")).toBe(false) + expect(jobs.hasMember("statuses", "retrying")).toBe(true) + }) + + it("passes actual frozen values to identity and reflection projections", () => { + const actual = Object.freeze({ id: "one", labels: [] as readonly string[], status: "running" }) + const accepted = new WeakSet([actual]) + const EnumeratedJobs = Layout.collection(Job, ({ members, first }) => ({ + properties: members((job) => Object.keys(job)), + selves: members((job) => [job]), + frozen: members((job) => [Object.isFrozen(job)]), + accepted: first((job) => accepted.has(job)), + })) + const jobs = EnumeratedJobs.make([actual]) + + expect(jobs.hasMember("properties", "id")).toBe(true) + expect(jobs.hasMember("properties", "labels")).toBe(true) + expect(jobs.hasMember("properties", "status")).toBe(true) + expect(jobs.hasMember("selves", actual)).toBe(true) + expect(jobs.hasMember("frozen", true)).toBe(true) + expect(jobs.first("accepted")?.()).toBe(actual) + }) + it("applies explicit member deltas without re-extracting unchanged membership", () => { let extractions = 0 const IndexedJobs = Layout.collection(Job, ({ members }) => ({ @@ -206,12 +437,20 @@ describe("Layout", () => { expect(jobs.hasMember("labels", "billing")).toBe(true) expect(jobs.hasMember("labels", "urgent")).toBe(true) - jobs.modify("two", (job) => ({ ...job, labels: [] }), { members: { labels: { remove: ["billing"] } } }) + jobs.modify("two", (job) => ({ ...job, labels: [] }), { + members: { labels: { remove: ["billing"] } }, + }) expect(jobs.hasMember("labels", "billing")).toBe(false) if (false) { - // @ts-expect-error Member deltas require arrays so string members are not split into characters. - jobs.modify("one", (job) => job, { members: { labels: { add: "urgent" } } }) + jobs.modify("one", (job) => job, { + members: { + labels: { + // @ts-expect-error Member deltas require arrays so strings are not split into characters. + add: "urgent", + }, + }, + }) } }) @@ -238,16 +477,67 @@ describe("Layout", () => { ) }) + it("restores pending comparison state when publication throws", () => { + const LabeledJob = Layout.struct({ + id: Layout.key(Layout.string), + label: Layout.string, + status: Layout.string, + }) + const IndexedJobs = Layout.collection(LabeledJob, ({ first }) => ({ + retry: first(["status"], (job) => job.status === "retrying"), + })) + const jobs = IndexedJobs.make([{ id: "one", label: "before", status: "running" }]) + const slot = jobs.get("one")! + const dispose = slot.subscribe(() => { + throw new Error("listener failed") + }) + + expect(() => jobs.update({ id: "one", label: "after", status: "running" })).toThrow("listener failed") + dispose() + const equivalent = { ...slot() } + + expect(jobs.set([equivalent])).toBe(false) + expect(slot()).not.toBe(equivalent) + }) + + it("does not leak a pending comparison into reentrant publication", () => { + const LabeledJob = Layout.struct({ + id: Layout.key(Layout.string), + label: Layout.string, + status: Layout.string, + }) + const IndexedJobs = Layout.collection(LabeledJob, ({ first }) => ({ + retry: first(["status"], (job) => job.status === "retrying"), + })) + const jobs = IndexedJobs.make([{ id: "one", label: "before", status: "running" }]) + const slot = jobs.get("one")! + let reentered = false + let changed: boolean | undefined + const dispose = slot.subscribe(() => { + if (reentered) return + reentered = true + changed = jobs.set([{ ...slot() }]) + }) + + jobs.update({ id: "one", label: "after", status: "running" }) + + expect(changed).toBe(false) + dispose() + }) + it("tracks first matches whose key is undefined", () => { const undefinedField: Layout.Field = { equivalent: Object.is } const OptionalJobs = Layout.collection( Layout.struct({ id: Layout.key(undefinedField), status: Layout.string }), - ({ first }) => ({ retry: first((job) => job.status === "retrying") }), + ({ first }) => ({ retry: first(["status"], (job) => job.status === "retrying") }), ) const jobs = OptionalJobs.make([{ id: undefined, status: "running" }]) jobs.update({ id: undefined, status: "retrying" }) - expect(jobs.first("retry")?.()).toEqual({ id: undefined, status: "retrying" }) + expect(jobs.first("retry")?.()).toEqual({ + id: undefined, + status: "retrying", + }) }) }) diff --git a/packages/quark/test/solid.test.ts b/packages/quark/test/solid.test.ts new file mode 100644 index 0000000000..37ee263444 --- /dev/null +++ b/packages/quark/test/solid.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "bun:test" +import { createComputed, createRoot, createSignal } from "solid-js" +import { Computed, Keyed, State, Transaction, type Readable } from "../src" +import { useSlot, useValue } from "../src/solid" + +describe("Solid adapter", () => { + it("bridges Quark values into Solid ownership", () => { + const left = State.make(1) + const right = State.make(2) + const total = Computed.make(() => left() + right()) + const values: number[] = [] + + createRoot((dispose) => { + const value = useValue(total) + createComputed(() => values.push(value())) + + Transaction.run(() => { + left.set(2) + right.set(3) + }) + dispose() + }) + + left.set(4) + expect(values).toEqual([3, 5]) + }) + + it("preserves function values", () => { + const first = () => 1 + const second = () => 2 + const state = State.make(first) + let value = first + + createRoot((dispose) => { + const current = useValue(state) + createComputed(() => value = current()) + state.set(second) + dispose() + }) + + expect(value).toBe(second) + }) + + it("switches keyed slots and releases obsolete subscriptions", () => { + const keyed = Keyed.make<{ readonly id: number; readonly value: string }, number>({ key: (value) => value.id }) + keyed.set([ + { id: 1, value: "one" }, + { id: 2, value: "two" }, + ]) + const active = [0, 0] + track(keyed.get(1)!, 0) + track(keyed.get(2)!, 1) + const values: Array = [] + + createRoot((dispose) => { + const [key, setKey] = createSignal(1) + const value = useSlot(keyed, key) + createComputed(() => values.push(value()?.value)) + + keyed.modify(1, (item) => ({ ...item, value: "ONE" })) + keyed.insert({ id: 3, value: "three" }) + setKey(2) + keyed.modify(1, (item) => ({ ...item, value: "ignored" })) + keyed.modify(2, (item) => ({ ...item, value: "TWO" })) + + expect(active).toEqual([0, 1]) + dispose() + }) + + expect(active).toEqual([0, 0]) + expect(values).toEqual(["one", "ONE", "two", "TWO"]) + + function track(slot: Readable<{ readonly id: number; readonly value: string }>, index: number) { + const subscribe = slot.subscribe + slot.subscribe = (listener) => { + active[index]++ + const dispose = subscribe(listener) + return () => { + active[index]-- + dispose() + } + } + } + }) +})