quad/quad-base/test/spec.effect.luau
qwreey-agent-selene dca789ecca
feat(m2): 단위 3 — Observer(레지스트리 소유·네 진입점) / Effect(rawRerun·_rerunRequired 홀드·cleanup·네 진입점) + onDestroying 스텁·mock + spec 2개
- Observer.luau: Observer.Init(module) — 인스턴스별 임플 + Subscribed/WeakSubscribed(H-99),
  _receive(canExecute 게이팅, 홀드 H-159)/_catchUp(유일한 재생 자리, from=nil H-164),
  네 진입점 인라인(H-149, Weak 프리미티브 H-111, 관대 H-133, 엄격), 생성자 순서(fn → 플래그 → _subs).
- Effect.luau: Effect.Init(module) — deps 검증(H-70), dep 종류별 클로저(H-107), fire(from==nil
  가드, _epochs Update만 H-151, _blocker 없음 H-150), rawRerun(force)/Rerun(_pending 지연, 홀드,
  사망 계약), _bindDestroying/_unbindDestroying/_consumeCleanup(_cleanupRunning H-160),
  네 진입점 자기 본문(H-144) + isRunning 가드(H-147) + resubscribeTail.
- State:Observer → ObserverImpl.new. LifetimeHandle: onDestroying 에러 스텁. mock: onDestroying.
- quad-types: EpochSet/Observer/ObserverFn/EffectHandle/EffectFn, State.Observer, Quad.Effect·onDestroying.
- spec.observer(8절)·spec.effect(9절) ALL PASS, analyze 0. ROADMAP 단위 3 체크박스(mock 전파 루프
  테스트 포함), round11 §5 단위 3 확인 목록 + H-178(사적 필드 _ 접두), 세션 원문·요약.

Co-authored-by: qwreey <me@qwreey.moe>
2026-08-29 01:52:21 +09:00

281 lines
8.7 KiB
Text

