feat: add reactive()

This commit is contained in:
littensy 2025-11-18 20:39:37 -08:00
parent 7bad215560
commit bd23d54374
26 changed files with 1129 additions and 569 deletions

View file

@ -40,6 +40,31 @@ test("should merge patch", function()
assert(a().c == 2, "should not change c")
end)
test("should merge patch (reactive)", function()
local a = Charm.reactive({ b = 1, c = 2 })
local runs = 0
Charm.effect(function()
local _ = a.b + a.c
runs += 1
end)
client.addSignals({ a = a })
client.patch({
{ type = "init", data = { a = { b = a.b, c = a.c } } },
})
assert(runs == 1, "should not trigger effect for redundant init")
client.patch({
{ type = "patch", data = { a = { b = 3 } } },
})
assert(a.b == 3, "should set b to the new value")
assert(a.c == 2, "should not change c")
assert(runs == 2, "should trigger effect for patch")
end)
test("should fix sparse array patches", function()
local a = Charm.atom({ 1 })

View file

@ -16,9 +16,9 @@ end)
test("should replicate server state to client", function()
local target = {}
local serverA = Charm.atom({ count = 0 })
local serverB = Charm.atom({ count = 0 })
local serverB = Charm.reactive({ count = 0 })
local clientA = Charm.atom({ count = -1 })
local clientB = Charm.atom({ count = -1 })
local clientB = Charm.reactive({ count = -1 })
client.addSignals({ a = clientA, b = clientB })
server.addSignalsToClient(target, { a = serverA, b = serverB })
@ -30,27 +30,32 @@ test("should replicate server state to client", function()
server.flush()
assert(clientA().count == 0, "Client atom A should be initialized to 0")
assert(clientB().count == 0, "Client atom B should be initialized to 0")
assert(clientB.count == 0, "Client reactive B should be initialized to 0")
serverA({ count = 1 })
serverB({ count = 1 })
serverB.count = 1
server.flush()
assert(clientA().count == 1, "Client atom A should be updated to 1")
assert(clientB().count == 1, "Client atom B should be updated to 1")
assert(clientB.count == 1, "Client reactive B should be updated to 1")
local runs = -1
Charm.effect(function()
local _ = clientB.count
runs += 1
end)
local prevClientB = clientB()
serverA({ count = 2 })
server.flush()
assert(clientA().count == 2, "Client atom A should be updated to 2")
assert(clientB() == prevClientB, "Client atom B should not be updated")
assert(runs == 0, "Client reactive B should not trigger update")
end)
test("should replicate historical server state to client", function()
local target = {}
local serverA = Charm.atom({ count = 0 })
local serverB = Charm.atom({ count = 0 })
local serverB = Charm.reactive({ count = 0 })
local clientA = Charm.atom({ count = -1 })
local clientB = Charm.atom({ count = -1 })
local clientB = Charm.reactive({ count = -1 })
config.preserveHistory = true
@ -67,30 +72,28 @@ test("should replicate historical server state to client", function()
Charm.effect(function()
local expected = table.remove(expectedA, 1)
assert(expected == clientA().count, `Client atom A should be {expected}`)
assert(expected == clientA().count, `Client reactive A should be {expected}`)
end)
Charm.effect(function()
local expected = table.remove(expectedB, 1)
assert(expected == clientB().count, `Client atom B should be {expected}`)
assert(expected == clientB.count, `Client reactive B should be {expected}`)
end)
server.flush()
serverA({ count = 1 })
serverA({ count = 2 })
serverB({ count = 1 })
serverB({ count = 2 })
serverB.count = 1
serverB.count = 2
server.flush()
assert(clientA().count == 2, "Client atom A should be updated to 2")
assert(clientB().count == 2, "Client atom B should be updated to 2")
assert(clientB.count == 2, "Client reactive B should be updated to 2")
local prevClientB = clientB()
serverA({ count = 3 })
serverA({ count = 4 })
server.flush()
assert(clientA().count == 4, "Client atom A should be updated to 4")
assert(clientB() == prevClientB, "Client atom B should not be updated")
end)
return {}

View file

@ -31,17 +31,17 @@ end)
test("returns a patch", function()
local prevState = { foo = "bar", baz = "qux" }
local nextState = { foo = "bar", baz = "quux" }
local patches = patch.diff(prevState, nextState)
local diffs = patch.diff(prevState, nextState)
assert(patches ~= nil, "should return a patch")
assert(patches.baz == "quux", "should return the changed value")
assert(patches.foo == nil, "should omit unchanged values")
assert(diffs ~= nil, "should return a patch")
assert(diffs.baz == "quux", "should return the changed value")
assert(diffs.foo == nil, "should omit unchanged values")
end)
test("applies a patch", function()
local prevState = { foo = "bar", baz = "qux" }
local patches = { baz = "quux", qaz = "qux" }
local applied = patch.apply(prevState, patches)
local diffs = { baz = "quux", qaz = "qux" }
local applied = patch.apply(prevState, diffs)
assert(applied ~= nil, "should return a new state")
assert(applied.baz == "quux", "should apply the changed value")
@ -49,11 +49,11 @@ test("applies a patch", function()
assert(applied.qaz == "qux", "should add the new value")
end)
test("allows nested patches", function()
test("allows nested diffs", function()
local prevState = { foo = { bar = "baz" } }
local nextState = { foo = { bar = "qux" } }
local patches = patch.diff(prevState, nextState)
local applied = patch.apply(prevState, patches)
local diffs = patch.diff(prevState, nextState)
local applied = patch.apply(prevState, diffs)
assert(applied.foo.bar == "qux", "should apply the nested patch")
end)
@ -78,8 +78,8 @@ test("preserves array density after serdes", function()
local prevState = { "a", "b", "c", "d", "e" }
local nextState = { "A", "b", "c", "D" }
local patches = mockArraySerdes(patch.diff(prevState, nextState))
local applied = patch.apply(prevState, patches)
local diffs = mockArraySerdes(patch.diff(prevState, nextState))
local applied = patch.apply(prevState, diffs)
for index in prevState do
assert(applied[index] == nextState[index], `expected {applied[index]} to be {nextState[index]}`)
@ -96,10 +96,10 @@ test("cannot preserve sparse arrays", function()
local prevState = {}
local nextState = { [2] = "foo" }
local patches = mockArraySerdes(patch.diff(prevState, nextState))
local applied = patch.apply(prevState, patches)
local diffs = mockArraySerdes(patch.diff(prevState, nextState))
local applied = patch.apply(prevState, diffs)
-- Not enough information is available to determine whether 'patches'
-- Not enough information is available to determine whether 'diffs'
-- should keep the string keys added during remote event serialization.
assert(applied["2"] ~= nil, "will fail to convert string keys to numbers")
assert(applied[2] == nil, "will fail to convert string keys to numbers")
@ -119,20 +119,12 @@ test("optionally fixes sparse arrays", function()
end
end)
test("optionally validates state", function()
local success = pcall(function()
config.validatePatches = true
patch.diff({}, { [1] = 1, a = true })
end)
assert(not success, "should not allow mixed tables")
success = pcall(function()
config.validatePatches = false
patch.diff({}, { [1] = 1, a = true })
end)
assert(success, "should skip validation if not requested")
test("patches mutable state", function()
local targetState: { [string]: string } = { foo = "bar", baz = "qux" }
patch.applyMutable(targetState, { baz = "quux", qaz = "qux" })
assert(targetState.baz == "quux", "should apply the changed value")
assert(targetState.foo == "bar", "should keep the unchanged value")
assert(targetState.qaz == "qux", "should add the new value")
end)
return {}

View file

@ -69,6 +69,33 @@ test("should replicate partial state patches", function()
assert(patch.isNone(payloads[1].data.b), "Expected patch payload to contain atom 'b' as None")
end)
test("should replicate partial state patches (reactive)", function()
local client = {}
local a: { [string]: number? } = Charm.reactive({ x = 0, y = 0, z = 0 })
local target, payloads
server.addSignalsToClient(client, { a = a })
server.connect(function(...)
target, payloads = ...
end)
server.flush()
assert(target == client, "Expected target to be the client")
assert(payloads[1].type == "init", "Expected first payload to be init")
assert(payloads[1].data.a.x == a.x, "Expected init payload to contain value 'a.x'")
assert(payloads[1].data.a.y == a.y, "Expected init payload to contain value 'a.y'")
assert(payloads[1].data.a.z == a.z, "Expected init payload to contain value 'a.z'")
a.x = 1
a.z = nil
server.flush()
assert(target == client, "Expected target to be the client")
assert(payloads[1].type == "patch", "Expected first payload to be patch")
assert(payloads[1].data.a.x == 1, "Expected patch payload to contain value 'a.x'")
assert(payloads[1].data.a.y == nil, "Expected patch payload to not contain value 'a.y'")
assert(patch.isNone(payloads[1].data.a.z), "Expected patch payload to contain value 'a.z' as None")
end)
test("should init new clients", function()
local clientA = {}
local clientB = {}
@ -89,8 +116,8 @@ test("should init new clients", function()
assert(payloadsA[1].type == "init", "Expected first payload to be init for client A")
assert(payloadsA[1].data.a == a(), "Expected init payload to contain atom 'a' for client A")
server.addSignalsToClient(clientB, { a = a })
a(2)
server.addSignalsToClient(clientB, { a = a })
server.flush()
assert(payloadsA[1].type == "patch", "Expected first payload to be patch for client A")
assert(payloadsA[1].data.a == a(), "Expected patch payload to contain atom 'a' for client A")
@ -166,20 +193,6 @@ test("should only replicate to synced clients", function()
assert(payloadsB[1].data.a == nil, "Expected patch payload to not contain atom 'a' for client B")
end)
test("should validate state", function()
local client = {}
local a = Charm.atom({})
server.addSignalsToClient(client, { a = a })
server.connect(function() end)
a({ [0.5] = true })
assert(not pcall(server.flush), "Expected server.flush to fail")
a({ [0.5] = true, valid = true })
server.flush()
end)
test("should serialize state", function()
local client = {}
local a = Charm.atom({ 1, 2, 3, 4, 5 })
@ -236,6 +249,36 @@ describe("historical", function()
assert(payloads[3].data.b == 2, "Expected patch payload to contain atom 'b'")
end)
test("should replicate granular state changes (reactive)", function()
local client = {}
local a = Charm.reactive({ value = 0 })
local b = Charm.reactive({ value = 0 })
local c = Charm.reactive({ value = 0 })
local target, payloads
server.addSignalsToClient(client, { a = a, b = b, c = c })
server.connect(function(...)
target, payloads = ...
end)
server.flush()
assert(target == client, "Expected target to be the client")
assert(payloads[1].type == "init", "Expected first payload to be init")
assert(payloads[1].data.a.value == 0, "Expected init payload to contain atom 'a'")
assert(payloads[1].data.b.value == 0, "Expected init payload to contain atom 'b'")
assert(payloads[1].data.c.value == 0, "Expected init payload to contain atom 'c'")
a.value = 1
a.value = 2
b.value = 2
server.flush()
assert(target == client, "Expected target to be the client")
assert(payloads[1].type == "patch", "Expected first payload to be patch")
assert(payloads[1].data.a.value == 1, "Expected patch payload to contain atom 'a'")
assert(payloads[2].data.a.value == 2, "Expected patch payload to contain atom 'a'")
assert(payloads[3].data.b.value == 2, "Expected patch payload to contain atom 'b'")
end)
test("should init new clients", function()
local clientA = {}
local clientB = {}

View file

@ -20,8 +20,4 @@ suite.describe("server", function()
require("./server.spec")
end)
suite.describe("validate", function()
require("./validate.spec")
end)
return {}

View file

@ -1,24 +0,0 @@
local suite = require("../../../../test/suite")
local validate = require("../validate")
local test = suite.test
test("validates inputs", function()
local cases: { [string]: { value: any, expected: any } } = {
array = { value = { 1, 2, 3 }, expected = true },
arraySparse = { value = { 1, nil :: any, 3 }, expected = true },
arraySparseInvalid = { value = { [1] = 0, [3] = 0 }, expected = false },
mixedTable = { value = { [1] = "foo", a = true }, expected = false },
}
for key, case in cases do
local success, result: any = pcall(validate, case.value)
if case.expected and not success then
error(`Expected {key} to be valid, threw {result}`)
elseif not case.expected and success then
error(`Expected {key} to be invalid, but it was valid`)
end
end
end)
return {}