charm/packages/charm-sync/test/server.test.luau
richard 573533058f
feat!: use alien-signals for reactivity (#50)
* feat: use alien-signals

* refactor: remove pnpm, fix tests

* fix: `effectScope` should set scope

* fix: check previous value before calling listener

* refactor: improve imports

* refactor: exclude re-exports from wally

* refactor: embrace scope cleanup, restructure code

* fix: update pkgs dir references

* ci: simplify pesde install

* build: install dev deps with wally

* feat: add detached, add more tests

* test: simplify mapped test update

* chore: rephrase doc comment

* perf: optimize subscribe and mapped

* refactor: avoid ambiguous optional function type

* refactor: add assertion error messages

* perf: optimize mapped, add benches

* chore: delete redundant benchmarks

* perf: simplify mapped logic

* test: prettify test library

* test: try different gray

* test: color brackets

* refactor: sync 2.0.4

* refactor: use setCurrentSub instead of pauseTracking

* test: improve test suite

* test: separate parent from root

* refactor: iterate over previous data in mapped

* refactor: improve logic

* style: use `---` doc comments

* refactor: improve logic

* chore: housekeeping

* ci: add pnpm step

* ci: init submodules

* test: use lune

* refactor: improve structure and alien-signals integration

* ci: update test file path

* feat: add peek and roblox-ts types

* feat: add deep freeze flag, misc refactors

* refactor: reduce type casting, housekeeping

* refactor: rewrite untracked, update alien-signals

* chore: update eslint, housekeeping

* refactor: start charm-sync rewrite

* fix: send init payload even if atom did not update

* test: add all sync tests

* feat: prepare for alpha release

* feat: add peek, make untracked escape scope

* fix: wrap hooks in untracked, update types

* feat: add flag for inner effect tracking

* refactor: add docs, remove some modules

* refactor: align with alien-signals 3.0

* feat: add signal api

* refactor: update charm sync api

* feat: update alien-signals

* refactor: remove unnecessary features, update docs

* feat: finalize sync docs

* docs: update sync section

* docs: simplify examples

* docs: capitalize headers

* docs: add migration tips

* docs: fix whitespace

* docs: rephrase computed arg change

* refactor: rename batch, misc fixes & perf updates

* refactor: simplify charm sync validation

* feat: update alien-signals, add recursion flag

* feat: add `recursive()`

* fix: allow dispose in observers

* feat: add `trigger()`, refactor source

* docs: update readme

* chore: prepare release candidate

* test: make some tests more reliable

* docs: fix typo

* feat: add `reactive()`

* chore: bump versions

* docs: reword reactive docs

* docs: fix typo

* feat: add `toRaw()`

* fix: check for nil diffs

* chore: bump versions

* perf: do not create proxies for iteration

* fix: make `toRaw` track all calls

* fix: check none before patching mutable tables

* chore: bump charm-sync version

* docs: add fishing game example project

* feat: sync signals that return reactive proxy

* fix: refine reactive proxy logic

* fix: prevent early reactive gc, alien-signals 3.1.1

* fix: wrap iterated values in `reactive()`

* fix: loosen client signal types

* chore: bump release candidate

* fix: remove unsynced signals immediately

* test: use lute for tests

* chore: bump versions

* ci: set up lute

* refactor: improve source readability

* ci: upgrade lute

* build: loosen workspace version requirements

* chore: bump deps

* fix: update signal overload for setter (#58)

* fix: add missing recursed check

* test: add important vue reactivity tests

* chore: fix react tests, run stylua

* chore: update vide

* docs: improve intro and explain getters

* docs: adjust ordering by importance

* docs: move listen nesting note

* build: fix react peer dep for ci

* chore: update lockfile

* ci: use javascript eslint config

* ci: ignore eslint config file

* refactor: move test directories

* feat: allow recursion in effects by default

* release: charm v0.11.0-rc.4

* refactor: remove recursive()

* refactor: reorganize repo, create deep-charm

* docs: rephrase some sections

* feat: export getter and setter types

* docs: rephrase intro again

* feat!: run listen and subscribe callback in untracked

* refactor!: remove peek

* docs: note peek in migration guide

* release: charm v0.11.0-rc.5

* release: charm-sync v0.4.0-rc.7

* release: react-charm v0.4.0-rc.4

* fix: remove old keys when updating key in `mapped()`

* refactor: shorten error traceback, housekeeping

* refactor: keep error level in some cases

* fix: check if cleanup functions disposed effect

* refactor!: run effects in batched

* ci: check eslint and prettier

* perf: run observe fn in untracked

* perf: call mapper as-is in `mapped()`

* refactor: error for effect cleanup errors

* refactor: use new type solver

* ci: disable new solver flag until 0.714

* chore: housekeeping

* refactor: improve errors in nonstrict, optimize noyield

* chore: housekeeping

* refactor: satisfy new solver

* refactor: improve error traceback

* docs: link roblox signals

* chore: housekeeping

* perf: remove `clearPending` inline operation

* refactor: check type of cleanup

* ci: remove selene

* fix: always return pending value in signal setter

* test: add multi benchmark

* release: charm v0.11.0-rc.6

* test: add cleanup order tests

* chore: bump deps

* docs: add warning for deep-charm and note for `observe()`

---------

Co-authored-by: safazi <safazics@gmail.com>
2026-06-20 20:51:17 -07:00

408 lines
15 KiB
Text

local Charm = require("../Charm")
local DeepCharm = require("../../deep-charm/src")
local config = require("../src/config")
local patch = require("../src/patch")
local server = require("../src/server")
local test = require("../../../tests/test")
local types = require("../src/types")
test.afterEach(function()
server.reset()
end)
test("should replicate state changes", function()
local client = {}
local a = Charm.atom(0)
local b = Charm.atom(0)
local c = Charm.atom(0)
local target: unknown, payloads: { types.SyncPayload }
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 == a(), "Expected init payload to contain atom 'a'")
assert(payloads[1].data.b == b(), "Expected init payload to contain atom 'b'")
assert(payloads[1].data.c == c(), "Expected init payload to contain atom 'c'")
a(1)
b(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 == a(), "Expected patch payload to contain atom 'a'")
assert(payloads[1].data.b == b(), "Expected patch payload to contain atom 'b'")
end)
test("should replicate partial state patches", function()
local client = {}
local a = Charm.atom({ x = 0, y = 0 })
local b = Charm.atom(0 :: number?)
local target: unknown, payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a, b = b })
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 atom 'a.x'")
assert(payloads[1].data.a.y == a().y, "Expected init payload to contain atom 'a.y'")
assert(payloads[1].data.b == b(), "Expected init payload to contain atom 'b'")
a({ x = 1, y = 0 })
b(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 atom 'a.x'")
assert(payloads[1].data.a.y == nil, "Expected patch payload to not contain atom 'a.y'")
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 = DeepCharm.reactive({ x = { y = 0 }, z = 0 :: number? })
local target: unknown, payloads: { types.SyncPayload }
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 ~= nil, "Expected init payload to contain value 'a.x'")
assert(payloads[1].data.a.x.y == a.x.y, "Expected init payload to contain value 'a.x.y'")
assert(payloads[1].data.a.z == a.z, "Expected init payload to contain value 'a.z'")
a.x.y = 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.y == 1, "Expected patch payload to contain value 'a.x'")
assert(patch.isNone(payloads[1].data.a.z), "Expected patch payload to contain value 'a.z' as None")
a.z = 1
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.z == 1, "Expected patch payload to contain value 'a.z'")
assert(payloads[1].data.a.x == nil, "Expected patch payload to not contain value 'a.x'")
end)
test("should handle nested proxies", function()
local client = {}
local src = DeepCharm.reactive({ (DeepCharm.reactive({})) })
local payloads: {}? = nil
server.addSignalsToClient(client, { src = src })
server.connect(function(_, newPayloads)
payloads = newPayloads
end)
server.flush()
payloads = nil
server.flush()
assert(payloads == nil, "Expected no payloads to be sent for unchanged nested proxy")
end)
test("should init new clients", function()
local clientA = {}
local clientB = {}
local a = Charm.atom(0)
local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload }
server.addSignalsToClient(clientA, { a = a })
server.connect(function(target, payloads)
if target == clientA then
payloadsA = payloads
elseif target == clientB then
payloadsB = payloads
end
end)
a(1)
server.flush()
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")
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")
assert(payloadsB[1].type == "init", "Expected first payload to be init for client B")
assert(payloadsB[1].data.a == a(), "Expected init payload to contain atom 'a' for client B")
end)
test("should init new atoms", function()
local client = {}
local a = Charm.atom(0)
local b = Charm.atom(0)
local target: unknown, payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a })
server.connect(function(...)
target, payloads = ...
end)
server.flush()
server.addSignalsToClient(client, { b = b })
a(1)
b(1)
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.b == b(), "Expected init payload to contain atom 'b'")
assert(payloads[2].type == "patch", "Expected second payload to be patch")
assert(payloads[2].data.a == a(), "Expected patch payload to contain atom 'a'")
assert(payloads[2].data.b == nil, "Expected patch payload to not contain atom 'b'")
end)
test("should only replicate to synced clients", function()
local clientA = {}
local clientB = {}
local a = Charm.atom(0)
local b = Charm.atom(0)
local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload }
server.addSignalsToClient(clientA, { a = a })
server.addSignalsToClient(clientB, { b = b })
server.connect(function(target, payloads)
if target == clientA then
payloadsA = payloads
elseif target == clientB then
payloadsB = payloads
end
end)
server.flush()
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")
assert(payloadsA[1].data.b == nil, "Expected init payload to not contain atom 'b' for client A")
assert(payloadsB[1].type == "init", "Expected first payload to be init for client B")
assert(payloadsB[1].data.b == b(), "Expected init payload to contain atom 'b' for client B")
assert(payloadsB[1].data.a == nil, "Expected init payload to not contain atom 'a' for client B")
payloadsA = {}
payloadsB = {}
a(1)
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")
assert(payloadsA[1].data.b == nil, "Expected patch payload to not contain atom 'b' for client A")
assert(#payloadsB == 0, "Expected no payloads for client B")
payloadsA = {}
payloadsB = {}
b(1)
server.flush()
assert(#payloadsA == 0, "Expected no payloads for client A")
assert(payloadsB[1].type == "patch", "Expected first payload to be patch for client B")
assert(payloadsB[1].data.b == b(), "Expected patch payload to contain atom 'b' for client B")
assert(payloadsB[1].data.a == nil, "Expected patch payload to not contain atom 'a' for client B")
end)
test("should serialize state", function()
local client = {}
local a = Charm.atom({ 1, 2, 3, 4, 5 })
local payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a })
server.connect(function(_, p)
payloads = p
end)
server.flush()
a({ 1, 2, 0, 4 })
server.flush()
assert(payloads[1].data.a["3"] == 0, "Expected index 3 to be serialized as string key")
assert(patch.isNone(payloads[1].data.a["5"]), "Expected index 5 to be serialized as string key")
end)
test("should not send unchanged state", function()
local client = {}
local a = Charm.atom(0)
local payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a })
server.connect(function(_, p)
payloads = p
end)
server.flush()
assert(payloads[1].type == "init", "Expected first payload to be init")
assert(payloads[1].data.a == 0, "Expected init payload to contain atom 'a'")
payloads = {}
a(0)
server.flush()
assert(#payloads == 0, "Expected no payloads to be sent for unchanged state")
end)
test.describe("historical", function()
test.beforeEach(function()
config.preserveHistory = true
end)
test.afterEach(function()
config.preserveHistory = false
end)
test("should replicate granular state changes", function()
local client = {}
local a = Charm.atom(0)
local b = Charm.atom(0)
local c = Charm.atom(0)
local target: unknown, payloads: { types.SyncPayload }
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 == 0, "Expected init payload to contain atom 'a'")
assert(payloads[1].data.b == 0, "Expected init payload to contain atom 'b'")
assert(payloads[1].data.c == 0, "Expected init payload to contain atom 'c'")
a(1)
a(2)
b(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 == 1, "Expected patch payload to contain atom 'a'")
assert(payloads[2].data.a == 2, "Expected patch payload to contain atom 'a'")
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 = DeepCharm.reactive({ value = 0 })
local b = DeepCharm.reactive({ value = 0 })
local c = DeepCharm.reactive({ value = 0 })
local target: unknown, payloads: { types.SyncPayload }
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 = {}
local a = Charm.atom(0)
local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload }
server.addSignalsToClient(clientA, { a = a })
server.connect(function(target, payloads)
if target == clientA then
payloadsA = payloads
elseif target == clientB then
payloadsB = payloads
end
end)
a(1)
a(2)
server.flush()
assert(payloadsA[1].type == "init", "Expected first payload to be init for client A")
assert(payloadsA[1].data.a == 1, "Expected init payload to contain atom 'a' for client A")
assert(payloadsA[2].type == "patch", "Expected second payload to be patch for client A")
assert(payloadsA[2].data.a == 2, "Expected second patch payload to contain atom 'a' with value 1 for client A")
server.addSignalsToClient(clientB, { a = a })
a(3)
a(4)
server.flush()
assert(payloadsA[1].type == "patch", "Expected first payload to be patch for client A")
assert(payloadsA[1].data.a == 3, "Expected patch payload to contain atom 'a' for client A")
assert(payloadsA[2].data.a == 4, "Expected patch payload to contain atom 'a' for client A")
assert(payloadsB[1].type == "init", "Expected first payload to be init for client B")
assert(payloadsB[1].data.a == 3, "Expected init payload to contain atom 'a' for client B")
assert(payloadsB[2].type == "patch", "Expected second payload to be patch for client B")
assert(payloadsB[2].data.a == 4, "Expected second patch payload to contain atom 'a' with value 3 for client B")
end)
test("should init new atoms", function()
local client = {}
local a = Charm.atom(0)
local b = Charm.atom(0)
local payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a })
server.connect(function(_, ...)
payloads = ...
end)
server.flush()
assert(payloads[1].type == "init", "Expected first payload to be init")
assert(payloads[1].data.a == 0, "Expected init payload to contain atom 'a'")
server.addSignalsToClient(client, { b = b })
a(1)
a(2)
b(1)
b(2)
server.flush()
assert(payloads[1].type == "patch", "Expected first payload to be patch")
assert(payloads[1].data.a == 1, "Expected patch payload to contain atom 'a'")
assert(payloads[2].data.a == 2, "Expected patch payload to contain atom 'a'")
assert(payloads[3].type == "init", "Expected third payload to be init")
assert(payloads[3].data.b == 1, "Expected init payload to contain atom 'b'")
assert(payloads[4].type == "patch", "Expected fourth payload to be patch")
assert(payloads[4].data.b == 2, "Expected patch payload to contain atom 'b'")
end)
test("should not send unchanged state", function()
local client = {}
local a = Charm.atom(0)
local payloads: { types.SyncPayload }
server.addSignalsToClient(client, { a = a })
server.connect(function(_, p)
payloads = p
end)
server.flush()
assert(payloads[1].type == "init", "Expected first payload to be init")
assert(payloads[1].data.a == 0, "Expected init payload to contain atom 'a'")
payloads = {}
a(0)
server.flush()
assert(#payloads == 0, "Expected no payloads to be sent for unchanged state")
end)
end)
return {}