feat: add serializeArrays option

This commit is contained in:
Richard 2024-11-04 14:39:45 -08:00
parent ecf808c706
commit f386e2c177
22 changed files with 412 additions and 365 deletions

View file

@ -19,30 +19,6 @@ return function()
expect(a()).to.equal(2)
end)
it("requires init by default", function()
local a = atom(1)
local client = client({ atoms = { a = a } })
client:sync({
type = "patch",
data = { { path = { "a" }, data = 2 } },
})
expect(a()).to.equal(1)
end)
it("can ignore init requirement", function()
local a = atom(1)
local client = client({ atoms = { a = a }, ignoreUnhydrated = false })
client:sync({
type = "patch",
data = { { path = { "a" }, data = 2 } },
})
expect(a()).to.equal(2)
end)
it("syncs state patch", function()
local a = atom({ b = 1, c = 2 })
local previous = a()
@ -57,7 +33,7 @@ return function()
client:sync({
type = "patch",
data = { { path = { "a", "b" }, data = 3 } },
data = { a = { b = 3 } },
})
expect(a()).to.never.equal(previous)
@ -65,6 +41,27 @@ return function()
expect(a().c).to.equal(2)
end)
it("fixes sparse array patches", function()
local a = atom({ 1 })
local client = client({ atoms = { a = a } })
client:sync({
type = "init",
data = { a = a() },
})
-- This patch would be the result of a diff between { 1 } and { 1, 2 } that
-- was JSON-serialized and deserialized. The client should be able to catch
-- this and convert the string key back to a number.
client:sync({
type = "patch",
data = { a = { ["2"] = 2 } },
})
expect(a()["2" :: any]).to.never.be.ok()
expect(a()[2]).to.equal(2)
end)
it("applies multiple patches", function()
local a = atom(1)
local client = client({ atoms = { a = a } })
@ -81,10 +78,10 @@ return function()
client:sync({
type = "patch",
data = { { path = { "a" }, data = 2 } },
data = { a = 2 },
}, {
type = "patch",
data = { { path = { "a" }, data = 3 } },
data = { a = 3 },
})
expect(a()).to.equal(3)
@ -113,10 +110,7 @@ return function()
client:sync({
type = "patch",
data = {
{ path = { "a" }, data = 1 },
{ path = { "b" }, data = 2 },
},
data = { a = 1, b = 2 },
})
expect(a()).to.equal(1)

View file

@ -1,19 +0,0 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local store = require(ReplicatedStorage.DevPackages.Charm.store)
return function()
beforeAll(function()
_G.__DEV__ = true
end)
afterAll(function()
_G.__DEV__ = nil
end)
afterEach(function()
table.clear(store.listeners :: any)
table.clear(store.capturing.stack)
store.capturing.index = 0
end)
end

View file

@ -1,68 +0,0 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local packets = require(ReplicatedStorage.DevPackages.CharmSync.packets)
return function()
it("creates a packet", function()
local prevState = { foo = "bar", baz = "qux" }
local nextState = { foo = "bar", baz = "quux" }
local results = packets.diff(prevState, nextState)
expect(#results).to.equal(1)
expect(table.concat(results[1].path, "/")).to.equal("baz")
expect(results[1].data).to.equal("quux")
end)
it("applies a packet", function()
local prevState = { foo = "bar", baz = "qux" }
local packet: packets.Packet = { path = { "baz" }, data = "quux" }
local nextState = packets.apply(prevState, { packet })
expect(nextState.baz).to.equal("quux")
expect(nextState.foo).to.equal("bar")
end)
it("applies nested packets", function()
local prevState = { foo = { bar = "baz" } }
local nextState = { foo = { bar = "qux" } }
local results = packets.diff(prevState, nextState)
local applied = packets.apply(prevState, results)
expect(prevState.foo.bar).to.equal("baz")
expect(applied.foo.bar).to.equal("qux")
end)
it("only copies changed tables", function()
local prevState = { foo = { bar = "baz" }, baz = { qux = "quux" } }
local nextState = { foo = { bar = "qux" }, baz = table.clone(prevState.baz) }
local results = packets.diff(prevState, nextState)
local applied = packets.apply(prevState, results)
expect(applied).to.never.equal(prevState)
expect(applied.foo).to.never.equal(prevState.foo)
expect(applied.foo.bar).to.equal("qux")
expect(applied.baz).to.equal(prevState.baz)
end)
it("excludes unchanged tables", function()
local prevState = { foo = { bar = "baz" }, baz = { qux = "quux" } }
local nextState = { foo = { bar = "qux" }, baz = table.clone(prevState.baz) }
local results = packets.diff(prevState, nextState)
expect(#results).to.equal(1)
expect(table.concat(results[1].path, "/")).to.equal("foo/bar")
expect(results[1].data).to.equal("qux")
end)
it("overwrites cleared tables", function()
local prevState = { foo = { 1, 2, 3 } }
local nextState = { foo = {} }
local results = packets.diff(prevState, nextState)
expect(#results).to.equal(1)
expect(table.concat(results[1].path, "/")).to.equal("foo")
expect(#results[1].data).to.equal(0)
end)
end

View file

@ -0,0 +1,111 @@
local HttpService = game:GetService("HttpService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local patch = require(ReplicatedStorage.DevPackages.CharmSync.patch)
return function()
local function serdes(value: { [any]: any })
return HttpService:JSONDecode(HttpService:JSONEncode(value))
end
it("returns a patch", function()
local prevState = { foo = "bar", baz = "qux" }
local nextState = { foo = "bar", baz = "quux" }
local patches = patch.diff(prevState, nextState)
expect(patches).to.be.ok()
expect(patches.baz).to.equal("quux")
expect(patches.foo).to.never.be.ok()
end)
it("applies a patch", function()
local prevState = { foo = "bar", baz = "qux" }
local patches = { baz = "quux", qaz = "qux" }
local applied = patch.apply(prevState, patches)
expect(applied).to.be.ok()
expect(applied.baz).to.equal("quux")
expect(applied.foo).to.equal("bar")
expect(applied.qaz).to.equal("qux")
end)
it("allows nested patches", function()
local prevState = { foo = { bar = "baz" } }
local nextState = { foo = { bar = "qux" } }
local patches = patch.diff(prevState, nextState)
local applied = patch.apply(prevState, patches)
expect(applied.foo.bar).to.equal("qux")
end)
it("preserves array density when applying", function()
local prevState = { "a", "b", "c", "d" }
local nextState = { ["2"] = "B", ["4"] = "D" }
local applied = patch.apply(prevState, nextState)
expect(applied[1]).to.equal("a")
expect(applied[2]).to.equal("B")
expect(applied[3]).to.equal("c")
expect(applied[4]).to.equal("D")
expect(applied["2"]).to.never.be.ok()
expect(applied["4"]).to.never.be.ok()
end)
it("preserves array density after serdes", function()
-- This particular change results in a patch that cannot be serialized
-- without losing information. Charm resolves this issue internally.
local prevState = { "a", "b", "c", "d", "e" }
local nextState = { "A", "b", "c", "D" }
local patches = serdes(patch.diff(prevState, nextState))
local applied = patch.apply(prevState, patches)
for index in next, prevState do
expect(applied[index]).to.equal(nextState[index])
end
for index in next, applied do
expect(applied[index]).to.equal(nextState[index])
end
end)
it("preserves large gaps between keys", function()
local prevState = table.create(10000, "foo")
local nextState = table.move(prevState, 2, 9998, 2, { [1] = "bar", [9999] = "bar" })
local start = os.clock()
local patches = serdes(patch.diff(prevState, nextState))
local applied = patch.apply(prevState, patches)
assert(os.clock() - start < 10e-3, `Performance is not acceptable ({(os.clock() - start) * 1e3}ms)`)
for index in next, prevState do
expect(applied[index]).to.equal(nextState[index])
end
end)
it("cannot preserve sparse arrays", function()
local prevState = {}
local nextState = { [2] = "foo" }
local patches = serdes(patch.diff(prevState, nextState))
local applied = patch.apply(prevState, patches)
-- Not enough information is available to determine whether 'patches'
-- should keep the string keys added during remote event serialization.
expect(applied["2"]).to.be.ok()
expect(applied[2]).to.never.be.ok()
end)
it("optionally fixes sparse arrays", function()
for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do
expect(index).to.be.a("string")
end
for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }, false) do
expect(index).to.be.a("number")
end
end)
end

View file

@ -5,6 +5,7 @@ local atom = Charm.atom
local CharmSync = require(ReplicatedStorage.DevPackages.CharmSync)
type SyncPayload = CharmSync.SyncPayload
local server = CharmSync.server
local patch = require(ReplicatedStorage.DevPackages.CharmSync.patch)
return function()
local target: Player = {} :: any
@ -21,14 +22,14 @@ return function()
server:hydrate(target)
expect(player).to.equal(target)
assert(payload.type == "init", "payload.type is not 'init'")
expect(payload.type).to.equal("init")
expect(payload.data.a).to.equal(1)
disconnect()
end)
it("sends state patches", function()
local a = atom({ 1, 2 })
local a = atom({ b = 1, c = 2 } :: { [string]: number? })
local server = server({ atoms = { a = a }, interval = -1 })
local player: Player, payload: SyncPayload
@ -36,27 +37,20 @@ return function()
player, payload = ...
end)
a({ 1, 5 })
a({ b = 1, c = 5 })
server:_sendPatch(target)
expect(player).to.equal(target)
assert(payload.type == "patch", "payload.type is not 'patch'")
expect(#payload.data).to.equal(1)
expect(payload.type).to.equal("patch")
expect(payload.data.a.b).to.never.be.ok()
expect(payload.data.a.c).to.equal(5)
expect(table.concat(payload.data[1].path, "/")).to.equal("a/2")
expect(payload.data[1].data).to.equal(5)
a({ 1, nil :: any, 1 })
a({ b = 1, d = 1 })
server:_sendPatch(target)
assert(payload.type == "patch", "payload.type is not 'patch'")
expect(#payload.data).to.equal(2)
expect(table.concat(payload.data[1].path, "/")).to.equal("a/2")
expect(payload.data[1].data).to.never.be.ok()
expect(table.concat(payload.data[2].path, "/")).to.equal("a/3")
expect(payload.data[2].data).to.equal(1)
expect(payload.data.a.b).to.never.be.ok()
expect(patch.isNone(payload.data.a.c)).to.equal(true)
expect(payload.data.a.d).to.equal(1)
disconnect()
end)
@ -81,16 +75,9 @@ return function()
server:_sendPatch(target)
expect(#payloads).to.equal(1)
assert(payloads[1].type == "patch", "payload 1 type is not 'patch'")
expect(#payloads[1].data).to.equal(2)
-- a(2)
expect(table.concat(payloads[1].data[1].path, "/")).to.equal("a")
expect(payloads[1].data[1].data).to.equal(2)
-- b(2)
expect(table.concat(payloads[1].data[2].path, "/")).to.equal("b")
expect(payloads[1].data[2].data).to.equal(2)
expect(payloads[1].type).to.equal("patch")
expect(payloads[1].data.a).to.equal(2)
expect(payloads[1].data.b).to.equal(2)
a(3)
a(4)
@ -98,24 +85,12 @@ return function()
server:_sendPatch(target)
expect(#payloads).to.equal(2)
assert(payloads[1].type == "patch", "payload 1 type is not 'patch'")
expect(#payloads[1].data).to.equal(1)
-- a(3)
expect(table.concat(payloads[1].data[1].path, "/")).to.equal("a")
expect(payloads[1].data[1].data).to.equal(3)
-- second a() call cannot be collapsed, so it is added to a new payload
assert(payloads[2].type == "patch", "payload 2 type is not 'patch'")
expect(#payloads[2].data).to.equal(2)
-- a(4)
expect(table.concat(payloads[2].data[1].path, "/")).to.equal("a")
expect(payloads[2].data[1].data).to.equal(4)
-- b(3)
expect(table.concat(payloads[2].data[2].path, "/")).to.equal("b")
expect(payloads[2].data[2].data).to.equal(3)
expect(payloads[1].type).to.equal("patch")
expect(payloads[1].data.a).to.equal(3)
expect(payloads[1].data.b).to.never.be.ok()
expect(payloads[2].type).to.equal("patch")
expect(payloads[2].data.a).to.equal(4)
expect(payloads[2].data.b).to.equal(3)
disconnect()
end)