fix(m2): /code-review high 반영 — mock Destroy 의미론(자손·순서·이중 no-op, H-172), lazy claim GC 타이밍(H-171), M7 TweenBrand 잔재(H-173), spec.init 신설; ② H-168~H-170은 round11 §4로
- test/mock.luau: Destroy = Destroying → Parent nil → 자손 재귀 → 연결 해제, 두 번째는 no-op; claim이 Destroy된 inst면 새 gcconn 즉시 Disconnect. spec.lifetime 6b/6c 추가. - test/spec.init.luau: Quad 탑레벨 값 확인을 typechecked 계층으로(smoke.init 5절 제거). - ROADMAP M7 체크박스·tween-plan: isTween/TweenBrand는 Brand.luau. - conventions: 규약 요약의 단위 나열 제거(소스는 brief §1). 코드 주석 절 인용을 제목 앞부분으로. - round11.md: H-168(Ref() 무인자 vs Ref<T>(T)) / H-169(재진입 :Set 옛 value) / H-170(resume이 에러 삼킴)을 §4 배치 문항으로(권고 전부 (a)), §5에 단위 끝 절차 기록. 세션 원문·요약 갱신. Co-authored-by: qwreey <me@qwreey.moe>
This commit is contained in:
parent
7e7328fd89
commit
325f3f0237
12 changed files with 191 additions and 37 deletions
|
|
@ -349,8 +349,8 @@ end)
|
|||
`:Apply`로 붙이는 factory는 항상 `State`를 반환해야 한다는 불변식.)
|
||||
|
||||
**base 프리미티브 아님 — 여전히 quad-roblox 유틸**(아래 "패키지 경계"
|
||||
절) — `Tween<T>` 값 타입/`isTween`만 base(`quad-base/Tween.luau`)에
|
||||
있고, `Animate`는 이미 있는 `:Compute`/`Tween{...}`/`isState`를 조합한
|
||||
절) — `Tween<T>` 값 타입만 base(`quad-base/Tween.luau`, `isTween`은
|
||||
**[2026-08-28]** 다른 술어와 같이 `Brand.luau`)에 있고, `Animate`는 이미 있는 `:Compute`/`Tween{...}`/`isState`를 조합한
|
||||
quad-roblox 레벨 편의 함수라 base 계약에 영향 없음.
|
||||
|
||||
## 초기 진입 애니메이션(`initValue`) — **에이전트 작업 범위 제외로 확정, 사용자가 직접 처리**
|
||||
|
|
|
|||
|
|
@ -381,8 +381,8 @@ haiku, 일반 작업은 sonnet. 특히 소스코드를 많이 읽어야 하는
|
|||
건 `H-nnn`으로 기록하고 `base/`+코드를 같은 커밋에서 자율로 고침 / 새
|
||||
필드·인자·이름·메커니즘·확정 역전은 코드에 넣지 말고 `round11.md` §4 표에
|
||||
쌓아 배치 회신(코드엔 `-- TODO(H-nnn)` 마커만) / 짠 코드 상당 부분을 무효화할
|
||||
규모면 즉시 중단·보고. (2) 단위 넷(① 공통 기반+mock 생명주기 / ② `EpochMap` 다음
|
||||
`Source/State/Store` / ③ `Observer`/`Effect` / ④ `GateNode`/`Blocker`+탑레벨)이
|
||||
규모면 즉시 중단·보고. (2) 단위 넷(구성은 그 파일 §1이 소스 — 여기 다시 나열하지
|
||||
않는다, 실제로 한 번 갈라졌다)이
|
||||
끝날 때마다 감사 루프 → `/code-review high` → 커밋 → 신선한 탐사자 → 사용자에게
|
||||
"§4를 보라" 한 줄. (3) 커밋 게이트는 두 층 — 매 커밋 doc-check ERROR 0, 단위
|
||||
끝 감사 루프. 이걸로 `HUMAN_TODO.md` 2번(자율 루프 설정 여부)은 닫혔다 —
|
||||
|
|
|
|||
|
|
@ -17,6 +17,12 @@
|
|||
| `H-165` | ① | 1 | 🟡 | `quad-types`에 `export type`을 더하면 pesde shim이 그걸 모른다 — `pesde install` 재실행 없이는 `QuadTypes.Ref`가 Unknown type | ✅ 반영(`project-setup-plan.md`) |
|
||||
| `H-166` | ① | 1 | 🟢 | `Ref.Revision` 초기값을 어느 문서도 안 정했다 | ✅ 반영(`ref-plan.md`: `0`) |
|
||||
| `H-167` | ① | 1 | 🟡 | 옮기며 `Ref<T>(default: T?)`/`.Value: T?`로 바꿔 놓았다 — 문서는 `Ref<T>(T)` 단일 파라미터, nil은 `Ref<<T?>>(nil)`로 | ✅ 코드를 문서에 맞춤(감사 2라운드) |
|
||||
| `H-168` | **②** | 1 | 🟡 | `Ref<T>(T)`면 무인자 `Ref()`가 strict에서 TypeError("expects 1 argument") — 그런데 `ref-plan.md`/`lifecycle-hooks-plan.md`/`debounce-throttle-plan.md`가 `Ref()`/`PreRef()`를 관용구로 가르친다 | §4 대기 |
|
||||
| `H-169` | **②** | 1 | 🟡 | `:Set` 블록이 스냅샷 콜백에 닫힌 인자 `value`를 넘겨서, 콜백 안 재진입 `ref:Set(new)` 뒤 남은 콜백이 **옛 값**을 받는다 — 문서 블록 그대로인데 문서의 "옛 값이 보이는 창이 없다"와 어긋남 | §4 대기 |
|
||||
| `H-170` | **②** | 1 | 🟡 | `coroutine.resume(k, self)`는 에러를 올리지 않는다 — 대기자 안의 에러·죽은 thread resume이 `:Set`에서 조용히 삼켜짐. `ref-plan.md` *"나중에 `coroutine.resume`이 에러남"*은 사실이 아님 | §4 대기 |
|
||||
| `H-171` | ① | 1 | 🟡 | mock lazy claim: Destroy된 inst에 다시 bind하면 GC 전엔 죽은 gcconn 재사용, GC 뒤엔 새 Connected gcconn — 결과가 GC 타이밍에 따라 갈림 | ✅ mock: 죽은 inst의 새 gcconn은 즉시 Disconnect(`spec.lifetime` 6b) |
|
||||
| `H-172` | ① | 1 | 🟡 | mock `Destroy`가 자손을 안 죽이고(조상 파괴 계약 검증 불가), `Parent` 변경 시그널이 연결 해제 뒤라 관측 불가, 재귀 Destroy 무한 루프 | ✅ mock: Destroying → Parent nil → 자손 Destroy → 연결 해제, 이중 Destroy no-op(`spec.lifetime` 6c) |
|
||||
| `H-173` | ① | 1 | 🟢 | `ROADMAP.md` M7 체크박스·`tween-plan.md` 352가 `isTween`/`TweenBrand`를 `Tween.luau`에 둔다고 아직 서술(감사 3라운드가 두 곳만 고침) | ✅ 반영 |
|
||||
|
||||
## 상세
|
||||
|
||||
|
|
@ -54,14 +60,63 @@
|
|||
nil 검사를 강요한다 — 문서가 이미 기각한 모양. 감사 2라운드가 발견.
|
||||
- **처리**: 코드를 문서대로 되돌림. 테스트의 `Ref()`는 `Ref<<number?>>(nil)`로.
|
||||
|
||||
### `H-168` 🟡 — `Ref<T>(T)`와 무인자 `Ref()` 관용구가 양립하지 않는다 (②)
|
||||
|
||||
- **어디서**: `quad-base/src/Ref.luau` 생성자 / `base/ref-plan.md` "제네릭 시그니처" vs 같은
|
||||
문서의 `Ref():Callback(fn)` 관용구(`H-120` 문단), `base/lifecycle-hooks-plan.md`의
|
||||
`PreRef():Callback(guard(fn))`, `base/debounce-throttle-plan.md`의 `Handle = Ref()`.
|
||||
- **무엇이**: `H-167`로 `default: T`가 되자 `local r = Quad.Ref()`가 strict에서 *"Argument
|
||||
count mismatch. Function expects 1 argument, but none are specified"*(리뷰 재현). 문서는
|
||||
명시 확장 `Ref<<T?>>(nil)`만 다루고 **빈 호출**은 안 다룬다. M8 훅 슈가가 문서대로
|
||||
짜이면 타입 검사를 못 통과한다.
|
||||
- **갈래**: §4.
|
||||
|
||||
### `H-169` 🟡 — 재진입 `:Set` 뒤 남은 콜백이 옛 `value`를 받는다 (②)
|
||||
|
||||
- **어디서**: `Ref.luau` `:Set`의 `k(value, self)` — `base/ref-plan.md` "`:Set(value)`의 순서"
|
||||
블록을 그대로 옮긴 것.
|
||||
- **무엇이**: 콜백 A가 `ref:Set(2)`를 다시 부르면 안쪽 파동이 먼저 다 돌고, 바깥 파동의 남은
|
||||
콜백 B는 `.Value == 2`인 채로 인자 `value == 1`을 받는다(리뷰 재현: B가 `(2,2)` 다음
|
||||
`(1,2)`). 문서의 *"모든 콜백이 새 값을 본다 … 옛 값이 보이는 창이 없다"*와 어긋남.
|
||||
문서가 재진입을 안 다뤄서 **문서 블록 수정이 필요한 자리** — 코드 임의 변경 금지.
|
||||
- **갈래**: §4.
|
||||
|
||||
### `H-170` 🟡 — `coroutine.resume`은 에러를 올리지 않는다 (②)
|
||||
|
||||
- **어디서**: `Ref.luau` `:Set`의 `coroutine.resume(k, self)` / `base/ref-plan.md` `:Wait(thread)`
|
||||
절 *"나중에 `coroutine.resume`이 에러남"* / `spec.ref.luau` 9번의 꼬리 `Set`.
|
||||
- **무엇이**: resume은 `(false, err)`를 **반환**하지 던지지 않는다. 대기자 안에서 난 에러와
|
||||
죽은 thread resume이 `:Set`에서 조용히 사라진다 — `architecture.md` "예외 안전성 계약 —
|
||||
감싸지 않는다"의 취지(에러는 전파된다)와 어긋나고, 문서 서술과 테스트 주석의 전제가
|
||||
틀려 있었다(주석은 고침).
|
||||
- **갈래**: §4.
|
||||
|
||||
### `H-171` 🟡 — mock lazy claim이 GC 타이밍에 따라 갈린다
|
||||
|
||||
- **처리**: `claim`이 이미 Destroy된 inst면 새 gcconn을 즉시 `Disconnect` — Roblox에선
|
||||
`nativeClaim`이 생성 시 1회라 이 경로가 없다. `spec.lifetime.luau` 6b가 GC 전/후 둘 다 고정.
|
||||
|
||||
### `H-172` 🟡 — mock `Destroy` 의미론
|
||||
|
||||
- **처리**: Destroying 발화 → Parent nil(변경 시그널 관측 가능) → 자손 재귀 Destroy → 연결
|
||||
해제, 이미 파괴됐으면 no-op. 6c가 조상 파괴 계약(5라운드 *"조상 파괴 시 unowned 요소도
|
||||
같이 죽는다"*)을 mock에서 검증 가능하게 한다.
|
||||
|
||||
### `H-173` 🟢 — `TweenBrand`/`isTween` 위치 잔재 둘
|
||||
|
||||
- **처리**: `ROADMAP.md` M7 체크박스, `tween-plan.md` "base 프리미티브 아님" 문단.
|
||||
|
||||
## §4 ⭐ 사용자 결정이 필요한 것 (배치 회신용)
|
||||
|
||||
| 문항 | 무엇 | 선택지 | 권고 | 권고 근거 | 옛 메커니즘 복원? |
|
||||
|---|---|---|---|---|---|
|
||||
| — | **[2026-08-28 기준] 비어 있음** | — | — | — | — |
|
||||
| **`H-168`** | `Ref<T>(T)` vs 무인자 `Ref()` 관용구 | (a) 시그니처 유지, 문서의 빈 호출 관용구를 전부 `Ref<<T?>>()`(nil-able 파라미터는 생략 가능)로 고쳐 씀 / (b) `default: T?`, `.Value: T?`(H-167 이전 모양 — `Ref(5).Value`까지 nil 검사 강요) / (c) 두 오버로드 `Ref<T>(T)` ∪ `Ref<T>() -> Ref<T?>` | **(a)** | "제네릭 시그니처" 확정(단일 파라미터, 명시 확장으로 넓힘)을 그대로 두고 관용구만 그 규칙에 맞추는 것 — (b)는 문서가 기각한 모양, (c)는 그 절이 기각한 2-파라미터 솔버 문제의 재개방 | (b)가 그렇다 |
|
||||
| **`H-169`** | 재진입 `:Set` 뒤 남은 콜백의 인자 | (a) 블록을 `k(self.Value, self)`로(항상 최신 값, 문서 불변식 그대로) / (b) 문서에 "재진입 시 바깥 파동의 남은 콜백은 자기 파동의 값을 받는다"로 계약화(코드 유지) / (c) 재진입 자체를 금지(error) | **(a)** | 문서 불변식(*"옛 값이 보이는 창이 없다"*)이 이미 (a)를 말하고 있고 한 토큰 차이 — (b)는 인자와 `.Value`가 다른 창을 계약으로 열고, (c)는 새 가드 | 아니오 |
|
||||
| **`H-170`** | resume이 삼키는 에러 | (a) `local ok, err = coroutine.resume(k, self); if not ok then error(err, 0) end` — 대기자 에러를 `:Set` 호출부로 다시 올림 / (b) UB로 문서화(대기자 에러는 사라진다) / (c) 대기자 소진을 `task.spawn`류 주입 op로(Roblox `task.spawn`은 에러를 콘솔로 보냄) | **(a)** | `architecture.md` "예외 안전성 계약"(감싸지 않는다 = 에러는 전파)과 같은 결. 다만 **새 코드 두 줄(re-raise)** 이라 사용자 결정 자리. (c)는 새 주입 op | 아니오 |
|
||||
|
||||
코드 쪽 잔여 마커: `grep -rn "TODO(H-" quad-base/src` — 이 표의 문항과 1:1이어야
|
||||
한다.
|
||||
한다. **[2026-08-28 기준] 마커 0개** — 위 셋은 단위 1 모듈을 막지 않아 코드는 문서
|
||||
블록 그대로 두고 문항만 올렸다(`H-168`은 코드가 아니라 M8 문서의 관용구 문제).
|
||||
|
||||
## §5 이상 없다고 확인한 것
|
||||
|
||||
|
|
@ -87,4 +142,11 @@
|
|||
(테스트 클로저가 업밸류를 직접 변경하게 할 것 — `lifecycle-pattern.md` (0)의
|
||||
`false or` 트릭이 막는 것과 같은 최적화).
|
||||
|
||||
**단위 1 끝 절차 기록**: 감사 루프 8라운드(발견 3→4→1→2→4→4→1→0, 5·6라운드 절반은
|
||||
각도를 넓혀 잡힌 옛 같은-파일 절 인용 부채) → `/code-review high` 1회 10건: ② 셋(`H-168`~
|
||||
`H-170`), ① 셋(`H-171`~`H-173`), 잔손질 셋(`smoke.init` 5절 → typechecked `spec.init.luau` /
|
||||
`conventions.md`의 단위 나열 제거 / 코드 주석의 절 인용을 제목 앞부분으로), 그리고
|
||||
기각 셋(gcconn 슬롯 중복·mock claim 불멸·공유 본문 빌더 — 전부 `lifecycle-pattern.md`
|
||||
(0)/(1) 그대로이거나 `claim-plan.md` §7-9가 이미 기각한 모양).
|
||||
|
||||
## §6 남은 의심 / 못 본 것
|
||||
|
|
|
|||
|
|
@ -2023,4 +2023,7 @@ Q4(`EffectHandle` 네 진입점 의사코드 — Observer 것 재사용, `Unsubs
|
|||
`is*` 11 한 잎 파일)/`LifetimeHandle`(`InitLifetimeHandle` 에러 스텁)/`Ref` 최소형/
|
||||
`quad-types` 타입/mock `installLifetime`(+`Destroy`가 Connection 전부 끊음)/spec 5개,
|
||||
`test.sh`에 `spec.*`+`luau-analyze`. 발견 `H-165`(pesde shim은 생성 시점 export만 —
|
||||
`pesde install` 재실행)·`H-166`(`Revision` 초기값 0) 둘 다 ①. ②/③ 없음.
|
||||
`pesde install` 재실행)·`H-166`(`Revision` 초기값 0) 둘 다 ①. 단위 끝: 감사 8라운드
|
||||
수렴(`H-167` `Ref<T>(T)` 되돌림 포함), `/code-review high` 10건 → ② 셋(`H-168` `Ref()`
|
||||
무인자 관용구 / `H-169` 재진입 `:Set` 옛 값 / `H-170` resume이 에러 삼킴, 권고 전부 (a))
|
||||
§4로, ① 셋(mock lazy claim·`Destroy` 의미론·M7 `TweenBrand` 잔재) 반영.
|
||||
|
|
|
|||
|
|
@ -33,7 +33,22 @@
|
|||
업밸류 클로저 캐시) — §5.
|
||||
- ②/③ 갈래 발견 **없음** — §4 표는 비어 있다.
|
||||
|
||||
## 단위 끝 절차 (같은 날, 뒤이어)
|
||||
|
||||
- 감사 루프 **8라운드** 수렴(3→4→1→2→4→4→1→0). 잡힌 것: `todos.md` 5번 stale /
|
||||
`smoke.init` 갱신 누락 / `quad-types-plan.md` `Quad` 블록 stale → 코드 포인터 /
|
||||
mock docstring 자기모순 / `architecture.md` 소스 트리(`Brand.luau` 누락·`LifetimeHandle`
|
||||
"내부는 Relate"·`Tween.luau`) / **`H-167`** `Ref<T>(T?)`로 옮겨 놓은 자기 실수 →
|
||||
문서대로 `Ref<T>(T)` / `TweenBrand` 위치 / `ROADMAP` "`Brand.luau` 트리에 없음" /
|
||||
`lifecycle-pattern.md`의 옛 "base가 Relate로 구현" 서술·원 확정 문단·순환 절 인용 /
|
||||
`ref-plan.md` 같은-파일 절 인용 둘 / 개수 리터럴 / `conventions.md` 단위 나열.
|
||||
**내 수정이 새 결함을 만든 것 둘**(순환 절 인용, 단위 나열 5세그먼트) — 코퍼스의
|
||||
기록된 실패 모드 그대로.
|
||||
- `/code-review high` 10건 → ② `H-168`(`Ref()` 무인자 vs `Ref<T>(T)`) / `H-169`(재진입
|
||||
`:Set`의 옛 `value`) / `H-170`(resume이 에러를 삼킴)은 §4 표로, ① `H-171`(mock lazy
|
||||
claim GC 타이밍) / `H-172`(mock `Destroy` 의미론) / `H-173`(M7 `TweenBrand` 잔재) 반영,
|
||||
잔손질 셋 반영, 기각 셋.
|
||||
|
||||
## 다음
|
||||
|
||||
단위 끝 절차(규약 §4): 감사 루프 → `/code-review high` → 커밋 → fable 탐사자 →
|
||||
사용자에게 "§4를 보라".
|
||||
fable 탐사자(규약 §5) → 사용자에게 "`round11.md` §4를 보라". §4엔 문항 셋, 권고 전부 (a).
|
||||
|
|
|
|||
|
|
@ -1707,8 +1707,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
|
||||
### 짜야 할 것
|
||||
|
||||
- [ ] `quad-base/Tween.luau`(값 타입만 — `Tween(opts)` 팩토리, `isTween`/
|
||||
`TweenBrand`, `Value: T` plain만 받고 State 재귀 없음)
|
||||
- [ ] `quad-base/Tween.luau`(값 타입만 — `Tween(opts)` 팩토리가 `TweenBrand`에
|
||||
등록, **[2026-08-28]** 브랜드 인스턴스와 `isTween`은 `Brand.luau`에 추가,
|
||||
`Value: T` plain만 받고 State 재귀 없음)
|
||||
- [ ] `Handlers/Property.luau`에 `isTween(realv)` 분기 추가(기존
|
||||
`Handlers/Tween.luau` 독립 핸들러는 폐기) + 3-상태 릴레이션 슬롯
|
||||
(**[2026-08-28 `H-155`]** `base/tween-plan.md`의 "3-상태 저장" 절이 소스 —
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
Sources, as-is:
|
||||
- `.claude/base/ref-plan.md` "API 모양" (surface, self-returning
|
||||
mutations, "already filled → call once on registration"),
|
||||
"`.Callbacks`는 … 해시맵 셋" (dedup contract, snapshot before
|
||||
"`.Callbacks`는 배열이 아니라" (dedup contract, snapshot before
|
||||
iterating), "`:WeakCallback(fn)`" (weak-key table `.WeakCallbacks`,
|
||||
Weak is the primitive and `:Callback` only adds the GC keep),
|
||||
"`:Set(value)`의 순서" (value → revision → callbacks; the code below
|
||||
|
|
|
|||
|
|
@ -19,8 +19,9 @@
|
|||
`.claude/base/lifecycle-pattern.md` (0)/(1)의 실 구현 스케치를 그대로 옮긴
|
||||
것(gcconn = 절대 안 발화하는 `ClassName` 변경 시그널의 Connection,
|
||||
gchold = inst에 매달린 값들의 강참조 홀더, `InstData`/`BindData` 두 Relate).
|
||||
이걸 위해 `Destroy`가 Roblox처럼 **그 인스턴스의 모든 Connection을 끊는다**
|
||||
(`Connected = false`) — gcconn의 `.Connected`가 곧 생존 판정이라서.
|
||||
이걸 위해 `Destroy`가 Roblox처럼 동작한다 — Destroying 발화 → Parent nil →
|
||||
**자손까지 Destroy** → **그 인스턴스의 모든 Connection을 끊는다**(`Connected =
|
||||
false`), 두 번째 Destroy는 no-op. gcconn의 `.Connected`가 곧 생존 판정이라서.
|
||||
Roblox와 다른 점 하나: gcconn/gchold 셋업(`nativeClaim`)은 quad가 Instance를
|
||||
만들 때 하는 일인데 mock Instance는 quad 밖에서 만들어지므로 여기선
|
||||
`bindLifetime` 첫 호출에 lazy로 한다 — quad-roblox는 그러면 안 된다
|
||||
|
|
@ -102,6 +103,7 @@ type Data = {
|
|||
changed: { [any]: Signal },
|
||||
properties: { [any]: any },
|
||||
destroying: Signal,
|
||||
destroyed: boolean,
|
||||
}
|
||||
|
||||
local dataOf = setmetatable({}, { __mode = "k" }) :: { [any]: Data }
|
||||
|
|
@ -194,6 +196,7 @@ function Instance.new(className: string): MockInstance
|
|||
changed = {},
|
||||
properties = {},
|
||||
destroying = newSignal(),
|
||||
destroyed = false,
|
||||
}
|
||||
return (getProxy(data) :: any) :: MockInstance
|
||||
end
|
||||
|
|
@ -229,13 +232,14 @@ end
|
|||
|
||||
function methods.Destroy(inst: any)
|
||||
local data = getData(inst)
|
||||
data.destroying:Fire()
|
||||
-- Roblox처럼 이 인스턴스의 모든 연결을 끊는다(Destroying 포함) —
|
||||
-- gcconn(`ClassName` 변경 시그널)의 `.Connected`가 생존 판정의 근거.
|
||||
data.destroying:DisconnectAll()
|
||||
for _, sig in data.changed do
|
||||
sig:DisconnectAll()
|
||||
if data.destroyed then
|
||||
return -- Roblox처럼 두 번째 Destroy는 no-op (Destroying 핸들러 안의 재귀 Destroy 포함)
|
||||
end
|
||||
data.destroyed = true
|
||||
-- Roblox 순서: Destroying 발화 → Parent = nil(변경 시그널 관측 가능) → 자손 Destroy
|
||||
-- → 이 인스턴스의 모든 연결 해제. gcconn(`ClassName` 변경 시그널)의 `.Connected`가
|
||||
-- 생존 판정의 근거라 마지막 단계가 곧 canExecute의 전환이다.
|
||||
data.destroying:Fire()
|
||||
if data.parent then
|
||||
local siblings = data.parent.children
|
||||
local i = table.find(siblings, data)
|
||||
|
|
@ -245,6 +249,13 @@ function methods.Destroy(inst: any)
|
|||
data.parent = nil
|
||||
fireChanged(data, "Parent")
|
||||
end
|
||||
for _, childData in table.clone(data.children) do
|
||||
methods.Destroy(getProxy(childData))
|
||||
end
|
||||
data.destroying:DisconnectAll()
|
||||
for _, sig in data.changed do
|
||||
sig:DisconnectAll()
|
||||
end
|
||||
end
|
||||
|
||||
local function isMockInstance(value: any): boolean
|
||||
|
|
@ -284,6 +295,13 @@ local function installLifetime(quad: any)
|
|||
nop(gchold, inst) -- 절대 발화 안 함. 클로저가 gchold와 inst를 업밸류로 붙잡는 게 전부
|
||||
end)
|
||||
gchold[1] = gcconn -- 배열 자리 1번은 gcconn 전용(값들은 해시 자리에)
|
||||
if getData(inst).destroyed then
|
||||
-- lazy claim의 구멍(리뷰 `H-171`): 이미 Destroy된 인스턴스의 옛 gcconn은 연결이
|
||||
-- 끊겨 GC되므로 여기로 다시 들어오는데, 새 gcconn이 Connected=true면 그 값은
|
||||
-- 영원히 산다. Roblox에선 nativeClaim이 생성 시 1회라 이 경로 자체가 없다 —
|
||||
-- 죽은 인스턴스에 묶인 값은 즉시 죽은 상태로 시작하게 맞춘다.
|
||||
gcconn:Disconnect()
|
||||
end
|
||||
InstData:SetWeak(inst, "gchold", gchold)
|
||||
InstData:SetWeak(inst, "gcconn", gcconn)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -63,20 +63,5 @@ do
|
|||
print("PASS")
|
||||
end
|
||||
|
||||
print()
|
||||
print("=== 5. [2026-08-28 M2 첫 단위] 탑레벨 값이 New()마다 실려 있음 ===")
|
||||
do
|
||||
local m = Quad.New()
|
||||
assert(type(m.Relate) == "function" and type(m.Void) == "function" and type(m.Ref) == "function", "Relate/Void/Ref constructors")
|
||||
for _, name in { "isEpoch", "isSource", "isState", "isStore", "isObserver", "isEffect", "isBlocker", "isModifier", "isRef", "isPreRef", "isPostRef" } do
|
||||
assert(type((m :: any)[name]) == "function", name .. " must be a function")
|
||||
end
|
||||
for _, name in { "bindLifetime", "unbindLifetime", "canBound", "canExecute" } do
|
||||
assert(type((m :: any)[name]) == "function", name .. " stub must be installed")
|
||||
end
|
||||
assert(m.Void == Quad.Void and m.Ref == Quad.Ref, "leaf modules are shared across instances")
|
||||
print("PASS")
|
||||
end
|
||||
|
||||
print()
|
||||
print("=== ALL PASS ===")
|
||||
|
|
|
|||
32
quad-base/test/spec.init.luau
Normal file
32
quad-base/test/spec.init.luau
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
--[[
|
||||
`Quad` 탑레벨 값 존재 확인 — `quad-types`의 `Quad` 타입과 `New()`가 실제로 싣는 값의
|
||||
드리프트(`ROADMAP.md` M2 `H-80`/`H-25` 부류)를 타입 검사 아래에서 잡는다.
|
||||
`init.luau`의 `{...} :: Quad` 캐스트는 빠진 필드를 안 잡으므로(실측) 이 런타임 확인이
|
||||
유일한 가드다 — 그래서 `smoke.*`(analyze 제외)가 아니라 `spec.*`에 둔다.
|
||||
]]
|
||||
|
||||
local Quad = require("../src")
|
||||
local QuadTypes = require("../roblox_packages/quad_types")
|
||||
|
||||
type Quad = QuadTypes.Quad
|
||||
|
||||
print("=== 1. New()마다 M2 첫 단위 탑레벨 값이 실려 있음 ===")
|
||||
do
|
||||
local m: Quad = Quad.New()
|
||||
assert(type(m.Relate) == "function" and type(m.Void) == "function" and type(m.Ref) == "function", "Relate/Void/Ref constructors")
|
||||
local predicates: { (any) -> boolean } = {
|
||||
m.isEpoch, m.isSource, m.isState, m.isStore, m.isObserver, m.isEffect,
|
||||
m.isBlocker, m.isModifier, m.isRef, m.isPreRef, m.isPostRef,
|
||||
}
|
||||
for i, p in predicates do
|
||||
assert(type(p) == "function", "predicate #" .. i .. " must be a function")
|
||||
assert(p({}) == false, "predicate #" .. i .. " is false for an unregistered table")
|
||||
end
|
||||
assert(type(m.bindLifetime) == "function" and type(m.unbindLifetime) == "function", "lifetime stubs")
|
||||
assert(type(m.canBound) == "function" and type(m.canExecute) == "function", "lifetime stubs")
|
||||
assert(m.Void == Quad.Void and m.Ref == Quad.Ref and m.Relate == Quad.Relate, "leaf modules are shared across instances")
|
||||
print("PASS")
|
||||
end
|
||||
|
||||
print()
|
||||
print("=== ALL PASS ===")
|
||||
|
|
@ -111,6 +111,43 @@ do
|
|||
print("PASS")
|
||||
end
|
||||
|
||||
print()
|
||||
print("=== 6b. 이미 Destroy된 inst에 bind — GC 여부와 무관하게 죽은 상태로 시작 (H-171) ===")
|
||||
do
|
||||
local inst = Instance.new("Frame")
|
||||
quad.bindLifetime(inst, {})
|
||||
inst:Destroy()
|
||||
local a = {}
|
||||
quad.bindLifetime(inst, a)
|
||||
assert(quad.canExecute(a) == false, "bound onto a destroyed inst: dead immediately (no GC yet)")
|
||||
collectgarbage()
|
||||
collectgarbage()
|
||||
local b = {}
|
||||
quad.bindLifetime(inst, b)
|
||||
assert(quad.canExecute(b) == false, "bound onto a destroyed inst after GC: still dead")
|
||||
print("PASS")
|
||||
end
|
||||
|
||||
print()
|
||||
print("=== 6c. 조상 Destroy — 자손에 묶인 값도 같이 죽는다 (H-172) ===")
|
||||
do
|
||||
local root = Instance.new("Frame")
|
||||
local child = Instance.new("Frame")
|
||||
child.Parent = root
|
||||
local v = {}
|
||||
quad.bindLifetime(child, v)
|
||||
local parentChanges = 0
|
||||
child:GetPropertyChangedSignal("Parent"):Connect(function()
|
||||
parentChanges += 1
|
||||
end)
|
||||
root:Destroy()
|
||||
assert(child.Parent == nil, "descendant is detached")
|
||||
assert(quad.canExecute(v) == false, "value bound to a descendant dies with the ancestor")
|
||||
assert(parentChanges == 1, "Parent change is observable before connections are cut")
|
||||
root:Destroy() -- 두 번째는 no-op
|
||||
print("PASS")
|
||||
end
|
||||
|
||||
print()
|
||||
print("=== 7. 첫 인자는 mock Instance여야 함 ===")
|
||||
do
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
--[[
|
||||
Ref 최소형 계약 — `.claude/base/ref-plan.md` "API 모양" / "`.Callbacks`는 … 해시맵 셋" /
|
||||
Ref 최소형 계약 — `.claude/base/ref-plan.md` "API 모양" / "`.Callbacks`는 배열이 아니라" /
|
||||
"`:WeakCallback(fn)`" / "`:Set(value)`의 순서" / "`Ref`는 `Epoch`를 만족한다",
|
||||
`.claude/base/state-epoch-plan.md` §2(리비전 랩).
|
||||
]]
|
||||
|
|
@ -198,7 +198,8 @@ do
|
|||
assert(got == r, "waiter is resumed with the Ref itself")
|
||||
assert(r.Callbacks[co] == nil, "waiter is consumed")
|
||||
assert(coroutine.status(co) == "dead", "waiter ran to completion")
|
||||
r:Set(2) -- 소진됐으니 다시 resume 안 함(죽은 코루틴 resume이면 여기서 티가 남)
|
||||
r:Set(2) -- 소진 확인은 위 Callbacks[co] == nil로 충분 — coroutine.resume은 에러를 올리지 않아
|
||||
-- 죽은 코루틴을 다시 resume해도 여기서 티가 안 난다(리뷰 `H-170`, §4 문항)
|
||||
print("PASS")
|
||||
end
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue