mirror of
https://github.com/littensy/charm.git
synced 2026-10-22 12:41:37 +00:00
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>
This commit is contained in:
parent
d0b7cff943
commit
573533058f
148 changed files with 9222 additions and 4384 deletions
220
packages/charm/test/computed.test.luau
Normal file
220
packages/charm/test/computed.test.luau
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
local Charm = require("../src")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
local atom = Charm.atom
|
||||
local computed = Charm.computed
|
||||
local effect = Charm.effect
|
||||
local signal = Charm.signal
|
||||
|
||||
test("should correctly propagate changes through computed core", function()
|
||||
local src, setSrc = signal(0)
|
||||
local c1 = computed(function()
|
||||
return src() % 2
|
||||
end)
|
||||
local c2 = computed(function()
|
||||
return c1()
|
||||
end)
|
||||
local c3 = computed(function()
|
||||
return c2()
|
||||
end)
|
||||
|
||||
c3()
|
||||
setSrc(1) -- c1 -> dirty, c2 -> toCheckDirty, c3 -> toCheckDirty
|
||||
c2() -- c1 -> none, c2 -> none
|
||||
setSrc(3) -- c1 -> dirty, c2 -> toCheckDirty
|
||||
|
||||
assert(c3() == 1, "bad final value")
|
||||
end)
|
||||
|
||||
test("should propagate updated source value through chained computations", function()
|
||||
local src, setSrc = signal(0)
|
||||
local a = computed(function()
|
||||
return src()
|
||||
end)
|
||||
local b = computed(function()
|
||||
return a() % 2
|
||||
end)
|
||||
local c = computed(function()
|
||||
return src()
|
||||
end)
|
||||
local d = computed(function()
|
||||
return b() + c()
|
||||
end)
|
||||
|
||||
assert(d() == 0, "bad initial value")
|
||||
setSrc(2)
|
||||
assert(d() == 2, "bad updated value")
|
||||
end)
|
||||
|
||||
test("should handle flags are indirectly updated during checkDirty", function()
|
||||
local a, setA = signal(false)
|
||||
local b = computed(function()
|
||||
return a()
|
||||
end)
|
||||
local c = computed(function()
|
||||
b()
|
||||
return 0
|
||||
end)
|
||||
local d = computed(function()
|
||||
c()
|
||||
return b()
|
||||
end)
|
||||
|
||||
assert(d() == false, "bad initial value")
|
||||
setA(true)
|
||||
assert(d() == true, "bad updated value")
|
||||
end)
|
||||
|
||||
test("should not update if the signal value is reverted", function()
|
||||
local times = 0
|
||||
|
||||
local src, setSrc = signal(0)
|
||||
local c1 = computed(function()
|
||||
times += 1
|
||||
return src()
|
||||
end)
|
||||
c1()
|
||||
assert(times == 1, "computed did not run")
|
||||
setSrc(1)
|
||||
setSrc(0)
|
||||
c1()
|
||||
assert(times == 1, "computed ran when it should not")
|
||||
end)
|
||||
|
||||
test("should receive previous value", function()
|
||||
local calls = {}
|
||||
local src, setSrc = signal(0)
|
||||
local c1 = computed(function(current: number?): number
|
||||
local incoming = src()
|
||||
return if current == incoming or incoming % 2 == 1 then current or incoming else incoming
|
||||
end)
|
||||
|
||||
effect(function()
|
||||
table.insert(calls, c1())
|
||||
end)
|
||||
|
||||
setSrc(2)
|
||||
setSrc(3)
|
||||
assert(c1() == 2, "should skip odd value")
|
||||
|
||||
setSrc(4)
|
||||
setSrc(5)
|
||||
assert(c1() == 4, "should skip odd value")
|
||||
assert(table.concat(calls) == "024", `should only call on even updates, got {table.concat(calls)}`)
|
||||
end)
|
||||
|
||||
-- https://github.com/vuejs/core/blob/minor/packages/reactivity/__tests__/computed.spec.ts#L875
|
||||
test("should chained computeds keep reactivity when computed effect happens", function()
|
||||
local v = atom("Hello")
|
||||
local c = computed(function()
|
||||
v(v() .. " World")
|
||||
return v()
|
||||
end)
|
||||
local d = computed(function()
|
||||
return c()
|
||||
end)
|
||||
local e = computed(function()
|
||||
return d()
|
||||
end)
|
||||
local res = computed(function()
|
||||
return d() .. " | " .. e()
|
||||
end)
|
||||
|
||||
assert(res() == "Hello World | Hello World", res())
|
||||
|
||||
v(v() .. " World")
|
||||
assert(res() == "Hello World World World World | Hello World World World World", res())
|
||||
end)
|
||||
|
||||
-- https://github.com/vuejs/core/blob/minor/packages/reactivity/__tests__/computed.spec.ts#L580
|
||||
test("should not trigger effect scheduler by recursive computed effect", function()
|
||||
local v = atom("Hello")
|
||||
local c = computed(function()
|
||||
v(v() .. " World")
|
||||
return v()
|
||||
end)
|
||||
local comp = computed(function()
|
||||
return c()
|
||||
end)
|
||||
|
||||
assert(comp() == "Hello World", comp())
|
||||
|
||||
v(v() .. " World")
|
||||
assert(comp() == "Hello World World World World", comp())
|
||||
end)
|
||||
|
||||
-- https://github.com/vuejs/core/blob/minor/packages/reactivity/__tests__/computed.spec.ts#L528
|
||||
test("should not override queried MaybeDirty result", function()
|
||||
local v1 = atom(nil :: any)
|
||||
local v2 = atom(false)
|
||||
local c1 = computed(function()
|
||||
local c = v1()
|
||||
if not v1() then
|
||||
c = { v = atom(0) }
|
||||
v1(c)
|
||||
end
|
||||
return c.v()
|
||||
end)
|
||||
local c2 = computed(function()
|
||||
if not v2() then
|
||||
return "no"
|
||||
end
|
||||
return if c1() then "yes" else "no"
|
||||
end)
|
||||
local c3 = computed(function()
|
||||
return c2()
|
||||
end)
|
||||
|
||||
c3()
|
||||
v2(true)
|
||||
|
||||
c3()
|
||||
v1().v(999)
|
||||
|
||||
assert(c3() == "yes", "should return correct value after update")
|
||||
end)
|
||||
|
||||
-- https://github.com/vuejs/core/blob/minor/packages/reactivity/__tests__/computed.spec.ts#L503
|
||||
test("should trigger effect even computed already dirty", function()
|
||||
local calls: { string } = {}
|
||||
local v = atom(0)
|
||||
local c1 = computed(function()
|
||||
if v() == 0 then
|
||||
v(1)
|
||||
end
|
||||
return "foo"
|
||||
end)
|
||||
local c2 = computed(function()
|
||||
return v() .. c1()
|
||||
end)
|
||||
|
||||
effect(function()
|
||||
table.insert(calls, c2())
|
||||
end)
|
||||
assert(table.concat(calls, ",") == "0foo", table.concat(calls, ","))
|
||||
assert(v() == 1, `expected 1 for v(), got {v()}`)
|
||||
v(2)
|
||||
assert(table.concat(calls, ",") == "0foo,2foo", table.concat(calls, ","))
|
||||
assert(v() == 2, `expected 2 for v(), got {v()}`)
|
||||
end)
|
||||
|
||||
test("should handle inner effect clearing outer computed's deps on rerun", function()
|
||||
local src, setSrc = signal(false)
|
||||
local stopEffect: () -> ()
|
||||
local c = computed(function()
|
||||
src()
|
||||
effect(function()
|
||||
return function()
|
||||
stopEffect()
|
||||
end
|
||||
end)
|
||||
effect(function() end)
|
||||
return 0
|
||||
end)
|
||||
stopEffect = effect(function()
|
||||
c()
|
||||
end)
|
||||
setSrc(true)
|
||||
end)
|
||||
|
||||
return {}
|
||||
576
packages/charm/test/effect.test.luau
Normal file
576
packages/charm/test/effect.test.luau
Normal file
|
|
@ -0,0 +1,576 @@
|
|||
local Charm = require("../src")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
local computed = Charm.computed
|
||||
local effect = Charm.effect
|
||||
local effectScope = Charm.effectScope
|
||||
local endBatch = Charm.endBatch
|
||||
local onCleanup = Charm.onCleanup
|
||||
local setActiveSub = Charm.setActiveSub
|
||||
local signal = Charm.signal
|
||||
local startBatch = Charm.startBatch
|
||||
|
||||
test("should clear subscriptions when untracked by all subscribers", function()
|
||||
local bRunTimes = 0
|
||||
|
||||
local a, setA = signal(1)
|
||||
local b = computed(function()
|
||||
bRunTimes += 1
|
||||
return a() * 2
|
||||
end)
|
||||
local stopEffect = effect(function()
|
||||
b()
|
||||
end)
|
||||
|
||||
assert(bRunTimes == 1, "should run initially")
|
||||
setA(2)
|
||||
assert(bRunTimes == 2, "should run after change")
|
||||
stopEffect()
|
||||
setA(3)
|
||||
assert(bRunTimes == 2, "should not run after stop")
|
||||
end)
|
||||
|
||||
test("should not run untracked inner effect", function()
|
||||
local a, setA = signal(3)
|
||||
local b = computed(function()
|
||||
return a() > 0
|
||||
end)
|
||||
|
||||
effect(function()
|
||||
if b() then
|
||||
effect(function()
|
||||
if a() == 0 then
|
||||
error("bad")
|
||||
end
|
||||
end)
|
||||
end
|
||||
end)
|
||||
|
||||
setA(2)
|
||||
setA(1)
|
||||
setA(0)
|
||||
end)
|
||||
|
||||
test("should run outer effect first", function()
|
||||
local a, setA = signal(1)
|
||||
local b, setB = signal(1)
|
||||
|
||||
effect(function()
|
||||
if a() > 0 then
|
||||
effect(function()
|
||||
b()
|
||||
if a() == 0 then
|
||||
error("bad")
|
||||
end
|
||||
end)
|
||||
end
|
||||
end)
|
||||
|
||||
startBatch()
|
||||
setB(0)
|
||||
setA(0)
|
||||
endBatch()
|
||||
end)
|
||||
|
||||
test("should not trigger inner effect when resolve maybe dirty", function()
|
||||
local a, setA = signal(0)
|
||||
local b = computed(function()
|
||||
return a() % 2
|
||||
end)
|
||||
|
||||
local innerTriggerTimes = 0
|
||||
|
||||
effect(function()
|
||||
effect(function()
|
||||
b()
|
||||
innerTriggerTimes += 1
|
||||
if innerTriggerTimes >= 2 then
|
||||
error("bad")
|
||||
end
|
||||
end)
|
||||
end)
|
||||
|
||||
setA(2)
|
||||
end)
|
||||
|
||||
test("should notify inner effects in the same order as non-inner effects", function()
|
||||
local getA, setA = signal(0)
|
||||
local getB, setB = signal(0)
|
||||
local c = computed(function()
|
||||
return getA() - getB()
|
||||
end)
|
||||
local order1 = {}
|
||||
local order2 = {}
|
||||
local order3 = {}
|
||||
|
||||
effect(function()
|
||||
table.insert(order1, "effect1")
|
||||
getA()
|
||||
end)
|
||||
effect(function()
|
||||
table.insert(order1, "effect2")
|
||||
getA()
|
||||
getB()
|
||||
end)
|
||||
|
||||
effect(function()
|
||||
c()
|
||||
effect(function()
|
||||
table.insert(order2, "effect1")
|
||||
getA()
|
||||
end)
|
||||
effect(function()
|
||||
table.insert(order2, "effect2")
|
||||
getA()
|
||||
getB()
|
||||
end)
|
||||
end)
|
||||
|
||||
effectScope(function()
|
||||
effect(function()
|
||||
table.insert(order3, "effect1")
|
||||
getA()
|
||||
end)
|
||||
effect(function()
|
||||
table.insert(order3, "effect2")
|
||||
getA()
|
||||
getB()
|
||||
end)
|
||||
end)
|
||||
|
||||
table.clear(order1)
|
||||
table.clear(order2)
|
||||
table.clear(order3)
|
||||
|
||||
startBatch()
|
||||
setB(1)
|
||||
setA(1)
|
||||
endBatch()
|
||||
|
||||
assert(table.concat(order1, ", ") == "effect2, effect1", "wrong order 1")
|
||||
assert(table.concat(order2, ", ") == "effect2, effect1", "wrong order 2")
|
||||
assert(table.concat(order3, ", ") == "effect2, effect1", "wrong order 3")
|
||||
end)
|
||||
|
||||
test("should custom effect support batch", function()
|
||||
local function batchEffect(fn: () -> ())
|
||||
return effect(function()
|
||||
startBatch()
|
||||
local success, result: unknown = pcall(fn)
|
||||
endBatch()
|
||||
if not success then
|
||||
error(result)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
local logs: { string } = {}
|
||||
local a = signal(0)
|
||||
local b, setB = signal(0)
|
||||
|
||||
local aa = computed(function()
|
||||
table.insert(logs, "aa-0")
|
||||
if a() == 0 then
|
||||
setB(1)
|
||||
end
|
||||
table.insert(logs, "aa-1")
|
||||
return nil
|
||||
end)
|
||||
|
||||
local bb = computed(function()
|
||||
table.insert(logs, "bb")
|
||||
return b()
|
||||
end)
|
||||
|
||||
batchEffect(function()
|
||||
bb()
|
||||
end)
|
||||
batchEffect(function()
|
||||
aa()
|
||||
end)
|
||||
|
||||
assert(table.concat(logs, ", ") == "bb, aa-0, aa-1, bb", `wrong order: {table.concat(logs, ", ")}`)
|
||||
end)
|
||||
|
||||
test("should duplicate subscribers do not affect the notify order", function()
|
||||
local src1, setSrc1 = signal(0)
|
||||
local src2, setSrc2 = signal(0)
|
||||
local order: { string } = {}
|
||||
|
||||
effect(function()
|
||||
table.insert(order, "a")
|
||||
local currentSub = setActiveSub(nil)
|
||||
local isOne = src2() == 1
|
||||
setActiveSub(currentSub)
|
||||
if isOne then
|
||||
src1()
|
||||
end
|
||||
src2()
|
||||
src1()
|
||||
end)
|
||||
effect(function()
|
||||
table.insert(order, "b")
|
||||
src1()
|
||||
end)
|
||||
setSrc2(1) -- src1.subs: a -> b -> a
|
||||
|
||||
table.clear(order)
|
||||
setSrc1(src1() + 1)
|
||||
|
||||
assert(table.concat(order) == "ab", `wrong order: {table.concat(order)}`)
|
||||
end)
|
||||
|
||||
test("should handle side effect with inner effects", function()
|
||||
local a, setA = signal(0)
|
||||
local b, setB = signal(0)
|
||||
local order: { string } = {}
|
||||
|
||||
effect(function()
|
||||
effect(function()
|
||||
a()
|
||||
table.insert(order, "a")
|
||||
end)
|
||||
effect(function()
|
||||
b()
|
||||
table.insert(order, "b")
|
||||
end)
|
||||
assert(table.concat(order) == "ab", `wrong order: {table.concat(order)}`)
|
||||
|
||||
table.clear(order)
|
||||
setB(1)
|
||||
setA(1)
|
||||
end)
|
||||
|
||||
-- DEVIATION: This assertion was moved outside of the effect so that it
|
||||
-- runs after the batch updates have been processed
|
||||
assert(table.concat(order) == "ba", `wrong order: {table.concat(order)}`)
|
||||
end)
|
||||
|
||||
test("should handle flags are indirectly updated during checkDirty", function()
|
||||
local a, setA = signal(false)
|
||||
local b = computed(function()
|
||||
return a()
|
||||
end)
|
||||
local c = computed(function()
|
||||
b()
|
||||
return 0
|
||||
end)
|
||||
local d = computed(function()
|
||||
c()
|
||||
return b()
|
||||
end)
|
||||
|
||||
local triggers = 0
|
||||
|
||||
effect(function()
|
||||
d()
|
||||
triggers += 1
|
||||
end)
|
||||
assert(triggers == 1, "did not run initially")
|
||||
setA(true)
|
||||
assert(triggers == 2, "did not update indirectly")
|
||||
end)
|
||||
|
||||
test("should not execute skipped effects from previous failed flush when updating unrelated signal", function()
|
||||
local a, setA = signal(0)
|
||||
local b, setB = signal(0)
|
||||
local c, setC = signal(0)
|
||||
local d = computed(function()
|
||||
c()
|
||||
return 0
|
||||
end)
|
||||
|
||||
local effect3Executed = false
|
||||
|
||||
effect(function()
|
||||
a()
|
||||
end)
|
||||
effect(function()
|
||||
if a() == 2 then
|
||||
error("Error in effect 2")
|
||||
end
|
||||
end)
|
||||
effect(function()
|
||||
a()
|
||||
d()
|
||||
effect3Executed = true
|
||||
end)
|
||||
effect(function()
|
||||
b()
|
||||
end)
|
||||
|
||||
setA(1)
|
||||
|
||||
effect3Executed = false
|
||||
local success, err = pcall(setA, 2)
|
||||
assert(not success and string.find(tostring(err), "Error in effect 2"), `bad error: {err}`)
|
||||
|
||||
assert(not effect3Executed, "effect 3 should not have executed after failed flush")
|
||||
setB(1)
|
||||
assert(not effect3Executed, "effect 3 should not have executed after updating unrelated signal b")
|
||||
setC(1)
|
||||
assert(effect3Executed, "effect 3 should have executed after updating signal c")
|
||||
end)
|
||||
|
||||
test("should allow finite recursion", function()
|
||||
local src, setSrc = signal(0)
|
||||
local order = {}
|
||||
|
||||
effect(function()
|
||||
table.insert(order, src())
|
||||
setSrc(math.min(src() + 1, 5))
|
||||
end)
|
||||
assert(table.concat(order) == "012345", `bad order: {table.concat(order)}`)
|
||||
end)
|
||||
|
||||
test("should run cleanups in sequence", function()
|
||||
local order = {}
|
||||
|
||||
local a, setA = signal(false)
|
||||
local stopEffect = effect(function()
|
||||
a()
|
||||
onCleanup(function()
|
||||
table.insert(order, "a")
|
||||
end)
|
||||
onCleanup(function()
|
||||
table.insert(order, "b")
|
||||
end)
|
||||
end)
|
||||
|
||||
assert(table.concat(order) == "", "should not cleanup immediately")
|
||||
|
||||
setA(true)
|
||||
assert(table.concat(order) == "ab", `bad update: {table.concat(order)}`)
|
||||
|
||||
table.clear(order)
|
||||
stopEffect()
|
||||
assert(table.concat(order) == "ab", `bad dispose: {table.concat(order)}`)
|
||||
|
||||
table.clear(order)
|
||||
setA(false)
|
||||
assert(table.concat(order) == "", "should not cleanup after update")
|
||||
end)
|
||||
|
||||
test("should run inner effect cleanup", function()
|
||||
local cleanups = 0
|
||||
|
||||
local src, setSrc = signal(false)
|
||||
local stopEffect = effect(function()
|
||||
src()
|
||||
effect(function()
|
||||
onCleanup(function()
|
||||
cleanups += 1
|
||||
end)
|
||||
end)
|
||||
effect(function()
|
||||
onCleanup(function()
|
||||
cleanups += 1
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
|
||||
assert(cleanups == 0, "should not cleanup immediately")
|
||||
setSrc(true)
|
||||
assert(cleanups == 2, "should cleanup after outer update")
|
||||
stopEffect()
|
||||
assert(cleanups == 4, "should cleanup after outer dispose")
|
||||
setSrc(false)
|
||||
assert(cleanups == 4, "should not cleanup after update")
|
||||
end)
|
||||
|
||||
test("should run cleanups outside any evaluation context", function()
|
||||
local calls = 0
|
||||
local a, setA = signal(0)
|
||||
local b, setB = signal(0)
|
||||
local c = computed(function()
|
||||
if a() == 0 then
|
||||
effect(function()
|
||||
onCleanup(function()
|
||||
b()
|
||||
end)
|
||||
end)
|
||||
end
|
||||
return a()
|
||||
end)
|
||||
|
||||
effect(function()
|
||||
calls += 1
|
||||
c()
|
||||
end)
|
||||
assert(calls == 1, "should run once")
|
||||
|
||||
calls = 0
|
||||
setA(1)
|
||||
assert(calls == 1, "should run once after a update")
|
||||
|
||||
calls = 0
|
||||
setB(1)
|
||||
assert(calls == 0, "should not run after b update")
|
||||
end)
|
||||
|
||||
test("should not retrigger the effect if the cleanup modifies one of the dependencies", function()
|
||||
local a, setA = signal(0)
|
||||
local calls = {}
|
||||
|
||||
effect(function()
|
||||
table.insert(calls, a())
|
||||
return function()
|
||||
setA(2)
|
||||
end
|
||||
end)
|
||||
assert(#calls == 1, "should have run once initially")
|
||||
table.clear(calls)
|
||||
|
||||
setA(1)
|
||||
assert(#calls == 1, "should run once after an update")
|
||||
assert(calls[1] == 2, `bad call: {calls[1]}`)
|
||||
end)
|
||||
|
||||
test("should run the cleanup if the effect disposes itself", function()
|
||||
local a, setA = signal(0)
|
||||
local calls = 0
|
||||
local dispose: () -> ()
|
||||
|
||||
dispose = effect(function()
|
||||
if a() > 0 then
|
||||
dispose()
|
||||
return function()
|
||||
calls += 1
|
||||
end
|
||||
end
|
||||
end)
|
||||
assert(calls == 0, "should not have called cleanup initially")
|
||||
setA(1)
|
||||
assert(calls == 1, "should have called cleanup once after first update")
|
||||
setA(2)
|
||||
assert(calls == 1, "should not call cleanup again after second update")
|
||||
end)
|
||||
|
||||
test("should not run the effect if the cleanup function disposes it", function()
|
||||
local a, setA = signal(0)
|
||||
local calls = 0
|
||||
local dispose: () -> ()
|
||||
|
||||
dispose = effect(function()
|
||||
a()
|
||||
calls += 1
|
||||
return function()
|
||||
dispose()
|
||||
end
|
||||
end)
|
||||
assert(calls == 1, "should have run effect once initially")
|
||||
setA(1)
|
||||
assert(calls == 1, "should not run effect again after update")
|
||||
end)
|
||||
|
||||
test("should reset the cleanup if the effect throws", function()
|
||||
local a, setA = signal(0)
|
||||
local calls = 0
|
||||
|
||||
effect(function()
|
||||
if a() == 0 then
|
||||
return function()
|
||||
calls += 1
|
||||
end
|
||||
else
|
||||
error("hello")
|
||||
end
|
||||
end)
|
||||
assert(calls == 0, "should not have called cleanup initially")
|
||||
local success, err = pcall(setA, 1)
|
||||
assert(not success and string.find(tostring(err), "hello"), `bad error: {err}`)
|
||||
assert(calls == 1, "should have called cleanup once after throwing")
|
||||
setA(0)
|
||||
assert(calls == 1, "should not call cleanup again after resetting value")
|
||||
end)
|
||||
|
||||
test("recursive effects should run and cleanup in order", function()
|
||||
local a, setA = signal(0)
|
||||
local order: { string } = {}
|
||||
|
||||
effect(function()
|
||||
local value = a()
|
||||
table.insert(order, `+{value}`)
|
||||
if value < 3 then
|
||||
setA(value + 1)
|
||||
end
|
||||
return function()
|
||||
table.insert(order, `-{value}`)
|
||||
end
|
||||
end)
|
||||
assert(table.concat(order) == "+0-0+1-1+2-2+3", `bad order: {table.concat(order)}`)
|
||||
table.clear(order)
|
||||
setA(100)
|
||||
assert(table.concat(order) == "-3+100", `bad order: {table.concat(order)}`)
|
||||
end)
|
||||
|
||||
test("should effects dispose in order", function()
|
||||
local order: { number } = {}
|
||||
|
||||
local src, setSrc = signal(false)
|
||||
local stopEffect = effect(function()
|
||||
src()
|
||||
for i = 1, 3 do
|
||||
effect(function()
|
||||
return function()
|
||||
table.insert(order, i)
|
||||
end
|
||||
end)
|
||||
end
|
||||
end)
|
||||
|
||||
assert(#order == 0, "should not cleanup immediately")
|
||||
setSrc(true)
|
||||
assert(table.concat(order) == "123", `bad rerun order: {table.concat(order)}`)
|
||||
table.clear(order)
|
||||
stopEffect()
|
||||
assert(table.concat(order) == "123", `bad dispose order: {table.concat(order)}`)
|
||||
table.clear(order)
|
||||
setSrc(false)
|
||||
assert(#order == 0, "should not cleanup after dispose")
|
||||
end)
|
||||
|
||||
test("should handle inner effect clearing outer effect's deps on rerun", function()
|
||||
local src, setSrc = signal(false)
|
||||
local stopEffect: () -> ()
|
||||
stopEffect = effect(function()
|
||||
src()
|
||||
effect(function()
|
||||
return function()
|
||||
stopEffect()
|
||||
end
|
||||
end)
|
||||
effect(function() end)
|
||||
end)
|
||||
setSrc(true)
|
||||
end)
|
||||
|
||||
test.skip("should handle inner effect cleanup disposing outer effect", function()
|
||||
local order: { number } = {}
|
||||
|
||||
local src, setSrc = signal(false)
|
||||
local stopEffect: () -> ()
|
||||
stopEffect = effect(function()
|
||||
src()
|
||||
table.insert(order, 0)
|
||||
for i = 1, 4 do
|
||||
effect(function()
|
||||
return function()
|
||||
table.insert(order, i)
|
||||
stopEffect()
|
||||
end
|
||||
end)
|
||||
end
|
||||
end)
|
||||
assert(table.concat(order) == "0", "should not cleanup immediately")
|
||||
|
||||
table.clear(order)
|
||||
setSrc(true)
|
||||
assert(table.concat(order) == "1234", `bad rerun order: {table.concat(order)}`)
|
||||
|
||||
table.clear(order)
|
||||
setSrc(false)
|
||||
assert(#order == 0, "should not cleanup after dispose")
|
||||
end)
|
||||
|
||||
return {}
|
||||
95
packages/charm/test/effectScope.test.luau
Normal file
95
packages/charm/test/effectScope.test.luau
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
local Charm = require("../src")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
local effect = Charm.effect
|
||||
local effectScope = Charm.effectScope
|
||||
local signal = Charm.signal
|
||||
local onCleanup = Charm.onCleanup
|
||||
|
||||
test("should not trigger after stop", function()
|
||||
local count, setCount = signal(1)
|
||||
|
||||
local triggers = 0
|
||||
|
||||
local stopScope = effectScope(function()
|
||||
effect(function()
|
||||
triggers += 1
|
||||
count()
|
||||
end)
|
||||
assert(triggers == 1, "did not run initially")
|
||||
|
||||
setCount(2)
|
||||
assert(triggers == 2, "did not run after update")
|
||||
end)
|
||||
|
||||
setCount(3)
|
||||
assert(triggers == 3, "did not run after update outside scope")
|
||||
stopScope()
|
||||
setCount(4)
|
||||
assert(triggers == 3, "ran after stop")
|
||||
end)
|
||||
|
||||
test("should run indirect scope cleanup", function()
|
||||
local cleanups = 0
|
||||
local cleanup: () -> ()
|
||||
|
||||
local stopScope = effectScope(function()
|
||||
cleanup = effectScope(function()
|
||||
onCleanup(function()
|
||||
cleanups += 1
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
|
||||
assert(cleanups == 0, "should not cleanup immediately")
|
||||
stopScope()
|
||||
assert(cleanups == 1, "should cleanup from parent dispose")
|
||||
cleanup()
|
||||
assert(cleanups == 1, "should skip redundant dispose")
|
||||
end)
|
||||
|
||||
test("should run indirect effect cleanup", function()
|
||||
local cleanups = 0
|
||||
local a, setA = signal(false)
|
||||
|
||||
local stopScope = effectScope(function()
|
||||
effect(function()
|
||||
a()
|
||||
onCleanup(function()
|
||||
cleanups += 1
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
|
||||
assert(cleanups == 0, "should not cleanup immediately")
|
||||
setA(true)
|
||||
assert(cleanups == 1, "should cleanup once")
|
||||
stopScope()
|
||||
assert(cleanups == 2, "should cleanup after outer dispose")
|
||||
setA(false)
|
||||
assert(cleanups == 2, "should not cleanup after update")
|
||||
end)
|
||||
|
||||
test("should dispose inner effects if created in an effect", function()
|
||||
local source, setSource = signal(1)
|
||||
local triggers = 0
|
||||
local dispose: () -> ()
|
||||
|
||||
effect(function()
|
||||
dispose = effectScope(function()
|
||||
effect(function()
|
||||
source()
|
||||
triggers += 1
|
||||
end)
|
||||
end)
|
||||
assert(triggers == 1, "did not run initially")
|
||||
end)
|
||||
|
||||
setSource(2)
|
||||
assert(triggers == 2, "did not run after update")
|
||||
dispose()
|
||||
setSource(3)
|
||||
assert(triggers == 2, "ran after dispose")
|
||||
end)
|
||||
|
||||
return {}
|
||||
200
packages/charm/test/errors.test.luau
Normal file
200
packages/charm/test/errors.test.luau
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
--!optimize 1
|
||||
|
||||
local Charm = require("../src")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
test("should disallow yielding", function()
|
||||
local success = pcall(function()
|
||||
Charm.effectScope(function()
|
||||
coroutine.yield()
|
||||
end)
|
||||
end)
|
||||
assert(not success, "should error on yield")
|
||||
end)
|
||||
|
||||
test.describe("traceback", function()
|
||||
local DEFAULT_ERROR = "Test error"
|
||||
|
||||
local function fail(message: string?)
|
||||
error(message or DEFAULT_ERROR, 0)
|
||||
end
|
||||
|
||||
local function indirectError(message: string?)
|
||||
fail(message)
|
||||
end
|
||||
|
||||
local function assertStackTrace(err: unknown, expectation: string?)
|
||||
expectation = expectation or DEFAULT_ERROR
|
||||
assert(type(err) == "string", "Expected error to be a string")
|
||||
assert(string.find(err, expectation), `Expected error message to be "{expectation}", got: {err}`)
|
||||
assert(string.find(err, "fail"), `Expected stack trace to contain fail, got: {err}`)
|
||||
assert(string.find(err, "indirectError"), `Expected stack trace to contain indirectError, got: {err}`)
|
||||
end
|
||||
|
||||
test("signal update", function()
|
||||
local _, err = pcall(function()
|
||||
local _, setSrc = Charm.signal(0)
|
||||
setSrc(function()
|
||||
indirectError()
|
||||
return 1
|
||||
end)
|
||||
end)
|
||||
assertStackTrace(err)
|
||||
end)
|
||||
|
||||
test("atom update", function()
|
||||
local _, err = pcall(function()
|
||||
local src = Charm.atom(0)
|
||||
src(function()
|
||||
indirectError()
|
||||
return 1
|
||||
end)
|
||||
end)
|
||||
assertStackTrace(err)
|
||||
end)
|
||||
|
||||
test("computed", function()
|
||||
local _, err = pcall(function()
|
||||
local src = Charm.computed(function()
|
||||
indirectError()
|
||||
end)
|
||||
src()
|
||||
end)
|
||||
assertStackTrace(err)
|
||||
end)
|
||||
|
||||
test("effect init", function()
|
||||
local _, err = pcall(function()
|
||||
Charm.effect(function()
|
||||
indirectError()
|
||||
end)
|
||||
end)
|
||||
assertStackTrace(err)
|
||||
end)
|
||||
|
||||
test("effect run", function()
|
||||
local _, err = pcall(function()
|
||||
local src, setSrc = Charm.signal(0)
|
||||
Charm.effect(function()
|
||||
if src() == 1 then
|
||||
indirectError()
|
||||
end
|
||||
end)
|
||||
setSrc(1)
|
||||
end)
|
||||
assertStackTrace(err)
|
||||
end)
|
||||
|
||||
test("effect cleanups", function()
|
||||
local _, err = pcall(function()
|
||||
local dispose = Charm.effect(function()
|
||||
Charm.onCleanup(function()
|
||||
indirectError("onCleanup")
|
||||
end)
|
||||
return function()
|
||||
indirectError("returned cleanup")
|
||||
end
|
||||
end)
|
||||
dispose()
|
||||
end)
|
||||
assertStackTrace(err, "onCleanup")
|
||||
assertStackTrace(err, "returned cleanup")
|
||||
end)
|
||||
|
||||
test("trigger", function()
|
||||
local _, err = pcall(function()
|
||||
Charm.trigger(function()
|
||||
indirectError()
|
||||
end)
|
||||
end)
|
||||
assertStackTrace(err)
|
||||
end)
|
||||
|
||||
test("untracked", function()
|
||||
local _, err = pcall(function()
|
||||
Charm.effectScope(function()
|
||||
Charm.untracked(function()
|
||||
indirectError()
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
assertStackTrace(err)
|
||||
end)
|
||||
|
||||
test("batch", function()
|
||||
local _, err = pcall(function()
|
||||
Charm.batch(function()
|
||||
indirectError()
|
||||
end)
|
||||
end)
|
||||
assertStackTrace(err)
|
||||
end)
|
||||
|
||||
test("subscribe getter", function()
|
||||
local _, err = pcall(function()
|
||||
Charm.subscribe(function()
|
||||
indirectError()
|
||||
end, function() end)
|
||||
end)
|
||||
assertStackTrace(err)
|
||||
end)
|
||||
|
||||
test("subscribe callback", function()
|
||||
local _, err = pcall(function()
|
||||
local src, setSrc = Charm.signal(0)
|
||||
Charm.subscribe(src, function()
|
||||
indirectError()
|
||||
end)
|
||||
setSrc(1)
|
||||
end)
|
||||
assertStackTrace(err)
|
||||
end)
|
||||
|
||||
test("listen getter", function()
|
||||
local _, err = pcall(function()
|
||||
Charm.listen(function()
|
||||
indirectError()
|
||||
end, function() end)
|
||||
end)
|
||||
assertStackTrace(err)
|
||||
end)
|
||||
|
||||
test("listen callback", function()
|
||||
local _, err = pcall(function()
|
||||
local src, setSrc = Charm.signal(0)
|
||||
Charm.listen(src, function()
|
||||
indirectError()
|
||||
end)
|
||||
setSrc(1)
|
||||
end)
|
||||
assertStackTrace(err)
|
||||
end)
|
||||
|
||||
test("mapped getter", function()
|
||||
local _, err = pcall(function()
|
||||
local src = Charm.mapped(function()
|
||||
indirectError()
|
||||
return { 1 }
|
||||
end, function()
|
||||
return 1
|
||||
end)
|
||||
src()
|
||||
end)
|
||||
assertStackTrace(err)
|
||||
end)
|
||||
|
||||
test("mapped transform", function()
|
||||
local _, err = pcall(function()
|
||||
local src = Charm.mapped(function()
|
||||
return { 1 }
|
||||
end, function()
|
||||
indirectError()
|
||||
return 1
|
||||
end)
|
||||
src()
|
||||
end)
|
||||
assertStackTrace(err)
|
||||
end)
|
||||
end)
|
||||
|
||||
return {}
|
||||
65
packages/charm/test/gc.test.luau
Normal file
65
packages/charm/test/gc.test.luau
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
local Charm = require("../src")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
local signal = Charm.signal
|
||||
local computed = Charm.computed
|
||||
local effect = Charm.effect
|
||||
local effectScope = Charm.effectScope
|
||||
|
||||
local function weakRef<T>(value: T): { value: T? }
|
||||
return setmetatable({ value = value }, { __mode = "v" }) :: any
|
||||
end
|
||||
|
||||
local function gc()
|
||||
for _ = 1, 2000 do
|
||||
local _ = table.create(100, 0)
|
||||
end
|
||||
end
|
||||
|
||||
--[[
|
||||
In alien-signals, a computed value unlinks from dependencies when its last
|
||||
parent scope is disposed. However, calling a computed value outside of a
|
||||
scope still links it to dependencies, which act as strong references.
|
||||
Computed values called in this way won't GC until unlinked from a scope.
|
||||
|
||||
This function releases a computed value by linking it to a scope and then
|
||||
immediately closing it, causing alien-signals to unlink the computed value
|
||||
from its dependencies and allow it to be garbage collected.
|
||||
]]
|
||||
local function unlink(getter: () -> unknown)
|
||||
effectScope(function()
|
||||
getter()
|
||||
end)()
|
||||
end
|
||||
|
||||
test("should release computed cache", function()
|
||||
local srcRef = weakRef({})
|
||||
local src, setSrc = signal(srcRef.value)
|
||||
local c = computed(function()
|
||||
return src()
|
||||
end)
|
||||
|
||||
c() -- cache src value
|
||||
setSrc(nil) -- release value
|
||||
unlink(c) -- release computed
|
||||
|
||||
gc()
|
||||
assert(srcRef.value == nil, "should release computed cache")
|
||||
end)
|
||||
|
||||
test("should not release effect for atom", function()
|
||||
local effectCalls = 0
|
||||
local src, setSrc = signal(0)
|
||||
|
||||
effect(function()
|
||||
effectCalls += 1
|
||||
src()
|
||||
end)
|
||||
assert(effectCalls == 1, "should call effect once")
|
||||
|
||||
gc()
|
||||
setSrc(src() + 1)
|
||||
assert(effectCalls == 2, "should call effect again")
|
||||
end)
|
||||
|
||||
return {}
|
||||
55
packages/charm/test/init.luau
Normal file
55
packages/charm/test/init.luau
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
local test = require("../../tests/test")
|
||||
|
||||
test.describe("computed", function()
|
||||
require("@self/computed.test")
|
||||
end)
|
||||
|
||||
test.describe("effect", function()
|
||||
require("@self/effect.test")
|
||||
end)
|
||||
|
||||
test.describe("effectScope", function()
|
||||
require("@self/effectScope.test")
|
||||
end)
|
||||
|
||||
test.describe("error", function()
|
||||
require("@self/errors.test")
|
||||
end)
|
||||
|
||||
test.describe("gc", function()
|
||||
require("@self/gc.test")
|
||||
end)
|
||||
|
||||
test.describe("listen", function()
|
||||
require("@self/listen.test")
|
||||
end)
|
||||
|
||||
test.describe("mapped", function()
|
||||
require("@self/mapped.test")
|
||||
end)
|
||||
|
||||
test.describe("observe", function()
|
||||
require("@self/observe.test")
|
||||
end)
|
||||
|
||||
test.describe("signal", function()
|
||||
require("@self/signal.test")
|
||||
end)
|
||||
|
||||
test.describe("subscribe", function()
|
||||
require("@self/subscribe.test")
|
||||
end)
|
||||
|
||||
test.describe("topology", function()
|
||||
require("@self/topology.test")
|
||||
end)
|
||||
|
||||
test.describe("trigger", function()
|
||||
require("@self/trigger.test")
|
||||
end)
|
||||
|
||||
test.describe("untracked", function()
|
||||
require("@self/untracked.test")
|
||||
end)
|
||||
|
||||
return {}
|
||||
77
packages/charm/test/listen.test.luau
Normal file
77
packages/charm/test/listen.test.luau
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
local Charm = require("../src")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
local signal = Charm.signal
|
||||
local listen = Charm.listen
|
||||
|
||||
test("should listen to selector", function()
|
||||
local source, setSource = signal(0)
|
||||
local current, previous
|
||||
|
||||
local unsub = listen(function()
|
||||
return source() + 1
|
||||
end, function(a, b)
|
||||
current = a
|
||||
previous = b
|
||||
end)
|
||||
|
||||
assert(current == 1 and previous == nil, "should run initially")
|
||||
setSource(0)
|
||||
assert(current == 1 and previous == nil, "should skip redundant update")
|
||||
setSource(1)
|
||||
assert(current == 2 and previous == 1, "should run with new value")
|
||||
unsub()
|
||||
setSource(2)
|
||||
assert(current == 2 and previous == 1, "should not run after unsub")
|
||||
end)
|
||||
|
||||
test("should run in untracked", function()
|
||||
local runs = 0
|
||||
local a, setA = signal(0)
|
||||
local b, setB = signal(0)
|
||||
|
||||
listen(a, function()
|
||||
runs += 1
|
||||
b()
|
||||
end)
|
||||
|
||||
assert(runs == 1, "should run once on init")
|
||||
setA(1)
|
||||
assert(runs == 2, "should run once on change")
|
||||
setB(1)
|
||||
assert(runs == 2, "should not run on b change")
|
||||
setA(2)
|
||||
assert(runs == 3, "should run on a change")
|
||||
end)
|
||||
|
||||
test("should run if first value is nil", function()
|
||||
local runs = 0
|
||||
local source, setSource = signal(nil :: number?)
|
||||
|
||||
listen(source, function()
|
||||
runs += 1
|
||||
end)
|
||||
|
||||
assert(runs == 1, "should run once on init")
|
||||
setSource(1)
|
||||
assert(runs == 2, "should run once on change")
|
||||
end)
|
||||
|
||||
test("should skip redundant updates", function()
|
||||
local order = {}
|
||||
local a, setA = signal(0)
|
||||
|
||||
listen(function(): "even" | "odd"
|
||||
return if a() % 2 == 0 then "even" else "odd"
|
||||
end, function(value)
|
||||
table.insert(order, value)
|
||||
end)
|
||||
|
||||
setA(1)
|
||||
setA(3)
|
||||
setA(4)
|
||||
setA(6)
|
||||
assert(table.concat(order) == "evenoddeven", `bad order: {table.concat(order)}`)
|
||||
end)
|
||||
|
||||
return {}
|
||||
128
packages/charm/test/mapped.test.luau
Normal file
128
packages/charm/test/mapped.test.luau
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
local Charm = require("../src")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
local signal = Charm.signal
|
||||
local mapped = Charm.mapped
|
||||
|
||||
test("should map state", function()
|
||||
local src, setSrc = signal({ 1, 2, 3 })
|
||||
local map = mapped(src, function(value: number)
|
||||
return value + 1
|
||||
end)
|
||||
|
||||
assert(table.concat(map()) == "234", table.concat(map()))
|
||||
setSrc({ 4, 5, 6 })
|
||||
assert(table.concat(map()) == "567", table.concat(map()))
|
||||
end)
|
||||
|
||||
test("should not map unchanged values", function()
|
||||
local calls = 0
|
||||
|
||||
local src, setSrc = signal({ 1, 2, 3 })
|
||||
local map = mapped(src, function(value: number)
|
||||
calls += 1
|
||||
return value + 1
|
||||
end)
|
||||
|
||||
assert(table.concat(map()) == "234", table.concat(map()))
|
||||
assert(calls == 3, "should map 3 values")
|
||||
|
||||
setSrc({ 1, 3, 3 })
|
||||
assert(table.concat(map()) == "244", table.concat(map()))
|
||||
assert(calls == 4, "should map 1 change")
|
||||
|
||||
setSrc({})
|
||||
assert(#map() == 0, "should be empty")
|
||||
assert(calls == 4, "should not map for removed values")
|
||||
end)
|
||||
|
||||
test("should not update if no values change", function()
|
||||
local calls = 0
|
||||
|
||||
local src, setSrc = signal({ 1, 2, 3 })
|
||||
local map = mapped(src, function(value: number)
|
||||
calls += 1
|
||||
return value + 1
|
||||
end)
|
||||
|
||||
local prev = map()
|
||||
assert(calls == 3, "should map 3 values")
|
||||
assert(table.concat(map()) == "234", table.concat(map()))
|
||||
|
||||
setSrc({ 4, 5, 6, 7 })
|
||||
setSrc({ 1, 2, 3 })
|
||||
map()
|
||||
assert(calls == 3, "should not map unchanged values")
|
||||
assert(table.concat(map()) == "234", table.concat(map()))
|
||||
assert(prev == map(), "should not create new table")
|
||||
end)
|
||||
|
||||
test("should filter values", function()
|
||||
local calls = 0
|
||||
|
||||
local src, setSrc = signal({ a = 1, b = 2, c = 3 })
|
||||
local map = mapped(src, function(value: number)
|
||||
calls += 1
|
||||
return if value < 0 then nil else value
|
||||
end)
|
||||
|
||||
local state = map()
|
||||
assert(calls == 3, "should map 3 values")
|
||||
assert(state.a and state.b and state.c, "should map all values")
|
||||
|
||||
calls = 0
|
||||
setSrc({ a = -1, b = 2, c = 3, d = 4 })
|
||||
state = map()
|
||||
assert(calls == 2, "should map 2 values")
|
||||
assert(state.a == nil, "should remove a")
|
||||
assert(state.b and state.c, "should keep b and c")
|
||||
assert(state.d == 4, "should add d")
|
||||
end)
|
||||
|
||||
test("should map keys", function()
|
||||
local calls = 0
|
||||
|
||||
local src, setSrc = signal({ a = 1, b = 2, c = 3 })
|
||||
local map = mapped(src, function(value: number, key: string)
|
||||
calls += 1
|
||||
return if value < 0 then nil else value, string.upper(key)
|
||||
end)
|
||||
|
||||
local state = map()
|
||||
assert(calls == 3, "should map 3 values")
|
||||
assert(not (state.a and state.b and state.c), "should discard old keys")
|
||||
assert(state.A == 1, "should map a")
|
||||
assert(state.B == 2, "should map b")
|
||||
assert(state.C == 3, "should map c")
|
||||
|
||||
calls = 0
|
||||
setSrc({ a = -1, b = 2, c = 3, d = 4 })
|
||||
state = map()
|
||||
assert(calls == 2, "should map 2 values")
|
||||
assert(state.A == nil and state.a == nil, "should remove a")
|
||||
assert(state.B and state.C, "should keep b and c")
|
||||
assert(state.D == 4, "should add d")
|
||||
end)
|
||||
|
||||
test("should remove old key if mapper returns a new key", function()
|
||||
local src, setSrc = signal({ 1 })
|
||||
local map = mapped(src, function(value: number, index: number)
|
||||
return true, `{index}={value}`
|
||||
end)
|
||||
|
||||
local state = map()
|
||||
assert(state["1=1"], "should have mapped first element to key 1=1")
|
||||
|
||||
setSrc({ 2 })
|
||||
state = map()
|
||||
assert(state["1=1"] == nil, "should have removed old key 1=1")
|
||||
assert(state["1=2"], "should have remapped element to key 1=2")
|
||||
|
||||
setSrc({ 3, 1 })
|
||||
state = map()
|
||||
assert(state["1=2"] == nil, "should have removed old key 1=2")
|
||||
assert(state["1=3"], "should have remapped element to key 1=3")
|
||||
assert(state["2=1"], "should have mapped second element to key 2=1")
|
||||
end)
|
||||
|
||||
return {}
|
||||
198
packages/charm/test/observe.test.luau
Normal file
198
packages/charm/test/observe.test.luau
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
local Charm = require("../src")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
local signal = Charm.signal
|
||||
local computed = Charm.computed
|
||||
local effect = Charm.effect
|
||||
local effectScope = Charm.effectScope
|
||||
local observe = Charm.observe
|
||||
|
||||
test("should observe items", function()
|
||||
local items, setItems = signal({ 0, 0, 0 })
|
||||
local updates = {}
|
||||
local cleanups = {}
|
||||
|
||||
observe(items, function(_, key: number)
|
||||
local get = computed(function()
|
||||
return items()[key]
|
||||
end)
|
||||
|
||||
effect(function()
|
||||
table.insert(updates, get())
|
||||
end)
|
||||
|
||||
return function()
|
||||
table.insert(cleanups, key)
|
||||
end
|
||||
end)
|
||||
|
||||
assert(table.concat(updates) == "000", `bad mount: {table.concat(updates)}`)
|
||||
|
||||
table.clear(updates)
|
||||
setItems({ 1, 2, 3 })
|
||||
assert(table.concat(updates) == "123", `bad update: {table.concat(updates)}`)
|
||||
|
||||
table.clear(updates)
|
||||
setItems({ 1, 2, nil :: any, 4 })
|
||||
assert(table.concat(updates) == "4", `should add 4: {table.concat(updates)}`)
|
||||
assert(table.concat(cleanups) == "3", `bad cleanup: {table.concat(cleanups)}`)
|
||||
end)
|
||||
|
||||
test("should dispose scopes", function()
|
||||
local items, setItems = signal({ 1, 2, 3 })
|
||||
local a, setA = signal(0)
|
||||
local runs = {}
|
||||
local disposes = {}
|
||||
|
||||
local dispose = observe(items, function(item)
|
||||
effect(function()
|
||||
table.insert(runs, item)
|
||||
a()
|
||||
end)
|
||||
effectScope(function()
|
||||
return function()
|
||||
table.insert(disposes, item)
|
||||
end
|
||||
end)
|
||||
end)
|
||||
|
||||
table.clear(runs)
|
||||
setItems({ 1, 2 })
|
||||
setA(1)
|
||||
assert(table.concat(runs) == "12", `bad reruns: {table.concat(runs)}`)
|
||||
assert(table.concat(disposes) == "3", `should dispose 3: {table.concat(disposes)}`)
|
||||
|
||||
table.clear(runs)
|
||||
table.clear(disposes)
|
||||
dispose()
|
||||
setA(2)
|
||||
assert(table.concat(runs) == "", `should not rerun: {table.concat(runs)}`)
|
||||
assert(table.concat(disposes) == "12", `should dispose 1 and 2: {table.concat(disposes)}`)
|
||||
end)
|
||||
|
||||
test("should queue addition and removal", function()
|
||||
local items, setItems = signal({ 1 })
|
||||
local edits = {}
|
||||
|
||||
observe(items, function(item)
|
||||
if item == 1 then
|
||||
setItems({ 1, 2, 3, 4 })
|
||||
elseif item == 4 then
|
||||
setItems({ 1 })
|
||||
end
|
||||
table.insert(edits, `+{item}`)
|
||||
return function()
|
||||
table.insert(edits, `-{item}`)
|
||||
end
|
||||
end)
|
||||
|
||||
assert(table.concat(edits) == "+1+2+3+4-2-3-4", `bad edits: {table.concat(edits)}`)
|
||||
end)
|
||||
|
||||
test("should handle recursive additions and removals", function()
|
||||
local items, setItems = signal({ 1 })
|
||||
local edits: { string } = {}
|
||||
|
||||
observe(items, function(item)
|
||||
if item < 5 then
|
||||
setItems(function(state: { number })
|
||||
state = table.clone(state)
|
||||
table.insert(state, item + 1)
|
||||
return state
|
||||
end)
|
||||
else
|
||||
setItems({})
|
||||
end
|
||||
table.insert(edits, `+{item}`)
|
||||
return function()
|
||||
table.insert(edits, `-{item}`)
|
||||
end
|
||||
end)
|
||||
|
||||
assert(table.concat(edits) == "+1+2+3+4+5-1-2-3-4-5", `bad edits: {table.concat(edits)}`)
|
||||
end)
|
||||
|
||||
test("should handle self removal", function()
|
||||
local items, setItems = signal({ 1, 2, 3, 4 })
|
||||
local edits: { string } = {}
|
||||
|
||||
observe(items, function(item, index)
|
||||
setItems(function(state: { number })
|
||||
state = table.clone(state)
|
||||
state[index] = nil
|
||||
return state
|
||||
end)
|
||||
table.insert(edits, `+{item}`)
|
||||
return function()
|
||||
table.insert(edits, `-{item}`)
|
||||
end
|
||||
end)
|
||||
|
||||
assert(table.concat(edits) == "+1+2+3+4-1-2-3-4", `bad edits: {table.concat(edits)}`)
|
||||
end)
|
||||
|
||||
test("should allow disposal during addition", function()
|
||||
local items, setItems = signal({})
|
||||
local edits = {}
|
||||
local dispose: () -> ()
|
||||
|
||||
dispose = observe(items, function(item)
|
||||
if item == 1 then
|
||||
dispose()
|
||||
end
|
||||
table.insert(edits, `+{item}`)
|
||||
return function()
|
||||
table.insert(edits, `-{item}`)
|
||||
end
|
||||
end)
|
||||
|
||||
setItems({ 1, 2, 3, 4 })
|
||||
assert(table.concat(edits) == "+1-1", `bad edits: {table.concat(edits)}`)
|
||||
end)
|
||||
|
||||
test("should allow disposal during removal", function()
|
||||
local items, setItems = signal({ 1, 2, 3, 4 })
|
||||
local edits = {}
|
||||
local dispose: () -> ()
|
||||
|
||||
dispose = observe(items, function(item)
|
||||
table.insert(edits, `+{item}`)
|
||||
return function()
|
||||
dispose()
|
||||
table.insert(edits, `-{item}`)
|
||||
end
|
||||
end)
|
||||
|
||||
setItems({ 1, 2, 3, nil :: any, 5 })
|
||||
assert(table.concat(edits) == "+1+2+3+4-1-2-3-4", `bad edits: {table.concat(edits)}`)
|
||||
end)
|
||||
|
||||
test("should not hang on error after recursive update", function()
|
||||
local items, setItems = signal({})
|
||||
|
||||
observe(items, function()
|
||||
setItems({ {} })
|
||||
error("test error")
|
||||
end)
|
||||
|
||||
local success, err = pcall(function()
|
||||
setItems({ {} })
|
||||
end)
|
||||
assert(not success, "should error")
|
||||
assert(string.find(tostring(err), "test error"), `bad error: {err}`)
|
||||
end)
|
||||
|
||||
test("should not hang on error after recursive init", function()
|
||||
local items, setItems = signal({ {} })
|
||||
|
||||
local success, err = pcall(function()
|
||||
observe(items, function()
|
||||
setItems({ {} })
|
||||
error("test error")
|
||||
end)
|
||||
end)
|
||||
assert(not success, "should error")
|
||||
assert(string.find(tostring(err), "test error"), `bad error: {err}`)
|
||||
end)
|
||||
|
||||
return {}
|
||||
67
packages/charm/test/signal.test.luau
Normal file
67
packages/charm/test/signal.test.luau
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
local Charm = require("../src")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
local signal = Charm.signal
|
||||
local effect = Charm.effect
|
||||
|
||||
test("should accept equality function", function()
|
||||
local calls = {}
|
||||
local src, setSrc = signal(0, function(current: number, incoming: number)
|
||||
return current == incoming or incoming % 2 == 1
|
||||
end)
|
||||
|
||||
effect(function()
|
||||
table.insert(calls, src())
|
||||
end)
|
||||
|
||||
setSrc(2)
|
||||
setSrc(3)
|
||||
assert(src() == 2, "should skip odd value")
|
||||
|
||||
setSrc(4)
|
||||
setSrc(5)
|
||||
assert(src() == 4, "should skip odd value")
|
||||
assert(table.concat(calls) == "024", `should only call on even updates, got {table.concat(calls)}`)
|
||||
end)
|
||||
|
||||
test("should enforce immutability", function()
|
||||
local src, setSrc = signal({ nested = {} })
|
||||
assert(not pcall(function()
|
||||
src().nested.a = 1
|
||||
end), "should not allow mutation of initial value")
|
||||
|
||||
setSrc({ nested = { a = 1 } })
|
||||
assert(not pcall(function()
|
||||
src().nested.a = 2
|
||||
end), "should not allow mutation of new value")
|
||||
end)
|
||||
|
||||
test("should not freeze values with metatables", function()
|
||||
local src = signal({ object = setmetatable({}, {}) })
|
||||
assert(
|
||||
pcall(function()
|
||||
src().object.a = 2
|
||||
end),
|
||||
"should allow mutation of values with metatables"
|
||||
)
|
||||
end)
|
||||
|
||||
test("should setter return same value as getter even if equal", function()
|
||||
local src, setSrc = signal(0, function()
|
||||
return true
|
||||
end)
|
||||
assert(setSrc(1) == src(), `setSrc(1) expected 0, got {setSrc(1)}`)
|
||||
end)
|
||||
|
||||
test("should setter return latest value", function()
|
||||
local src, setSrc = signal(0)
|
||||
effect(function()
|
||||
if src() == 1 then
|
||||
setSrc(2)
|
||||
end
|
||||
end)
|
||||
assert(setSrc(1) == 2, `setSrc(1) expected 2, got {setSrc(1)}`)
|
||||
assert(src() == 2, `src() expected 2, got {src()}`)
|
||||
end)
|
||||
|
||||
return {}
|
||||
91
packages/charm/test/subscribe.test.luau
Normal file
91
packages/charm/test/subscribe.test.luau
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
local Charm = require("../src")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
local signal = Charm.signal
|
||||
local computed = Charm.computed
|
||||
local subscribe = Charm.subscribe
|
||||
|
||||
test("should subscribe to selector", function()
|
||||
local source, setSource = signal(0)
|
||||
local current, previous
|
||||
|
||||
local unsub = subscribe(function()
|
||||
return source() + 1
|
||||
end, function(a, b)
|
||||
current = a
|
||||
previous = b
|
||||
end)
|
||||
|
||||
assert(not current and not previous, "should not run initially")
|
||||
setSource(0)
|
||||
assert(not current and not previous, "should skip redundant update")
|
||||
setSource(1)
|
||||
assert(current == 2 and previous == 1, "should run with new value")
|
||||
unsub()
|
||||
setSource(2)
|
||||
assert(current == 2 and previous == 1, "should not run after unsub")
|
||||
end)
|
||||
|
||||
test("should run in untracked", function()
|
||||
local runs = 0
|
||||
local a, setA = signal(0)
|
||||
local b, setB = signal(0)
|
||||
|
||||
subscribe(a, function()
|
||||
runs += 1
|
||||
b()
|
||||
end)
|
||||
|
||||
assert(runs == 0, "should not run once on init")
|
||||
setA(1)
|
||||
assert(runs == 1, "should run once on change")
|
||||
setB(1)
|
||||
assert(runs == 1, "should not run on b change")
|
||||
setA(2)
|
||||
assert(runs == 2, "should run on a change")
|
||||
end)
|
||||
|
||||
test("should ensure correct execution order", function()
|
||||
local order: { number } = {}
|
||||
local n1, setN1 = signal(0)
|
||||
local n2, setN2 = signal(0)
|
||||
local sum = computed(function()
|
||||
return n1() + n2()
|
||||
end)
|
||||
|
||||
subscribe(n1, function()
|
||||
table.insert(order, 1)
|
||||
setN2(n2() + 1)
|
||||
end)
|
||||
|
||||
subscribe(sum, function()
|
||||
table.insert(order, 2)
|
||||
end)
|
||||
|
||||
subscribe(n1, function()
|
||||
table.insert(order, 3)
|
||||
end)
|
||||
|
||||
setN1(n1() + 1)
|
||||
|
||||
assert(table.concat(order) == "123", `bad order: {table.concat(order)}`)
|
||||
end)
|
||||
|
||||
test("should skip redundant updates", function()
|
||||
local order: { "even" | "odd" } = {}
|
||||
local a, setA = signal(0)
|
||||
|
||||
subscribe(function(): "even" | "odd"
|
||||
return if a() % 2 == 0 then "even" else "odd"
|
||||
end, function(value)
|
||||
table.insert(order, value)
|
||||
end)
|
||||
|
||||
setA(1)
|
||||
setA(3)
|
||||
setA(4)
|
||||
setA(6)
|
||||
assert(table.concat(order) == "oddeven", `bad order: {table.concat(order)}`)
|
||||
end)
|
||||
|
||||
return {}
|
||||
484
packages/charm/test/topology.test.luau
Normal file
484
packages/charm/test/topology.test.luau
Normal file
|
|
@ -0,0 +1,484 @@
|
|||
local Charm = require("../src")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
local atom = Charm.atom
|
||||
local computed = Charm.computed
|
||||
local effect = Charm.effect
|
||||
|
||||
--[[
|
||||
Tests adopted with thanks from preact-core implementation at
|
||||
https://github.com/preactjs/signals/blob/main/packages/core/test/signal.test.tsx
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2022-present Preact Team
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE
|
||||
]]
|
||||
|
||||
test.describe("graph updates", function()
|
||||
test("should drop A->B->A updates", function()
|
||||
-- A
|
||||
-- / |
|
||||
-- B | <- Looks like a flag doesn't it? :D
|
||||
-- \ |
|
||||
-- C
|
||||
-- |
|
||||
-- D
|
||||
local a = atom(2)
|
||||
|
||||
local b = computed(function()
|
||||
return a() - 1
|
||||
end)
|
||||
local c = computed(function()
|
||||
return a() + b()
|
||||
end)
|
||||
|
||||
local computeCalls = 0
|
||||
local compute = function()
|
||||
computeCalls += 1
|
||||
return "d: " .. c()
|
||||
end
|
||||
local d = computed(compute)
|
||||
|
||||
-- Trigger read
|
||||
assert(d() == "d: 3", "d should be 3")
|
||||
assert(computeCalls == 1, "compute should be called once")
|
||||
computeCalls = 0
|
||||
|
||||
a(4)
|
||||
d()
|
||||
assert(computeCalls == 1, "compute should drop update")
|
||||
end)
|
||||
|
||||
test("should only update every signal once (diamond graph)", function()
|
||||
-- In this scenario "D" should only update once when "A" receives
|
||||
-- an update. This is sometimes referred to as the "diamond" scenario.
|
||||
-- A
|
||||
-- / \
|
||||
-- B C
|
||||
-- \ /
|
||||
-- D
|
||||
local a = atom("a")
|
||||
local b = computed(function()
|
||||
return a()
|
||||
end)
|
||||
local c = computed(function()
|
||||
return a()
|
||||
end)
|
||||
|
||||
local spyCalls = 0
|
||||
local spy = function()
|
||||
spyCalls += 1
|
||||
return b() .. " " .. c()
|
||||
end
|
||||
local d = computed(spy)
|
||||
|
||||
assert(d() == "a a", "d should be 'a a'")
|
||||
assert(spyCalls == 1, "compute should be called once")
|
||||
|
||||
a("aa")
|
||||
assert(d() == "aa aa", "d should be 'aa aa'")
|
||||
assert(spyCalls == 2, "compute should be called only twice")
|
||||
end)
|
||||
|
||||
test("should only update every signal once (diamond graph + tail)", function()
|
||||
-- "E" will be likely updated twice if our mark+sweep logic is buggy.
|
||||
-- A
|
||||
-- / \
|
||||
-- B C
|
||||
-- \ /
|
||||
-- D
|
||||
-- |
|
||||
-- E
|
||||
local a = atom("a")
|
||||
local b = computed(function()
|
||||
return a()
|
||||
end)
|
||||
local c = computed(function()
|
||||
return a()
|
||||
end)
|
||||
|
||||
local d = computed(function()
|
||||
return b() .. " " .. c()
|
||||
end)
|
||||
|
||||
local spyCalls = 0
|
||||
local spy = function()
|
||||
spyCalls += 1
|
||||
return d()
|
||||
end
|
||||
local e = computed(spy)
|
||||
|
||||
assert(e() == "a a", "e should be 'a a'")
|
||||
assert(spyCalls == 1, "compute should be called once")
|
||||
|
||||
a("aa")
|
||||
assert(e() == "aa aa", "e should be 'aa aa'")
|
||||
assert(spyCalls == 2, "compute should be called only twice")
|
||||
end)
|
||||
|
||||
test("should bail out if result is the same", function()
|
||||
-- Bail out if value of "B" never changes
|
||||
-- A->B->C
|
||||
local a = atom("a")
|
||||
local b = computed(function()
|
||||
a()
|
||||
return "foo"
|
||||
end)
|
||||
|
||||
local spyCalls = 0
|
||||
local spy = function()
|
||||
spyCalls += 1
|
||||
return b()
|
||||
end
|
||||
local c = computed(spy)
|
||||
|
||||
assert(c() == "foo", "c should be 'foo'")
|
||||
assert(spyCalls == 1, "compute should be called once")
|
||||
|
||||
a("aa")
|
||||
assert(c() == "foo", "c should be 'foo'")
|
||||
assert(spyCalls == 1, "compute should bail out")
|
||||
end)
|
||||
|
||||
test("should only update every signal once (jagged diamond graph + tails)", function()
|
||||
-- "F" and "G" will be likely updated twice if our mark+sweep logic is buggy.
|
||||
-- A
|
||||
-- / \
|
||||
-- B C
|
||||
-- | |
|
||||
-- | D
|
||||
-- \ /
|
||||
-- E
|
||||
-- / \
|
||||
-- F G
|
||||
local a = atom("a")
|
||||
|
||||
local b = computed(function()
|
||||
return a()
|
||||
end)
|
||||
local c = computed(function()
|
||||
return a()
|
||||
end)
|
||||
|
||||
local d = computed(function()
|
||||
return c()
|
||||
end)
|
||||
|
||||
local eSpyCalls = 0
|
||||
local eSpyTime = 0
|
||||
local eSpy = function()
|
||||
eSpyCalls += 1
|
||||
eSpyTime = os.clock()
|
||||
return b() .. " " .. d()
|
||||
end
|
||||
local e = computed(eSpy)
|
||||
|
||||
local fSpyCalls = 0
|
||||
local fSpyTime = 0
|
||||
local fSpy = function()
|
||||
fSpyCalls += 1
|
||||
fSpyTime = os.clock()
|
||||
return e()
|
||||
end
|
||||
local f = computed(fSpy)
|
||||
local gSpyCalls = 0
|
||||
local gSpyTime = 0
|
||||
local gSpy = function()
|
||||
gSpyCalls += 1
|
||||
gSpyTime = os.clock()
|
||||
return e()
|
||||
end
|
||||
local g = computed(gSpy)
|
||||
|
||||
assert(f() == "a a", "f should be 'a a'")
|
||||
assert(fSpyCalls == 1, "f should be called once")
|
||||
|
||||
assert(g() == "a a", "g should be 'a a'")
|
||||
assert(gSpyCalls == 1, "g should be called once")
|
||||
|
||||
eSpyCalls, eSpyTime = 0, 0
|
||||
fSpyCalls, fSpyTime = 0, 0
|
||||
gSpyCalls, gSpyTime = 0, 0
|
||||
|
||||
a("b")
|
||||
|
||||
assert(e() == "b b", "e should be 'b b'")
|
||||
assert(eSpyCalls == 1, "e should run once after update")
|
||||
|
||||
assert(f() == "b b", "f should be 'b b'")
|
||||
assert(fSpyCalls == 1, "f should run once after update")
|
||||
|
||||
assert(g() == "b b", "g should be 'b b'")
|
||||
assert(gSpyCalls == 1, "g should run once after update")
|
||||
|
||||
eSpyCalls, eSpyTime = 0, 0
|
||||
fSpyCalls, fSpyTime = 0, 0
|
||||
gSpyCalls, gSpyTime = 0, 0
|
||||
|
||||
a("c")
|
||||
|
||||
assert(e() == "c c", "e should be 'c c'")
|
||||
assert(eSpyCalls == 1, "e should run once after update")
|
||||
|
||||
assert(f() == "c c", "f should be 'c c'")
|
||||
assert(fSpyCalls == 1, "f should run once after update")
|
||||
|
||||
assert(g() == "c c", "g should be 'c c'")
|
||||
assert(gSpyCalls == 1, "g should run once after update")
|
||||
|
||||
-- top to bottom
|
||||
assert(eSpyTime < fSpyTime, "e should be called before f")
|
||||
-- left to right
|
||||
assert(eSpyTime < gSpyTime, "e should be called before g")
|
||||
end)
|
||||
|
||||
test("should only subscribe to core listened to", function()
|
||||
-- *A
|
||||
-- / \
|
||||
-- *B C <- we don't listen to C
|
||||
local a = atom("a")
|
||||
|
||||
local b = computed(function()
|
||||
return a()
|
||||
end)
|
||||
local spyCalls = 0
|
||||
local spy = function()
|
||||
spyCalls += 1
|
||||
return a()
|
||||
end
|
||||
computed(spy)
|
||||
|
||||
assert(b() == "a", "b should be 'a'")
|
||||
assert(spyCalls == 0, "compute should not run")
|
||||
|
||||
a("aa")
|
||||
assert(b() == "aa", "b should be 'aa'")
|
||||
assert(spyCalls == 0, "compute should not run")
|
||||
end)
|
||||
|
||||
test("should only subscribe to core listened to II", function()
|
||||
-- Here both "B" and "C" are active in the beginning, but
|
||||
-- "B" becomes inactive later. At that point it should
|
||||
-- not receive any updates anymore.
|
||||
-- *A
|
||||
-- / \
|
||||
-- *B D <- we don't listen to C
|
||||
-- |
|
||||
-- *C
|
||||
local a = atom("a")
|
||||
local spyBCalls = 0
|
||||
local spyB = function()
|
||||
spyBCalls += 1
|
||||
return a()
|
||||
end
|
||||
local b = computed(spyB)
|
||||
|
||||
local spyCCalls = 0
|
||||
local spyC = function()
|
||||
spyCCalls += 1
|
||||
return b()
|
||||
end
|
||||
local c = computed(spyC)
|
||||
|
||||
local d = computed(function()
|
||||
return a()
|
||||
end)
|
||||
|
||||
local result = ""
|
||||
local unsub = effect(function()
|
||||
result = c()
|
||||
end)
|
||||
|
||||
assert(result == "a", "result should be 'a'")
|
||||
assert(d() == "a", "d should be 'a'")
|
||||
|
||||
spyBCalls = 0
|
||||
spyCCalls = 0
|
||||
unsub()
|
||||
|
||||
a("aa")
|
||||
|
||||
assert(spyBCalls == 0, "b should not run")
|
||||
assert(spyCCalls == 0, "c should not run")
|
||||
assert(d() == "aa", "d should be 'aa'")
|
||||
end)
|
||||
|
||||
test("should ensure subs update even if one dep unmarks it", function()
|
||||
-- In this scenario "C" always returns the same value. When "A"
|
||||
-- changes, "B" will update, then "C" at which point its update
|
||||
-- to "D" will be unmarked. But "D" must still update because
|
||||
-- "B" marked it. If "D" isn't updated, then we have a bug.
|
||||
-- A
|
||||
-- / \
|
||||
-- B *C <- returns same value every time
|
||||
-- \ /
|
||||
-- D
|
||||
local a = atom("a")
|
||||
local b = computed(function()
|
||||
return a()
|
||||
end)
|
||||
local c = computed(function()
|
||||
a()
|
||||
return "c"
|
||||
end)
|
||||
local spyResult: string?
|
||||
local spy = function()
|
||||
spyResult = b() .. " " .. c()
|
||||
return spyResult
|
||||
end
|
||||
local d = computed(spy)
|
||||
|
||||
assert(d() == "a c", "d should be 'a c'")
|
||||
spyResult = nil
|
||||
|
||||
a("aa")
|
||||
d()
|
||||
assert(spyResult == "aa c", "d should have returned 'aa c'")
|
||||
end)
|
||||
|
||||
test("should ensure subs update even if two deps unmark it", function()
|
||||
-- In this scenario both "C" and "D" always return the same
|
||||
-- value. But "E" must still update because "A" marked it.
|
||||
-- If "E" isn't updated, then we have a bug.
|
||||
-- A
|
||||
-- / | \
|
||||
-- B *C *D
|
||||
-- \ | /
|
||||
-- E
|
||||
local a = atom("a")
|
||||
local b = computed(function()
|
||||
return a()
|
||||
end)
|
||||
local c = computed(function()
|
||||
a()
|
||||
return "c"
|
||||
end)
|
||||
local d = computed(function()
|
||||
a()
|
||||
return "d"
|
||||
end)
|
||||
local spyResult: string?
|
||||
local spy = function()
|
||||
spyResult = b() .. " " .. c() .. " " .. d()
|
||||
return spyResult
|
||||
end
|
||||
local e = computed(spy)
|
||||
|
||||
assert(e() == "a c d", "e should be 'a c d'")
|
||||
spyResult = nil
|
||||
|
||||
a("aa")
|
||||
e()
|
||||
assert(spyResult == "aa c d", "e should have returned 'aa c d'")
|
||||
end)
|
||||
|
||||
test("should support lazy branches", function()
|
||||
local a = atom(0)
|
||||
local b = computed(function()
|
||||
return a()
|
||||
end)
|
||||
local c = computed(function()
|
||||
return if a() > 0 then a() else b()
|
||||
end)
|
||||
|
||||
assert(c() == 0, "c should be 0")
|
||||
a(1)
|
||||
assert(c() == 1, "c should be 1")
|
||||
|
||||
a(0)
|
||||
assert(c() == 0, "c should be 0 again")
|
||||
end)
|
||||
|
||||
test("should not update a sub if all deps unmark it", function()
|
||||
-- In this scenario "B" and "C" always return the same value. When "A"
|
||||
-- changes, "D" should not update.
|
||||
-- A
|
||||
-- / \
|
||||
-- *B *C
|
||||
-- \ /
|
||||
-- D
|
||||
local a = atom("a")
|
||||
local b = computed(function()
|
||||
a()
|
||||
return "b"
|
||||
end)
|
||||
local c = computed(function()
|
||||
a()
|
||||
return "c"
|
||||
end)
|
||||
local spyCalls = 0
|
||||
local spy = function()
|
||||
spyCalls += 1
|
||||
return b() .. " " .. c()
|
||||
end
|
||||
local d = computed(spy)
|
||||
|
||||
assert(d() == "b c", "d should be 'b c'")
|
||||
spyCalls = 0
|
||||
|
||||
a("aa")
|
||||
assert(spyCalls == 0, "d should not run")
|
||||
end)
|
||||
end)
|
||||
|
||||
test.describe("error handling", function()
|
||||
test("should keep graph consistent on errors during activation", function()
|
||||
local a = atom(0)
|
||||
local b = computed(function(): nil
|
||||
error("fail")
|
||||
end)
|
||||
local c = computed(function()
|
||||
return a()
|
||||
end)
|
||||
|
||||
local success, result = pcall(b)
|
||||
assert(not success, "should throw")
|
||||
assert(string.match(tostring(result), "fail"), "should throw fail")
|
||||
|
||||
a(1)
|
||||
assert(c() == 1, "c should recover and return 1")
|
||||
end)
|
||||
|
||||
test("should keep graph consistent on errors in computeds", function()
|
||||
local a = atom(0)
|
||||
local b = computed(function(): number
|
||||
if a() == 1 then
|
||||
error("fail")
|
||||
end
|
||||
return a()
|
||||
end)
|
||||
local c = computed(function()
|
||||
return b()
|
||||
end)
|
||||
|
||||
assert(c() == 0, "c should be 0")
|
||||
|
||||
a(1)
|
||||
local success, result = pcall(b)
|
||||
assert(not success, "should throw")
|
||||
assert(string.match(tostring(result), "fail"), "should throw fail")
|
||||
|
||||
a(2)
|
||||
assert(c() == 2, "c should recover and return 2")
|
||||
end)
|
||||
end)
|
||||
|
||||
return {}
|
||||
111
packages/charm/test/trigger.test.luau
Normal file
111
packages/charm/test/trigger.test.luau
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
local Charm = require("../src")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
local computed = Charm.computed
|
||||
local effect = Charm.effect
|
||||
local listen = Charm.listen
|
||||
local signal = Charm.signal
|
||||
local subscribe = Charm.subscribe
|
||||
local trigger = Charm.trigger
|
||||
|
||||
test.beforeEach(function()
|
||||
Charm.flags.frozen = false
|
||||
end)
|
||||
|
||||
test.afterEach(function()
|
||||
Charm.flags.frozen = true
|
||||
end)
|
||||
|
||||
test("should not throw when triggering with no dependencies", function()
|
||||
trigger(function() end)
|
||||
end)
|
||||
|
||||
test("should trigger updates for dependent computed signals", function()
|
||||
local arr = signal({} :: { number })
|
||||
local length = computed(function()
|
||||
return #arr()
|
||||
end)
|
||||
|
||||
assert(length() == 0, "initial length should be 0")
|
||||
table.insert(arr(), 1)
|
||||
trigger(arr)
|
||||
assert(length() == 1, "length should be 1")
|
||||
end)
|
||||
|
||||
test("should trigger updates for the second source signal", function()
|
||||
local src1 = signal({} :: { number })
|
||||
local src2 = signal({} :: { number })
|
||||
local length = computed(function()
|
||||
return #src2()
|
||||
end)
|
||||
|
||||
assert(length() == 0, "initial length should be 0")
|
||||
table.insert(src2(), 1)
|
||||
trigger(function()
|
||||
src1()
|
||||
src2()
|
||||
end)
|
||||
assert(length() == 1, "length should be 1")
|
||||
end)
|
||||
|
||||
test("should trigger effect once", function()
|
||||
local src1 = signal({} :: { number })
|
||||
local src2 = signal({} :: { number })
|
||||
local triggers = 0
|
||||
|
||||
effect(function()
|
||||
triggers += 1
|
||||
src1()
|
||||
src2()
|
||||
end)
|
||||
assert(triggers == 1, "effect should run once initially")
|
||||
|
||||
trigger(function()
|
||||
src1()
|
||||
src2()
|
||||
end)
|
||||
assert(triggers == 2, "effect should run twice")
|
||||
end)
|
||||
|
||||
test("should not notify the trigger function sub", function()
|
||||
local src1 = signal({} :: { number })
|
||||
local src2 = computed(function()
|
||||
return src1()
|
||||
end)
|
||||
|
||||
effect(function()
|
||||
src1()
|
||||
src2()
|
||||
end)
|
||||
trigger(function()
|
||||
src1()
|
||||
src2()
|
||||
end)
|
||||
end)
|
||||
|
||||
test("should trigger updates for subscribe", function()
|
||||
local src = signal({} :: { number })
|
||||
local triggers = 0
|
||||
|
||||
subscribe(src, function()
|
||||
triggers += 1
|
||||
end)
|
||||
|
||||
trigger(src)
|
||||
assert(triggers == 1, "subscribe callback should run after trigger")
|
||||
end)
|
||||
|
||||
test("should trigger updates for listen", function()
|
||||
local src = signal({} :: { number })
|
||||
local triggers = 0
|
||||
|
||||
listen(src, function()
|
||||
triggers += 1
|
||||
end)
|
||||
assert(triggers == 1, "listen callback should run once initially")
|
||||
|
||||
trigger(src)
|
||||
assert(triggers == 2, "listen callback should run after trigger")
|
||||
end)
|
||||
|
||||
return {}
|
||||
176
packages/charm/test/untracked.test.luau
Normal file
176
packages/charm/test/untracked.test.luau
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
local Charm = require("../src")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
local signal = Charm.signal
|
||||
local computed = Charm.computed
|
||||
local effect = Charm.effect
|
||||
local effectScope = Charm.effectScope
|
||||
local untracked = Charm.untracked
|
||||
|
||||
test("should run detached from effect", function()
|
||||
local a, setA = signal(1)
|
||||
local effects = 0
|
||||
local innerEffects = {}
|
||||
|
||||
local stopEffect = effect(function()
|
||||
a()
|
||||
untracked(function()
|
||||
local stopInnerEffect = effect(function()
|
||||
effects += 1
|
||||
return function()
|
||||
effects -= 1
|
||||
end
|
||||
end)
|
||||
table.insert(innerEffects, stopInnerEffect)
|
||||
end)
|
||||
end)
|
||||
|
||||
assert(effects == 1, "should have one effect")
|
||||
setA(2)
|
||||
assert(effects == 2, "should have two effects")
|
||||
stopEffect()
|
||||
assert(effects == 2, "should still have two effects")
|
||||
for _, stopInnerEffect in innerEffects do
|
||||
stopInnerEffect()
|
||||
end
|
||||
assert(effects == 0, "should have zero effects")
|
||||
end)
|
||||
|
||||
test("should run detached from effect scope", function()
|
||||
local effects = 0
|
||||
local stopEffect: () -> ()
|
||||
|
||||
local stopScope = effectScope(function()
|
||||
untracked(function()
|
||||
stopEffect = effect(function()
|
||||
effects += 1
|
||||
return function()
|
||||
effects -= 1
|
||||
end
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
|
||||
assert(effects == 1, "should have one effect")
|
||||
stopScope()
|
||||
assert(effects == 1, "should still have one effect")
|
||||
stopEffect()
|
||||
assert(effects == 0, "should have zero effects")
|
||||
end)
|
||||
|
||||
test("should block tracking inside effects", function()
|
||||
local a, setA = signal(1)
|
||||
local b, setB = signal(2)
|
||||
local spyCalls = 0
|
||||
|
||||
effect(function()
|
||||
untracked(function()
|
||||
spyCalls += 1
|
||||
return a() + b()
|
||||
end)
|
||||
end)
|
||||
assert(spyCalls == 1, "should be called once")
|
||||
|
||||
setA(10)
|
||||
setB(20)
|
||||
assert(spyCalls == 1, "should be called once")
|
||||
end)
|
||||
|
||||
test("should block tracking even when run inside effect run inside untracked", function()
|
||||
local s, setS = signal(1)
|
||||
local spyCalls = 0
|
||||
|
||||
untracked(function()
|
||||
effect(function()
|
||||
untracked(function()
|
||||
spyCalls += 1
|
||||
return s()
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
assert(spyCalls == 1, "should be called once")
|
||||
|
||||
setS(2)
|
||||
assert(spyCalls == 1, "should be called once")
|
||||
end)
|
||||
|
||||
test("should not cause signal assignments to throw", function()
|
||||
local a, setA = signal(1)
|
||||
local aChangedTime, setAChangedTime = signal(0)
|
||||
|
||||
local dispose = effect(function()
|
||||
a()
|
||||
untracked(function()
|
||||
setAChangedTime(aChangedTime() + 1)
|
||||
end)
|
||||
end)
|
||||
|
||||
setA(2)
|
||||
assert(aChangedTime() == 2, "aChangedTime should be 2")
|
||||
setA(3)
|
||||
assert(aChangedTime() == 3, "aChangedTime should be 3")
|
||||
|
||||
dispose()
|
||||
end)
|
||||
|
||||
test("should block tracking inside computed signals", function()
|
||||
local a, setA = signal(1)
|
||||
local b, setB = signal(2)
|
||||
local spyCalls = 0
|
||||
local c = computed(function()
|
||||
return untracked(function()
|
||||
spyCalls += 1
|
||||
return a() + b()
|
||||
end)
|
||||
end)
|
||||
|
||||
assert(spyCalls == 0, "should not be called yet")
|
||||
assert(c() == 3, "c should be 3")
|
||||
setA(10)
|
||||
c()
|
||||
setB(20)
|
||||
c()
|
||||
assert(spyCalls == 1, "should be called once")
|
||||
assert(c() == 3, "c should be 3")
|
||||
end)
|
||||
|
||||
test("should track outside untracked", function()
|
||||
local a, setA = signal(1)
|
||||
local b, setB = signal(2)
|
||||
local c, setC = signal(3)
|
||||
local spyCalls = 0
|
||||
|
||||
effect(function()
|
||||
a()
|
||||
untracked(c)
|
||||
b()
|
||||
spyCalls += 1
|
||||
end)
|
||||
|
||||
assert(spyCalls == 1, "should be called once")
|
||||
setA(10)
|
||||
assert(spyCalls == 2, "should be called twice")
|
||||
setB(20)
|
||||
assert(spyCalls == 3, "should be called three times")
|
||||
setC(30)
|
||||
assert(spyCalls == 3, "should not run on c change")
|
||||
end)
|
||||
|
||||
test("should stack untracked", function()
|
||||
local a, setA = signal(1)
|
||||
local spyCalls = 0
|
||||
|
||||
effect(function()
|
||||
untracked(function()
|
||||
untracked(a)
|
||||
return a()
|
||||
end)
|
||||
spyCalls += 1
|
||||
end)
|
||||
|
||||
assert(spyCalls == 1, "should be called once")
|
||||
setA(2)
|
||||
assert(spyCalls == 1, "should not be called twice")
|
||||
end)
|
||||
|
||||
return {}
|
||||
Loading…
Add table
Add a link
Reference in a new issue