- 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>
281 lines
8.7 KiB
Text
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 ===")
|