chore(quark): sync tests with standalone
This commit is contained in:
parent
2a8ad383ea
commit
f36acdc6b9
3 changed files with 564 additions and 69 deletions
|
|
@ -1,7 +1,8 @@
|
||||||
import { describe, expect, it } from "bun:test"
|
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 id: number
|
||||||
readonly label: string
|
readonly label: string
|
||||||
}
|
}
|
||||||
|
|
@ -9,108 +10,238 @@ type Item = {
|
||||||
const item = (id: number, label: string): Item => ({ id, label })
|
const item = (id: number, label: string): Item => ({ id, label })
|
||||||
|
|
||||||
describe("Keyed", () => {
|
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<Item, number>({ 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<Item, number>({ key: (value) => value.id })
|
const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
|
||||||
keyed.set([item(1, "one"), item(2, "two")])
|
keyed.set([item(1, "one"), item(2, "two")])
|
||||||
const [one, two] = keyed.slots()
|
const initial = keyed.slots()
|
||||||
const structure = keyed.slots()
|
const structures: Array<readonly number[]> = []
|
||||||
const structures: number[][] = []
|
const dispose = keyed.slots.subscribe((slots) => structures.push(slots.map((slot) => slot().id)))
|
||||||
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]))
|
|
||||||
|
|
||||||
keyed.set([item(1, "ONE"), item(2, "TWO")])
|
keyed.set([item(1, "ONE"), item(2, "TWO")])
|
||||||
|
expect(keyed.slots()).toBe(initial)
|
||||||
|
|
||||||
expect(keyed.slots()).toBe(structure)
|
keyed.set([item(1, "ONE"), item(2, "TWO")])
|
||||||
expect(keyed.slots()).toEqual([one, two])
|
expect(keyed.slots()).toBe(initial)
|
||||||
expect(values).toEqual([[item(1, "ONE"), item(2, "TWO")]])
|
|
||||||
expect(structures).toEqual([])
|
|
||||||
|
|
||||||
keyed.set([item(2, "TWO"), item(1, "ONE")])
|
keyed.set([item(2, "TWO"), item(1, "ONE")])
|
||||||
|
|
||||||
expect(keyed.slots()).toEqual([two, one])
|
|
||||||
expect(structures).toEqual([[2, 1]])
|
expect(structures).toEqual([[2, 1]])
|
||||||
disposeSlots()
|
dispose()
|
||||||
disposeValues()
|
})
|
||||||
|
|
||||||
|
it("reacts to aggregate value updates while preserving unchanged aggregate snapshots", () => {
|
||||||
|
const keyed = Keyed.make<Item, number>({ 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<readonly Item[]> = []
|
||||||
|
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", () => {
|
it("uses custom equivalence to cut off slot and aggregate updates", () => {
|
||||||
const keyed = Keyed.make<Item, number>({
|
const keyed = Keyed.make<Item, number>({
|
||||||
key: (value) => value.id,
|
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")
|
const original = item(1, "one")
|
||||||
keyed.set([original])
|
keyed.set([original])
|
||||||
const slot = keyed.slots()[0]
|
const slot = keyed.slots()[0]
|
||||||
const aggregate = keyed.values()
|
const aggregate = keyed.values()
|
||||||
|
const slotValues: Item[] = []
|
||||||
|
const aggregateValues: Array<readonly Item[]> = []
|
||||||
|
const disposeSlot = slot.subscribe((value) => slotValues.push(value))
|
||||||
|
const disposeAggregate = keyed.values.subscribe((values) => aggregateValues.push(values))
|
||||||
|
|
||||||
keyed.set([item(1, "ONE")])
|
keyed.set([item(1, "ONE")])
|
||||||
|
|
||||||
expect(slot()).toBe(original)
|
expect(slot()).toBe(original)
|
||||||
expect(keyed.values()).toBe(aggregate)
|
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<number, string>({ 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<Item, number>({ key: (value) => value.id })
|
const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
|
||||||
keyed.set([item(1, "one"), item(2, "two")])
|
keyed.set([item(1, "one")])
|
||||||
const [removed, retained] = keyed.slots()
|
const removed = keyed.slots()[0]
|
||||||
|
const removedValues: Item[] = []
|
||||||
|
const dispose = removed.subscribe((value) => removedValues.push(value))
|
||||||
|
|
||||||
keyed.set([item(2, "two")])
|
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(reinserted).not.toBe(removed)
|
||||||
expect(keyed.slots()[1]).toBe(retained)
|
expect(reinserted()).toEqual(item(1, "new one"))
|
||||||
expect(retained()).toEqual(item(2, "TWO"))
|
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<Item, number>({ key: (value) => value.id })
|
const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
|
||||||
keyed.set([item(1, "one"), item(2, "two")])
|
keyed.set([item(1, "one"), item(2, "two")])
|
||||||
const slots = keyed.slots()
|
const slots = keyed.slots()
|
||||||
const values = keyed.values()
|
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.set([item(1, "changed"), item(1, "duplicate")])).toThrow("Keyed values must have unique keys")
|
||||||
|
|
||||||
expect(keyed.slots()).toBe(slots)
|
expect(keyed.slots()).toBe(slots)
|
||||||
expect(keyed.values()).toBe(values)
|
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<Item, number>({ key: (value) => value.id })
|
const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
|
||||||
keyed.set([item(-0, "zero"), item(Number.NaN, "nan")])
|
keyed.set([item(-0, "negative zero")])
|
||||||
const [zero, nan] = keyed.slots()
|
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.slots()[0]).toBe(zero)
|
||||||
expect(() => keyed.set([item(0, "zero"), item(-0, "duplicate")])).toThrow("Keyed values must have unique keys")
|
expect(zero()).toEqual(item(0, "zero"))
|
||||||
expect(() => keyed.set([item(Number.NaN, "nan"), item(Number.NaN, "duplicate")])).toThrow(
|
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<Item, number>({ 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",
|
"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<Item, number>({ key: (value) => value.id })
|
const keyed = Keyed.make<Item, number>({ 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 observations: string[] = []
|
||||||
const summary = Computed.make(() => {
|
const summary = Computed.make(() => {
|
||||||
const slots = keyed
|
const slotKeys = keyed
|
||||||
.slots()
|
.slots()
|
||||||
.map((slot) => `${slot().id}:${slot().label}`)
|
.map((slot) => slot().id)
|
||||||
.join(",")
|
.join(",")
|
||||||
const values = keyed
|
const values = keyed
|
||||||
.values()
|
.values()
|
||||||
.map((value) => `${value.id}:${value.label}`)
|
.map((value) => `${value.id}:${value.label}`)
|
||||||
.join(",")
|
.join(",")
|
||||||
return `${slots}|${values}`
|
return `${slotKeys}|${values}`
|
||||||
})
|
})
|
||||||
const dispose = summary.subscribe((value) => observations.push(value))
|
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()
|
dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("stops slot, structural, and aggregate notifications after disposal", () => {
|
||||||
|
const keyed = Keyed.make<Item, number>({ 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<Item, number>({ key: (value) => value.id })
|
||||||
|
expect(keyed.slots()).toEqual([])
|
||||||
|
expect(keyed.values()).toEqual([])
|
||||||
|
const structures: Array<readonly unknown[]> = []
|
||||||
|
const aggregates: Array<readonly Item[]> = []
|
||||||
|
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", () => {
|
it("updates one existing slot without publishing structure", () => {
|
||||||
const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
|
const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
|
||||||
keyed.set([item(1, "one"), item(2, "two")])
|
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(true)
|
||||||
expect(keyed.remove(2)).toBe(false)
|
expect(keyed.remove(2)).toBe(false)
|
||||||
expect(keyed.slots()).toEqual([one, four, three])
|
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", () => {
|
it("counts publications and equivalence suppressions when instrumented", () => {
|
||||||
|
|
@ -202,18 +336,4 @@ describe("Keyed", () => {
|
||||||
equivalenceSuppressions: 1,
|
equivalenceSuppressions: 1,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it("publishes nothing for a full unchanged rebuild", () => {
|
|
||||||
const metrics = Keyed.metrics()
|
|
||||||
const keyed = Keyed.make<Item, number>({ 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)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,8 @@ const Job = Layout.struct({
|
||||||
})
|
})
|
||||||
|
|
||||||
const Jobs = Layout.collection(Job, ({ members, first }) => ({
|
const Jobs = Layout.collection(Job, ({ members, first }) => ({
|
||||||
labels: members((job) => job.labels),
|
labels: members(["labels"], (job) => job.labels),
|
||||||
nextRetry: first((job) => job.status === "retrying"),
|
nextRetry: first(["status"], (job) => job.status === "retrying"),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
describe("Layout", () => {
|
describe("Layout", () => {
|
||||||
|
|
@ -37,12 +37,17 @@ describe("Layout", () => {
|
||||||
"Keyed layout must declare exactly one key field",
|
"Keyed layout must declare exactly one key field",
|
||||||
)
|
)
|
||||||
expect(() =>
|
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")
|
).toThrow("Keyed layout must declare exactly one key field")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("generates the same trusted equivalence as the closure backend", () => {
|
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 generated = Layout.compile(Item, { backend: "generated" })
|
||||||
const values = [
|
const values = [
|
||||||
{ id: 1, label: "one" },
|
{ id: 1, label: "one" },
|
||||||
|
|
@ -53,12 +58,70 @@ describe("Layout", () => {
|
||||||
values.forEach((left) => {
|
values.forEach((left) => {
|
||||||
values.forEach((right) => {
|
values.forEach((right) => {
|
||||||
expect(generated.equivalent(left, right)).toBe(closure.equivalent(left, 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<typeof Root> = {
|
||||||
|
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", () => {
|
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({
|
const Row = Layout.keyedUnion({
|
||||||
key: Layout.key("id", Layout.string),
|
key: Layout.key("id", Layout.string),
|
||||||
tag: "type",
|
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 generated = Layout.compile(Row, { backend: "generated" })
|
||||||
const group = {
|
const group = {
|
||||||
id: "group-1",
|
id: "group-1",
|
||||||
|
|
@ -94,9 +157,19 @@ describe("Layout", () => {
|
||||||
completed: false,
|
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, 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(
|
expect(
|
||||||
plan.equivalent(group, {
|
plan.equivalent(group, {
|
||||||
id: "group-1",
|
id: "group-1",
|
||||||
|
|
@ -127,8 +200,14 @@ describe("Layout", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it("includes nested keys in structural equivalence", () => {
|
it("includes nested keys in structural equivalence", () => {
|
||||||
const Child = Layout.struct({ id: Layout.key(Layout.number), value: Layout.string })
|
const Child = Layout.struct({
|
||||||
const Parent = Layout.struct({ id: Layout.key(Layout.number), child: Child })
|
id: Layout.key(Layout.number),
|
||||||
|
value: Layout.string,
|
||||||
|
})
|
||||||
|
const Parent = Layout.struct({
|
||||||
|
id: Layout.key(Layout.number),
|
||||||
|
child: Child,
|
||||||
|
})
|
||||||
const parent = Layout.compile(Parent)
|
const parent = Layout.compile(Parent)
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
|
|
@ -184,6 +263,158 @@ describe("Layout", () => {
|
||||||
expect(jobs.first("nextRetry")?.().id).toBe("four")
|
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<string> = {
|
||||||
|
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<object>([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", () => {
|
it("applies explicit member deltas without re-extracting unchanged membership", () => {
|
||||||
let extractions = 0
|
let extractions = 0
|
||||||
const IndexedJobs = Layout.collection(Job, ({ members }) => ({
|
const IndexedJobs = Layout.collection(Job, ({ members }) => ({
|
||||||
|
|
@ -206,12 +437,20 @@ describe("Layout", () => {
|
||||||
expect(jobs.hasMember("labels", "billing")).toBe(true)
|
expect(jobs.hasMember("labels", "billing")).toBe(true)
|
||||||
expect(jobs.hasMember("labels", "urgent")).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)
|
expect(jobs.hasMember("labels", "billing")).toBe(false)
|
||||||
|
|
||||||
if (false) {
|
if (false) {
|
||||||
// @ts-expect-error Member deltas require arrays so string members are not split into characters.
|
jobs.modify("one", (job) => job, {
|
||||||
jobs.modify("one", (job) => job, { members: { labels: { add: "urgent" } } })
|
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", () => {
|
it("tracks first matches whose key is undefined", () => {
|
||||||
const undefinedField: Layout.Field<undefined> = { equivalent: Object.is }
|
const undefinedField: Layout.Field<undefined> = { equivalent: Object.is }
|
||||||
const OptionalJobs = Layout.collection(
|
const OptionalJobs = Layout.collection(
|
||||||
Layout.struct({ id: Layout.key(undefinedField), status: Layout.string }),
|
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" }])
|
const jobs = OptionalJobs.make([{ id: undefined, status: "running" }])
|
||||||
|
|
||||||
jobs.update({ id: undefined, status: "retrying" })
|
jobs.update({ id: undefined, status: "retrying" })
|
||||||
|
|
||||||
expect(jobs.first("retry")?.()).toEqual({ id: undefined, status: "retrying" })
|
expect(jobs.first("retry")?.()).toEqual({
|
||||||
|
id: undefined,
|
||||||
|
status: "retrying",
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
85
packages/quark/test/solid.test.ts
Normal file
85
packages/quark/test/solid.test.ts
Normal file
|
|
@ -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<string | undefined> = []
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue