quad/quad-base/test/spec.observer.luau
qwreey-agent-selene 3670e88d78
fix(m2): /code-review high(단위 3·4) 반영 — 임플을 module._impl로(H-181, 인스턴스 영구 핀 해소), Gate 실패 시 detach(H-188), Observer.Subscribed=false(H-189), Apply 검증(H-190); ② H-182~H-187은 §4 + TODO 마커
- State/Observer/Effect: implByModule(weak-key, 값이 키 캡처 → ephemeron 없어 불멸) 제거,
  임플은 rawset(module, "_impl") 비공개 필드(H-174 (a) 원문 모양). spec.init 2가 인스턴스 GC 고정.
- State.Gate: 검증 실패 시 상류 _subs에서 detach, setup 전 _onUpstreamEmit = Void. Apply 객체 분기 검증.
- Effect 시딩은 dep:_track(map), EpochSet 타입은 quad-types 하나로, State.Impl._module 제거.
- 코드 마커 TODO(H-182)(H-183)(H-184)(H-185)(H-186)(H-187); round11 §4 표 여섯 행(권고 전부 (a)).
- WARN 둘(round11.md 축약 참조) 전체 이름으로. 세션 원문·요약.

Co-authored-by: qwreey <me@qwreey.moe>
2026-08-29 02:58:27 +09:00

211 lines
7.5 KiB
Text

