refactor!: migrate to monorepo (#23)

* refactor!: create monorepo

* chore: prepare release, set up workflows

* refactor: rename `modules` to `src`

* docs: add docs for all packages

* refactor: rename root project file

* chore: bump deps

* build: rename `lint`, add `pack` script

* revert: delete pack script

This will not be useful for development.
This commit is contained in:
richard 2024-10-12 18:29:15 -07:00 • committed by GitHub
parent b038bf1f70
commit 08c7e690d5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
81 changed files with 917 additions and 570 deletions

View file

@ -0,0 +1,32 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Charm = require(ReplicatedStorage.DevPackages.Charm)
local computed = Charm.computed
local atom = Charm.atom
local function parameterGenerator()
return atom(0)
end
local benchmarks = {
atom = function(_, state: any)
for _ = 1, 500 do
state(math.random(1, 2))
end
end,
computed = function(_, state: any)
computed(function()
return 2 * state()
end)
for _ = 1, 500 do
state(math.random(1, 2))
end
end,
}
return {
ParameterGenerator = parameterGenerator,
Functions = benchmarks,
}

View file

@ -0,0 +1,121 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Charm = require(ReplicatedStorage.DevPackages.Charm)
local atom = Charm.atom
local subscribe = Charm.subscribe
local CharmSync = require(ReplicatedStorage.DevPackages.CharmSync)
local client = CharmSync.client
return function()
it("syncs state payload", function()
local a = atom(1)
local client = client({ atoms = { a = a } })
client:sync({
type = "init",
data = { a = 2 },
})
expect(a()).to.equal(2)
end)
it("syncs state patch", function()
local a = atom({ b = 1, c = 2 })
local previous = a()
local client = client({ atoms = { a = a } })
client:sync({
type = "init",
data = { a = a() },
})
expect(a()).to.equal(previous)
client:sync({
type = "patch",
data = { a = { b = 3 } },
})
expect(a()).to.never.equal(previous)
expect(a().b).to.equal(3)
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 } })
local calls = {}
subscribe(a, function(value)
table.insert(calls, value)
end)
client:sync({
type = "init",
data = { a = a() },
})
client:sync({
type = "patch",
data = { a = 2 },
}, {
type = "patch",
data = { a = 3 },
})
expect(a()).to.equal(3)
expect(calls[1]).to.equal(2)
expect(calls[2]).to.equal(3)
end)
it("batches patches", function()
local a = atom(0)
local b = atom(0)
local client = client({ atoms = { a = a, b = b } })
local states = {}
subscribe(a, function()
states.b = b()
end)
subscribe(b, function()
states.a = a()
end)
client:sync({
type = "init",
data = { a = a(), b = b() },
})
client:sync({
type = "patch",
data = { a = 1, b = 2 },
})
expect(a()).to.equal(1)
expect(b()).to.equal(2)
expect(states.a).to.equal(1)
expect(states.b).to.equal(2)
end)
end

View file

@ -0,0 +1,19 @@
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

@ -0,0 +1,101 @@
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)
end

View file

@ -0,0 +1,97 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Charm = require(ReplicatedStorage.DevPackages.Charm)
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
it("sends state to new players", function()
local a = atom(1)
local server = server({ atoms = { a = a }, interval = -1 })
local player: Player, payload: SyncPayload
local disconnect = server:connect(function(...)
player, payload = ...
end)
server:hydrate(target)
expect(player).to.equal(target)
expect(payload.type).to.equal("init")
expect(payload.data.a).to.equal(1)
disconnect()
end)
it("sends state patches", function()
local a = atom({ b = 1, c = 2 } :: { [string]: number? })
local server = server({ atoms = { a = a }, interval = -1 })
local player: Player, payload: SyncPayload
local disconnect = server:connect(function(...)
player, payload = ...
end)
a({ b = 1, c = 5 })
server:_sendPatch(target)
expect(player).to.equal(target)
expect(payload.type).to.equal("patch")
expect(payload.data.a.b).to.never.be.ok()
expect(payload.data.a.c).to.equal(5)
a({ b = 1, d = 1 })
server:_sendPatch(target)
expect(payload.data.a.b).to.never.be.ok()
expect(payload.data.a.c).to.equal(patch.NONE)
expect(payload.data.a.d).to.equal(1)
disconnect()
end)
it("preserves history", function()
local a = atom(1)
local b = atom(1)
local payloads: { SyncPayload }
local server = server({
atoms = { a = a, b = b },
interval = -1,
preserveHistory = true,
})
local disconnect = server:connect(function(_, ...)
payloads = { ... }
end)
a(2)
b(2)
server:_sendPatch(target)
expect(#payloads).to.equal(1)
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)
b(3)
server:_sendPatch(target)
expect(#payloads).to.equal(2)
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)
end

View file

@ -0,0 +1,36 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Charm = require(ReplicatedStorage.DevPackages.Charm)
local atom = Charm.atom
local validate = require(ReplicatedStorage.DevPackages.CharmSync.validate)
return function()
it("validates inputs", function()
local cases: { [string]: { value: any, expected: any } } = {
array = { value = { 1, 2, 3 }, expected = true },
array_sparse = { value = { 1, nil :: any, 3 }, expected = true },
array_sparse_invalid = { value = { [1] = 0, [3] = 0 }, expected = false },
map_decimal_key = { value = { [1.5] = 0 }, expected = false },
map_mixed_key = { value = { [1] = 0, a = 0 }, expected = false },
number = { value = 1, expected = true },
number_infinity = { value = math.huge, expected = false },
number_nan = { value = 0 / 0, expected = false },
nested_atom = { value = atom(0), expected = false },
}
for key, case in next, cases do
local success, result = pcall(function()
validate(case.value, key)
end)
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)
end

View file

@ -0,0 +1,62 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Charm = require(ReplicatedStorage.DevPackages.Charm)
local atom = Charm.atom
local store = require(ReplicatedStorage.DevPackages.Charm.store)
return function()
it("updates state", function()
local source = atom(1)
source(2)
expect(source()).to.equal(2)
source(function(x)
return x + 1
end)
expect(source()).to.equal(3)
end)
it("calls listeners", function()
local source = atom(1)
local value
local function listener()
value = source()
end
store.listeners[source][listener] = true
source(2)
expect(value).to.equal(2)
end)
it("skips pending listeners", function()
local source = atom(1)
local outer = 0
local inner = 0
local function listener()
outer += 1
local function listener()
inner += 1
end
store.listeners[source][listener] = true
end
store.listeners[source][listener] = true
source(2)
expect(outer).to.equal(1)
expect(inner).to.equal(0)
source(3)
expect(outer).to.equal(2)
expect(inner).to.equal(1)
end)
it("takes a custom comparator", function()
local max = atom(1, {
equals = function(prev, next)
return next < prev
end,
})
max(2)
expect(max()).to.equal(2)
max(1)
expect(max()).to.equal(2)
max(3)
expect(max()).to.equal(3)
end)
end

View file

@ -0,0 +1,64 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Charm = require(ReplicatedStorage.DevPackages.Charm)
local atom = Charm.atom
local computed = Charm.computed
local subscribe = Charm.subscribe
local effect = Charm.effect
local store = require(ReplicatedStorage.DevPackages.Charm.store)
return function()
it("batches updates", function()
local source = atom(1)
local value = 0
subscribe(source, function()
value += 1
end)
store.batch(function()
source(2)
source(3)
source(4)
end)
expect(value).to.equal(1)
end)
it("handles duplicate listeners", function()
local a = atom(1)
local b = atom(1)
local value = 0
effect(function()
value += 1
a()
b()
end)
value = 0 -- effect() will run once initially
store.batch(function()
a(2)
b(2)
a(3)
end)
expect(value).to.equal(1)
end)
it("batches computed atoms", function()
local source = atom(1)
local updates = 0
local computations = 0
local double = computed(function()
computations += 1
return source() * 2
end)
computations = 0 -- computed atoms will run once initially
subscribe(double, function()
updates += 1
end)
store.batch(function()
source(2)
source(3)
source(4)
end)
expect(updates).to.equal(1)
expect(computations).to.equal(1)
expect(double()).to.equal(8)
end)
end

View file

@ -0,0 +1,64 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Charm = require(ReplicatedStorage.DevPackages.Charm)
local atom = Charm.atom
local subscribe = Charm.subscribe
local store = require(ReplicatedStorage.DevPackages.Charm.store)
return function()
it("gracefully handles yielding", function()
expect(function()
store.capture(function()
task.wait(10)
return 1
end)
end).to.throw()
end)
it("captures atoms from a function", function()
local a, b, c = atom(1), atom(1), atom(2)
local deps, result = store.capture(function()
return a() + b()
end)
expect(deps[a]).to.be.ok()
expect(deps[b]).to.be.ok()
expect(deps[c]).to.never.be.ok()
expect(result).to.equal(2)
end)
it("captures an atom by itself", function()
local a = atom(1)
local deps, result = store.capture(a)
expect(deps[a]).to.be.ok()
expect(result).to.equal(1)
end)
it("skips atoms in inner capture calls", function()
local a = atom(1)
local b = atom(1)
local inner
local outer = store.capture(function()
inner = store.capture(function()
return b()
end)
return a()
end)
assert(outer[a], "outer did not capture a")
assert(outer[b] == nil, "outer captured b")
assert(inner[a] == nil, "inner captured a")
assert(inner[b], "inner did not capture b")
end)
it("should not capture notified dependents", function()
local state = atom(1)
subscribe(state, function() end)
local deps = store.capture(function()
return state(2)
end)
expect(deps[state]).to.never.be.ok()
end)
end

View file

@ -0,0 +1,105 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Charm = require(ReplicatedStorage.DevPackages.Charm)
local atom = Charm.atom
local computed = Charm.computed
local store = require(ReplicatedStorage.DevPackages.Charm.store)
local collect = require(script.Parent.Parent.utils.collect)
local count = require(script.Parent.Parent.utils.count)
return function()
it("memoizes the result", function()
local calls = 0
local a = atom(1)
local b = atom(2)
local double = computed(function()
calls += 1
return a() * b()
end)
expect(calls).to.equal(1)
a(1)
expect(calls).to.equal(1)
a(2)
expect(calls).to.equal(2)
b(3)
expect(calls).to.equal(3)
double()
expect(calls).to.equal(3)
end)
it("can be nested", function()
local source = atom(1)
local double = computed(function()
return source() * 2
end)
local quadruple = computed(function()
return double() * 2
end)
expect(quadruple()).to.equal(4)
source(2)
expect(quadruple()).to.equal(8)
end)
it("collects computed atoms", function()
local source = atom(0)
computed(function()
return source()
end)
collect()
assert(count(store.listeners[source]) == 0, "computed atom did not gc")
end)
it("collects nested computed atoms", function()
local source = atom(0)
do
-- Declare this in another scope to ensure no references exist at the
-- time of garbage collection.
local double = computed(function()
return source() * 2
end)
computed(function()
return double() * 2
end)
end
collect()
assert(count(store.listeners[source]) == 0, "nested computed atom did not gc")
end)
it("tracks conditional dependencies", function()
local condition = atom(false)
local a = atom(0)
local b = atom(1)
local reruns = 0
local current
-- assign variable to avoid gc
local _ = computed(function()
reruns += 1
current = if condition() then a() else b()
return current
end)
expect(reruns).to.equal(1)
expect(current).to.equal(1)
condition(true)
expect(reruns).to.equal(2)
expect(current).to.equal(0)
a(1)
b(2)
expect(reruns).to.equal(3)
expect(current).to.equal(1)
condition(false)
expect(reruns).to.equal(4)
expect(current).to.equal(2)
end)
end

View file

@ -0,0 +1,96 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Charm = require(ReplicatedStorage.DevPackages.Charm)
local atom = Charm.atom
local effect = Charm.effect
return function()
it("runs on state change", function()
local a = atom(0)
local b = atom(0)
local current
local unsubscribe = effect(function()
current = a() + b()
end)
expect(current).to.equal(0)
for index = 1, 3 do
a(index)
expect(current).to.equal(index)
end
for index = 1, 3 do
b(index)
expect(current).to.equal(a() + index)
end
unsubscribe()
a(4)
expect(current).to.equal(6)
end)
it("accepts a cleanup function", function()
local a = atom(0)
local cleanups = 0
local unsubscribe = effect(function()
a()
return function()
cleanups += 1
end
end)
expect(cleanups).to.equal(0)
a(1)
expect(cleanups).to.equal(1)
unsubscribe()
expect(cleanups).to.equal(2)
end)
it("tracks conditional dependencies", function()
local condition = atom(false)
local a = atom(0)
local b = atom(1)
local reruns = 0
local current
effect(function()
reruns += 1
current = if condition() then a() else b()
end)
expect(reruns).to.equal(1)
expect(current).to.equal(1)
condition(true)
expect(reruns).to.equal(2)
expect(current).to.equal(0)
a(1)
b(2)
expect(reruns).to.equal(3)
expect(current).to.equal(1)
condition(false)
expect(reruns).to.equal(4)
expect(current).to.equal(2)
end)
it("allows self-cleanup", function()
local a = atom(1)
local cleanedUp = false
local cleanup
cleanup = effect(function()
if cleanup then
assert(not cleanedUp, "cleanup ran twice")
cleanup()
cleanedUp = true
end
a()
end)
a(2)
a(3)
end)
end

View file

@ -0,0 +1,19 @@
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

@ -0,0 +1,97 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Charm = require(ReplicatedStorage.DevPackages.Charm)
local atom = Charm.atom
local mapped = Charm.mapped
local observe = Charm.observe
local store = require(ReplicatedStorage.DevPackages.Charm.store)
return function()
it("returns mapped atoms", function()
local itemsAtom = atom({ "a", "b", "c" })
local mappedAtom = mapped(itemsAtom, function(value, key)
return string.upper(value), key
end)
local updates = 0
store.listeners[mappedAtom][function()
updates += 1
end] = true
for key = 1, 3 do
local item = mappedAtom()[key]
expect(item).to.equal(string.upper(itemsAtom()[key]))
end
itemsAtom({ "d", "e", "f" })
expect(updates).to.equal(1)
for key = 1, 3 do
local item = mappedAtom()[key]
expect(item).to.equal(string.upper(itemsAtom()[key]))
end
itemsAtom({ [1] = "d", [3] = "f" })
expect(mappedAtom()[2]).to.never.be.ok()
expect(updates).to.equal(2)
end)
it("uses key extractor", function()
local itemsAtom = atom({ { key = 1 }, { key = 2 }, { key = 3 } })
local mappedAtom = mapped(itemsAtom, function(item)
return item, item.key
end)
for key = 1, 3 do
local item = mappedAtom()[key]
expect(item).to.equal(itemsAtom()[key])
end
local previous = mappedAtom()
itemsAtom({ itemsAtom()[1], itemsAtom()[3] })
expect(mappedAtom()[1]).to.equal(previous[1])
expect(mappedAtom()[1]).to.equal(itemsAtom()[1])
expect(mappedAtom()[2]).to.never.be.ok()
expect(mappedAtom()[3]).to.equal(previous[3])
expect(mappedAtom()[3]).to.equal(itemsAtom()[2])
end)
it("allows observe()", function()
local itemsAtom = atom({
{ key = 1, value = "a" },
{ key = 2, value = "b" },
{ key = 3, value = "c" },
})
local mappedAtom = mapped(itemsAtom, function(item)
return item, item.key
end)
local added = 0
local deleted = 0
observe(mappedAtom, function(item, key)
expect(item).to.equal(itemsAtom()[key])
added += 1
return function()
expect(mappedAtom()[key]).to.never.be.ok()
deleted += 1
end
end)
expect(added).to.equal(3)
expect(deleted).to.equal(0)
itemsAtom({ itemsAtom()[1], itemsAtom()[3] })
expect(added).to.equal(3)
expect(deleted).to.equal(1)
itemsAtom({})
expect(added).to.equal(3)
expect(deleted).to.equal(3)
end)
end

View file

@ -0,0 +1,35 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Charm = require(ReplicatedStorage.DevPackages.Charm)
local atom = Charm.atom
local observe = Charm.observe
return function()
it("observes lifetime", function()
local itemsAtom = atom({ "a", "b", "c" })
local state = {}
local cleanup = observe(itemsAtom, function(item, key)
state[key] = item
return function()
state[key] = nil
end
end)
for key = 1, 3 do
expect(state[key]).to.equal(itemsAtom()[key])
end
itemsAtom({ [4] = "d", [5] = "e", [6] = "f" })
for key = 1, 6 do
expect(state[key]).to.equal(itemsAtom()[key])
end
cleanup()
for key = 1, 6 do
expect(state[key]).to.equal(nil)
end
end)
end

View file

@ -0,0 +1,26 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Charm = require(ReplicatedStorage.DevPackages.Charm)
local atom = Charm.atom
local capture = Charm.capture
local peek = Charm.peek
return function()
it("calls an atom without capturing it", function()
local a, b, c = atom(1), atom(1), atom(1)
local deps, result = capture(function()
return a() + peek(b) + c()
end)
expect(deps[a]).to.be.ok()
expect(deps[b]).to.never.be.ok()
expect(deps[c]).to.be.ok()
expect(result).to.equal(3)
end)
it("passes through arguments", function()
expect(peek(1)).to.equal(1)
expect(peek(function()
return 1
end)).to.equal(1)
end)
end

View file

@ -0,0 +1,101 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Charm = require(ReplicatedStorage.DevPackages.Charm)
local atom = Charm.atom
local computed = Charm.computed
local subscribe = Charm.subscribe
local store = require(ReplicatedStorage.DevPackages.Charm.store)
local collect = require(script.Parent.Parent.utils.collect)
local count = require(script.Parent.Parent.utils.count)
return function()
it("runs on state change", function()
local current, previous
local source = atom(0)
local unsubscribe = subscribe(source, function(...)
current, previous = ...
end)
for index = 1, 3 do
source(index)
expect(current).to.equal(index)
expect(previous).to.equal(index - 1)
end
unsubscribe()
source(4)
expect(current).to.equal(3)
expect(previous).to.equal(2)
end)
it("prevents garbage collect", function()
local source = atom(1)
local updates = 0
-- Create a new scope to encapsulate variables with a limited lifetime, like
-- 'unsubscribe' and 'computed', to ensure they are garbage collected.
--
-- The idea here is to test whether subscribe() prevents every computed
-- atom in a chain from being garbage collected. If any are GCed, then
-- the subscription will stop updating and the test will fail.
do
local unsubscribe
do
local double = computed(function()
return source() * 2
end)
unsubscribe = subscribe(
computed(function()
return double() * 2
end),
function()
updates += 1
end
)
end
collect()
source(source() + 1)
assert(updates == 1, "subscription stopped updating")
assert(count(store.listeners[source]) == 1, "computed atom was garbage collected")
unsubscribe()
end
collect()
assert(count(store.listeners[source]) == 0, "computed atom did not collect")
end)
it("tracks conditional dependencies", function()
local condition = atom(false)
local a = atom(0)
local b = atom(1)
local reruns = 0
local current
subscribe(function()
return if condition() then a() else b()
end, function(state)
reruns += 1
current = state
end)
condition(true)
expect(reruns).to.equal(1)
expect(current).to.equal(0)
a(1)
b(2)
expect(reruns).to.equal(2)
expect(current).to.equal(1)
condition(false)
expect(reruns).to.equal(3)
expect(current).to.equal(2)
end)
end

5
tests/init.server.luau Normal file
View file

@ -0,0 +1,5 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local TestEZ = require(ReplicatedStorage.DevPackages.TestEZ)
TestEZ.TestBootstrap:run({ script.Parent }, TestEZ.Reporters.TextReporter)

View file

@ -0,0 +1,46 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Charm = require(ReplicatedStorage.DevPackages.Charm)
local atom = Charm.atom
local ReactCharm = require(ReplicatedStorage.DevPackages.ReactCharm)
local useAtom = ReactCharm.useAtom
local renderHook = require(script.Parent.Parent.utils.renderHook)
return function()
it("tracks atom state", function()
local state = atom(0)
local hook = renderHook(function()
return useAtom(state)
end)
expect(hook.result).to.equal(0)
state(1)
expect(hook.result).to.equal(1)
end)
it("accepts a molecule", function()
local state = atom(0)
local hook = renderHook(function()
return useAtom(function()
return 2 * state()
end)
end)
expect(hook.result).to.equal(0)
state(1)
expect(hook.result).to.equal(2)
end)
it("tracks dependencies", function()
local state = atom(0)
local hook = renderHook(function()
return useAtom(state, { state })
end)
expect(hook.result).to.equal(0)
state(1)
expect(hook.result).to.equal(1)
state = atom(0)
hook.rerender()
expect(hook.result).to.equal(0)
state(1)
expect(hook.result).to.equal(1)
end)
end

12
tests/utils/collect.luau Normal file
View file

@ -0,0 +1,12 @@
--[=[
Trigger a garbage collection cycle by allocating a large amount of junk memory
and then waiting for the next frame.
]=]
local function collect()
for _ = 1, 1e4 do
local _ = table.create(1e3)
end
task.wait()
end
return collect

15
tests/utils/count.luau Normal file
View file

@ -0,0 +1,15 @@
--[=[
Counts the number of elements in a table.
@param object The table to count.
@return The number of elements in the table.
]=]
local function count(object: any): number
local count = 0
for _ in next, object do
count += 1
end
return count
end
return count

View file

@ -0,0 +1,42 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
_G.__ROACT_17_MOCK_SCHEDULER__ = true
local React = require(ReplicatedStorage.DevPackages.React)
local ReactRoblox = require(ReplicatedStorage.DevPackages.ReactRoblox)
type RenderedHook<Props, Result> = {
result: Result,
rerender: (Props?) -> (),
unmount: () -> (),
}
local function renderHook<Props, Result>(callback: (Props) -> Result, props: Props?): RenderedHook<Props, Result>
local root = ReactRoblox.createLegacyRoot(Instance.new("Folder"))
local hook = {}
local function Component(props)
hook.result = callback(props)
end
function hook.rerender(newProps)
props = newProps or props
ReactRoblox.act(function()
root:render(React.createElement(Component, props))
end)
end
function hook.unmount()
ReactRoblox.act(function()
root:unmount()
end)
end
ReactRoblox.act(function()
hook.rerender(props)
end)
return hook
end
return renderHook

View file

@ -0,0 +1,36 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Charm = require(ReplicatedStorage.DevPackages.Charm)
local atom = Charm.atom
local Vide = require(ReplicatedStorage.DevPackages.Vide)
local root = Vide.root
local VideCharm = require(ReplicatedStorage.DevPackages.VideCharm)
local useAtom = VideCharm.useAtom
return function()
it("tracks atom state", function()
local state = atom(0)
local source
local cleanup = root(function()
source = useAtom(state)
end)
expect(source()).to.equal(0)
state(1)
expect(source()).to.equal(1)
cleanup()
end)
it("accepts a molecule", function()
local state = atom(0)
local source
local cleanup = root(function()
source = useAtom(function()
return 2 * state()
end)
end)
expect(source()).to.equal(0)
state(1)
expect(source()).to.equal(2)
cleanup()
end)
end