--[[
Effect 계약 — `.claude/base/effect-plan.md` "확정 구조" / "의사코드 — 생성자" / `_bindDestroying` /
`_unbindDestroying` / `_consumeCleanup` / `rawRerun`+`Rerun` / "`EffectHandle:Subscribe()`" /
"`Effect(fn, ...deps)`". `H-70`/`H-107`/`H-144`/`H-147`/`H-150`/`H-151`/`H-159`/`H-160`.
]]
local Quad = require("../src")
local mock = require("./mock")
local QuadTypes = require("../roblox_packages/quad_types")
type EffectHandle = QuadTypes.EffectHandle
-- strict 모드에서 `collectgarbage`는 선언 안 된 전역 — 표준 luau CLI엔 있다(Roblox엔 없음)
local collectgarbage = (_G :: any).collectgarbage :: () -> ()
local quad = Quad.New()
mock.installLifetime(quad)
local Source, Ref, Effect = quad.Source, quad.Ref, quad.Effect
local Instance = mock.Instance
print("=== 1. 생성 — deps 검증(H-70), 설치 즉시 1회(force), _deps 강한 주인, 브랜드 ===")
do
local s = Source(1)
local r = Ref(1)
local runs = 0
local e = Effect(function(self)
runs += 1
assert(quad.isEffect(self), "fn receives the handle")
end, s, r, s) -- duplicate dep is ignored
assert(runs == 1, "installed once on creation, before any binding (force)")
assert(quad.isEffect(e) and not quad.isObserver(e), "brand")
local depCount = 0
for _ in pairs((e :: any)._deps) do
depCount += 1
end
assert(depCount == 2, "duplicate dep collapsed; one entry per dep")
local bads: { { value: any, message: string } } = {
{ value = nil, message = "is nil" },
{ value = {}, message = "not a State/Source/Ref" },
{ value = 5, message = "not a State/Source/Ref" },
}
for _, bad in bads do
local ok, err = pcall(function()
Effect(function() end, s, bad.value)
end)
assert(not ok and string.find(tostring(err), bad.message, 1, true) ~= nil, "dep #2 " .. bad.message .. ": " .. tostring(err))
assert(string.find(tostring(err), "spec.effect.luau", 1, true) ~= nil, "level 2")
end
assert(not pcall(function()
Effect(5 :: any)
end), "fn must be a function")
print("PASS")
end
print()
print("=== 2. 발화 — 안 묶이면 홀드(H-159), 묶이면 dep 변경마다 1회; Ref dep도 같은 경로 ===")
do
local s, r = Source(1), Ref(1)
local runs = 0
local e = Effect(function()
runs += 1
end, s, r)
s:Set(2)
r:Set(2)
assert(runs == 1 and (e :: any)._rerunRequired == true, "unbound: changes held, not run")
local inst = Instance.new("Frame")
quad.bindLifetime(inst, e)
assert(runs == 2 and (e :: any)._rerunRequired == false, "bind replays the hold exactly once (not per change)")
s:Set(3)
assert(runs == 3, "State dep fires")
r:Set(3)
assert(runs == 4, "Ref dep fires")
quad.unbindLifetime(e)
s:Set(4)
assert(runs == 4, "unbound again: silent (held)")
print("PASS")
end
print()
print("=== 3. 다이아몬드 — 공통 상류가 한 번 바뀌면 fn은 한 번 (H-107: 클로저를 나눠도 dedup은 _epochs) ===")
do
local a = Source(1)
local b = a:Compute(function(x): number
return x:Get() + 1
end)
local c = a:Compute(function(x): number
return x:Get() + 2
end)
local runs = 0
local e = Effect(function()
runs += 1
end, b, c):Subscribe()
runs = 0
a:Set(5)
assert(runs == 1, "one Set → one run, got " .. runs)
e:Unsubscribe()
print("PASS")
end
print()
print("=== 4. cleanup — fn의 반환이 cleanup, 다음 fn 전에 1회, Unsubscribe가 소진, 파괴가 소진 ===")
do
local s = Source(1)
local log: { string } = {}
local e = Effect(function()
table.insert(log, "run")
return function()
table.insert(log, "clean")
end
end, s):Subscribe()
s:Set(2)
assert(table.concat(log, ",") == "run,clean,run", "cleanup runs right before the next fn")
e:Unsubscribe()
assert(table.concat(log, ",") == "run,clean,run,clean", "Unsubscribe consumes the cleanup exactly once")
assert((e :: any)._rerunRequired == true, "consumed = must reinstall next time")
-- 재구독 → 꼬리가 1회 재실행(H-144)
e:Subscribe()
assert(table.concat(log, ",") == "run,clean,run,clean,run", "resubscribe replays once")
e:Unsubscribe()
-- leaf 파괴 → Destroying 콜백이 cleanup 소진
log = {}
local inst = Instance.new("Frame")
local e2 = Effect(function()
table.insert(log, "run")
return function()
table.insert(log, "clean")
end
end, s)
quad.bindLifetime(inst, e2)
assert(table.concat(log, ",") == "run", "bound: no replay (nothing held)")
inst:Destroy()
assert(table.concat(log, ",") == "run,clean", "Destroying consumed the cleanup")
assert((e2 :: any)._destroyConn == nil, "connection dropped")
quad.unbindLifetime(e2) -- 죽은 뒤에도 안전(no-op)
print("PASS")
end
print()
print("=== 5. cleanup 없는 fn — 바인드/재마운트마다 재실행되지 않는다 (_rerunRequired 판정, H-58 회귀 방지) ===")
do
local s = Source(1)
local runs = 0
local e = Effect(function()
runs += 1
end, s)
local a, b = Instance.new("Frame"), Instance.new("Frame")
quad.bindLifetime(a, e)
quad.unbindLifetime(e)
quad.bindLifetime(b, e)
assert(runs == 1, "no held change → no re-run on (re)bind, got " .. runs)
print("PASS")
end
print()
print("=== 6. 재진입 — fn 안의 dep:Set은 지연 재실행(_pending); fn/cleanup은 자기 구독을 못 바꾼다 (H-147) ===")
do
local s = Source(0)
local runs = 0
local e: any
e = Effect(function()
runs += 1
if s:Get() == 0 then
s:Set(1) -- re-entrant change during fn
end
end, s):Subscribe()
assert(runs == 2 and s:Get() == 1, "deferred re-run happened once after fn returned");
(e :: any):Unsubscribe()
local caught: { string } = {}
local e2: any
e2 = Effect(function(self)
for _, name in { "Subscribe", "WeakSubscribe", "Unsubscribe", "WeakUnsubscribe" } do
local ok = pcall(function()
(self :: any)[name](self)
end)
if not ok then
table.insert(caught, name)
end
end
return function()
local ok = pcall(function()
e2:Subscribe()
end)
if not ok then
table.insert(caught, "cleanup:Subscribe")
end
end
end, s)
assert(#caught == 4, "all four entry points refuse inside fn, got " .. table.concat(caught, ","))
e2:Subscribe()
e2:Unsubscribe() -- runs the cleanup → its Subscribe attempt must error too
assert(caught[#caught] == "cleanup:Subscribe", "entry points refuse inside cleanup too")
print("PASS")
end
print()
print("=== 7. 네 진입점 — 게이트·관대/엄격·강한 킵 뒤 꼬리, canExecute 연동 ===")
do
local s = Source(1)
local runs = 0
local e = Effect(function()
runs += 1
end, s)
assert(quad.canExecute(e) == false, "unbound")
s:Set(2) -- held
assert(e:WeakSubscribe() == e and e.Subscribed == true and quad.canExecute(e) == true, "weak primitive")
assert(runs == 2, "tail replayed the hold")
assert(not pcall(function()
e:Subscribe()
end), "already subscribed")
e:WeakUnsubscribe()
assert((e :: any).Subscribed == false, "lenient release")
e:WeakUnsubscribe() -- silent
e:Subscribe()
assert(not pcall(function()
e:WeakUnsubscribe()
end), "strong keep present → error")
e:Unsubscribe()
assert(not pcall(function()
e:Unsubscribe()
end), "strict")
local ok, err = pcall(function()
quad.bindLifetime(Instance.new("Frame"), Effect(function() end, s):Subscribe())
end)
assert(not ok and string.find(tostring(err), "already subscribed", 1, true) ~= nil, "bindLifetime shares the gate: " .. tostring(err))
print("PASS")
end
print()
print("=== 8. error — fn이 던지면 그 Effect는 죽는다(_running 남음), 이후 재진입 전부 차단 ===")
do
local s = Source(1)
local e = Effect(function()
if s:Get() == 2 then
error("boom")
end
end, s):Subscribe()
local ok = pcall(function()
s:Set(2)
end)
assert(not ok, "the error propagated out of Set (no pcall inside quad)")
assert((e :: any)._running == true, "dead: _running stays set")
s:Set(1) -- 발화는 _pending으로만 기록되고 fn은 안 돈다
assert(not pcall(function()
e:Unsubscribe()
end), "entry points are blocked on a dead handle")
print("PASS")
end
print()
print("=== 9. GC — Effect가 강한 주인: 핸들을 놓으면 Ref 콜백·내부 Observer도 같이 사라진다 ===")
do
local s, r = Source(1), Ref(1)
local weak = setmetatable({}, { __mode = "v" }) :: { any }
local function make()
weak[1] = Effect(function() end, s, r)
end
make()
collectgarbage()
collectgarbage()
assert(weak[1] == nil, "an unowned Effect is collectable")
assert(next(r.WeakCallbacks) == nil, "its Ref callback went with it")
assert(next((s :: any)._subs) == nil, "and its internal Observer left the State's subscriber set")
-- 강한 구독은 살려둔다
local calls = 0
local function makeStrong()
Effect(function()
calls += 1
end, s):Subscribe()
end
makeStrong()
collectgarbage()
collectgarbage()
calls = 0
s:Set(9)
assert(calls == 1, "strongly subscribed Effect survives with no references")
print("PASS")
end
print()
print("=== ALL PASS ===")