quad/quad-base/test/spec.observer.luau
qwreey-agent-selene 0d66e03875
feat: round11 §4 배치 회신 1차 반영 — 확정 일곱(H-182~H-185/H-187/H-200/H-203), 재질문 둘(H-186/H-198)·보류(H-205)
- H-182 (a): Effect `_dying` — Destroying 콜백이 세우고 재바인드·Subscribe류가
  내림, rawRerun 홀드 조건 합류(Slot `_destroyed`와 다른 이름은 의도 — 재바인드 가능)
- H-183 (a): Observer `_running` — 모든 fn 실행 둘레 + 네 진입점 첫 줄 가드
  (H-147 대칭; error 시 잔류는 설계상 인정)
- H-184 (a): `_assertBindable` 훅 — bindLifetime이 부기 커밋 전에 문의(level 3),
  H-147 가드는 _bindDestroying에서 이 훅으로 이동. mock + lifecycle-pattern (1)
- H-185: 권고 기각 — cleanup은 하나만(목록 소진은 표면 확대), 문서·타입 주석 명시
- H-187 (a): 타입 별칭 이름 넷 승인(quad-types-plan 기록, 마커 해소)
- H-200 (b): Gate 생성이 setup 동안 상류 _subs에서 떼고 성공 후 재등록(pcall 없음)
- H-203 (a): Blocker 순회가 핸들마다 IsBlocked 재확인 — 재차단 시 잔여는 다음 Off로
- H-168~H-170 재확인 반영: H-170 한계 공개 문서화(ref-plan + content-map 22번)
- 스펙: spec.effect 10 / spec.observer 9 / spec.gate 1 확장 / spec.blocker 8
- 감사 3라운드(3+2 → 3 → 0 수렴): 필드 목록 _running/_dying, CLAUDE.md·
  project-context.md M2 배너(목록은 todos.md 00번 단일 소스), session-summary
- 남은 코드 마커 셋: H-186/H-198(재질문 — §4 회신 2 블록에 메인 답변), H-205(보류)

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01LF78pXeFGD1ZSVD3ifteYG
2026-08-31 12:56:31 +09:00

280 lines
10 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) 전역 경로"(네 진입점, `H-183` `_running`) / "(1)"(`bindLifetime`의
커밋 전 `_assertBindable` 문의 — `H-184`) / "(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("=== 9. H-183 — fn은 자기 생명주기를 못 바꾼다(_running 가드, H-147 대칭); H-184 — 거부돼도 반쯤 묶이지 않음 ===")
do
local s = Source(1)
local inst = Instance.new("Frame")
local subErr: any, bindErr: any = nil, nil
local o = s:Observer(function(_, self)
local ok, err = pcall(function()
(self :: any):Subscribe()
end)
if not ok then
subErr = tostring(err)
end
local ok2, err2 = pcall(function()
quad.bindLifetime(inst, self)
end)
if not ok2 then
bindErr = tostring(err2)
end
end)
assert(subErr ~= nil and string.find(subErr, "inside its own fn", 1, true) ~= nil, "Subscribe inside the install fire errors: " .. tostring(subErr))
assert(bindErr ~= nil and string.find(bindErr, "inside its own fn", 1, true) ~= nil, "bindLifetime inside the install fire errors: " .. tostring(bindErr))
-- H-184: the refused bind committed NOTHING — the handle binds cleanly afterwards
assert(quad.canBound(o), "not half-bound after the refusal")
quad.bindLifetime(inst, o)
assert(quad.canExecute(o), "binds cleanly after construction")
-- _receive-time fn runs are guarded the same way
local recvErr: any = nil
local s3 = Source(0)
local o3: any
o3 = s3:Observer(function(_, self, from)
if from ~= nil then
local ok, err = pcall(function()
(self :: any):WeakSubscribe()
end)
if not ok then
recvErr = tostring(err)
end
end
end)
quad.bindLifetime(Instance.new("Frame"), o3)
s3:Set(1)
assert(recvErr ~= nil and string.find(recvErr, "inside its own fn", 1, true) ~= nil, "entry point inside a _receive fn errors: " .. tostring(recvErr))
-- fn이 error로 죽으면 _running이 선 채 남는다 — 진입점은 막히지만 발화는 계속
-- (오류 시 구조 깨짐은 설계상 인정 — 사용자 확정 2026-08-31)
local s4 = Source(0)
local fires = 0
local o4: any = s4:Observer(function(_, _, from)
fires += 1
if from ~= nil then
error("observer fn boom")
end
end)
quad.bindLifetime(Instance.new("Frame"), o4)
assert(not pcall(function()
s4:Set(1)
end), "the fn error propagates out of Set")
assert(o4._running == true, "the flag stays up after an error (dead by contract)")
assert(not pcall(function()
o4:Subscribe()
end), "entry points refuse while the flag is up")
local okAfter = pcall(function()
s4:Set(2)
end)
assert(not okAfter and fires == 3, "firing itself is not gated by the flag (next Set still runs fn)")
print("PASS")
end
print()
print("=== ALL PASS ===")