--[[
Observer 계약 — `.claude/base/source-state-plan.md` "전파 루프 — 확정 의사코드"(`_receive`/`_catchUp`/
생성자 순서) / "`state:Observer(fn)`" 절(등록 즉시 1회, `fn(targetState, self, emitFrom?)`, `nil` 계약),
`.claude/base/lifecycle-pattern.md` "(2) 전역 경로"(네 진입점) / "(4) 실제 호출부"(`canExecute` 게이팅).
]]
local Quad = require("../src")
local mock = require("./mock")
local QuadTypes = require("../roblox_packages/quad_types")
type Observer = QuadTypes.Observer
-- strict 모드에서 `collectgarbage`는 선언 안 된 전역 — 표준 luau CLI엔 있다(Roblox엔 없음)
local collectgarbage = (_G :: any).collectgarbage :: () -> ()
local quad = Quad.New()
mock.installLifetime(quad)
local Source = quad.Source
local Instance = mock.Instance
print("=== 1. 생성 — 등록 즉시 1회(emitFrom = nil), _state 강참조, 브랜드, 순서(fn → _subs) ===")
do
local s = Source(1)
local calls: { any } = {}
local o = s:Observer(function(target, self, from)
table.insert(calls, { target = target, self = self, from = from })
end)
assert(#calls == 1 and calls[1].target == s and calls[1].self == o and calls[1].from == nil, "install fire: (state, observer, nil)")
assert(quad.isObserver(o) and not quad.isState(o) and not quad.isEpoch(o), "brand")
assert((o :: any)._state == s, "observer holds its State strongly")
assert((s :: any)._subs[o] == true, "joined the subscriber set after the install fire")
assert((o :: any)._rerunRequired == false, "install fire lowered the hold flag")
assert(o.Subscribed == false, "Subscribed starts false (shared flag, same as Effect — H-189)")
-- 설치 발화가 자기 State를 Set해도 플래그가 서지 않는다(순서 계약)
local s2 = Source(0)
local o2 = s2:Observer(function(target, _, from)
if from == nil and target:Get() == 0 then
(target :: any):Set(1)
end
end)
assert((o2 :: any)._rerunRequired == false, "fn → _subs order: the install fire's own Set did not raise the flag")
print("PASS")
end
print()
print("=== 2. _receive — canExecute 게이팅: 안 묶이면 홀드, 묶이면 fn(state, self, from) ===")
do
local s = Source(1)
local calls: { { from: any } } = {} -- `table.insert(t, nil)`은 길이를 안 늘리므로 래퍼로 기록
local o = s:Observer(function(_, _, from)
table.insert(calls, { from = from })
end)
s:Set(2)
assert(#calls == 1, "unbound observer does not fire")
assert((o :: any)._rerunRequired == true, "…the change is held (H-159)")
local inst = Instance.new("Frame")
quad.bindLifetime(inst, o)
assert(#calls == 2 and calls[2].from == nil, "bind replays the held change once, with no source (H-164)")
assert((o :: any)._rerunRequired == false, "flag lowered")
s:Set(3)
assert(#calls == 3 and calls[3].from == s, "bound: fires with the source epoch")
inst:Destroy()
s:Set(4)
assert(#calls == 3 and (o :: any)._rerunRequired == true, "dead inst: held again, not fired")
print("PASS")
end
print()
print("=== 3. 무인자 state:Observer() — 항상 관측 유틸(호출 즉시 Get) ===")
do
local runs = 0
local s = Source(1)
local d = s:Compute(function(x): number
runs += 1
return x:Get()
end)
local o = d:Observer()
assert(runs == 1, "install fire observed (computed) once")
o:Subscribe()
s:Set(2)
assert(runs == 2, "each change re-observes")
o:Unsubscribe()
print("PASS")
end
print()
print("=== 4. WeakSubscribe(프리미티브) — .Subscribed=true, 약한 등록, 캐치업; canExecute true ===")
do
local s = Source(1)
local calls = 0
local o = s:Observer(function()
calls += 1
end)
s:Set(2) -- held
assert(o:WeakSubscribe() == o, "returns self")
assert(o.Subscribed == true and quad.canExecute(o) == true, "flag + gate")
assert(calls == 2, "WeakSubscribe replayed the held change once")
s:Set(3)
assert(calls == 3, "fires while weakly subscribed")
local ok, err = pcall(function()
o:WeakSubscribe()
end)
assert(not ok and string.find(tostring(err), "already subscribed", 1, true) ~= nil, "double subscribe: " .. tostring(err))
assert(string.find(tostring(err), "spec.observer.luau", 1, true) ~= nil, "level 2")
local ok2, err2 = pcall(function()
quad.bindLifetime(Instance.new("Frame"), o)
end)
assert(not ok2 and string.find(tostring(err2), "already subscribed", 1, true) ~= nil, "bindLifetime shares the gate: " .. tostring(err2))
print("PASS")
end
print()
print("=== 5. WeakUnsubscribe — 관대(H-133), 단 강한 킵이 있으면 error ===")
do
local s = Source(1)
local o = s:Observer(function() end)
o:WeakUnsubscribe() -- never subscribed: silent
o:WeakSubscribe():WeakUnsubscribe()
assert((o :: any).Subscribed == false and quad.canExecute(o) == false, "released")
o:WeakUnsubscribe() -- already released: silent
o:Subscribe()
local ok, err = pcall(function()
o:WeakUnsubscribe()
end)
assert(not ok and string.find(tostring(err), "use :Unsubscribe()", 1, true) ~= nil, "strong keep present: " .. tostring(err))
assert(o.Subscribed == true, "…and nothing was changed")
o:Unsubscribe()
print("PASS")
end
print()
print("=== 6. Subscribe/Unsubscribe — 인라인 게이트(H-149), 강한 킵, 엄격 해제 ===")
do
local s = Source(1)
local calls = 0
local o = s:Observer(function()
calls += 1
end)
s:Set(2)
assert(o:Subscribe() == o and calls == 2, "Subscribe replays the held change once")
local ok, err = pcall(function()
o:Subscribe()
end)
assert(not ok and string.find(tostring(err), "spec.observer.luau", 1, true) ~= nil, "double subscribe points at the caller: " .. tostring(err))
local w = s:Observer(function() end):WeakSubscribe()
local ok2, err2 = pcall(function()
w:Unsubscribe()
end)
assert(not ok2 and string.find(tostring(err2), "use :WeakUnsubscribe()", 1, true) ~= nil, "strict: weakly subscribed → error: " .. tostring(err2))
assert(w.Subscribed == true, "…untouched")
assert(o:Unsubscribe() == o and o.Subscribed == false and quad.canExecute(o) == false, "released both tables")
local ok3 = pcall(function()
o:Unsubscribe()
end)
assert(not ok3, "second Unsubscribe errors (strict)")
s:Set(3)
assert(calls == 2, "released observer is silent")
print("PASS")
end
print()
print("=== 7. GC — 강한 구독은 참조를 안 들어도 산다, 약한 구독은 놓으면 수거 ===")
do
local s = Source(1)
local strongCalls, weakCalls = 0, 0
local function make()
s:Observer(function()
strongCalls += 1
end):Subscribe()
s:Observer(function()
weakCalls += 1
end):WeakSubscribe()
end
make()
collectgarbage()
collectgarbage()
strongCalls, weakCalls = 0, 0
s:Set(2)
assert(strongCalls == 1, "strongly subscribed observer survives with no references")
assert(weakCalls == 0, "weakly subscribed observer was collected")
print("PASS")
end
print()
print("=== 8. 파동 중 새 구독자 — 스냅샷 계약(다음 파동부터) + 인스턴스별 레지스트리 ===")
do
local s = Source(1)
local late: any = nil
local lateCalls = 0
s:Observer(function(_, _, from)
if from ~= nil and late == nil then
late = s:Observer(function(_, _, f)
if f ~= nil then
lateCalls += 1
end
end):Subscribe()
end
end):Subscribe()
s:Set(2)
assert(late ~= nil and lateCalls == 0, "subscriber added mid-wave is not visited in that wave")
s:Set(3)
assert(lateCalls == 1, "…but from the next wave")
local other = Quad.New()
mock.installLifetime(other)
local oo = other.Source(0):Observer(function() end)
assert(getmetatable(oo :: any) ~= getmetatable(late :: any), "each quad instance has its own Observer impl (H-174)")
print("PASS")
end
print()
print("=== ALL PASS ===")