06. Roblox Studio 없이 헤드리스로 테스트하기
This content is not available in your language yet.
대상 독자: 반응형 로직을 Studio를 켜지 않고 검증하고 싶은 개발자 다루는 개념:
quad-base의 엔진 무관성,Source/Compute/Observer/Store/Blocker의 등록 의미론, 프로바이더 주입,Slot조립 범위: 반응형 그래프(§2)는 여러분의 프로젝트에 이미 깔려 있는quad_base만으로 평범한luauCLI에서 그대로 됩니다. 백엔드가 있어야 하는 부분(§3 — 프로바이더 주입,Observer/Effect/Slot의 생명주기 결합, 트리 조립)은 갖다 쓸 수 있는 공개 mock 패키지가 아직 없어서 자기 프로바이더를 직접 붙이거나 이 저장소의 spec을 본보기로 삼아야 합니다. 실제 Roblox 프로퍼티/이벤트/Tween은 Roblox Studio에서 직접 확인해야 합니다.
1. quad-base는 엔진을 모른다
섹션 제목: “1. quad-base는 엔진을 모른다”quad-base는 Roblox 전역(Instance, game, task …)을 참조하지 않습니다.
물리 트리를 만지는 일은 전부 프로바이더가 주입하는 엔진 op를 통해서만
일어납니다. 그래서 Source/State/Store/Blocker/Slot 같은 반응형·부기
로직은 평범한 luau CLI에서 그대로 돌아갑니다.
이 구조를 테스트에 쓰는 방법은 세 층이고, 첫 층만 백엔드 없이 됩니다.
1. 반응형 그래프 (Source, State, Store, Blocker, Compute) → 백엔드 없이 그냥 돈다2. 디스패치/Slot 부기 (Slot:List, 재조정, 생명주기) → 프로바이더가 있어야 돈다(공개 mock 없음)3. 실제 Roblox 프로퍼티/이벤트/Tween → Roblox Studio에서 직접 확인해야 한다UI 버그가 났을 때 1층에서 재현되면 Studio를 열 필요가 없습니다.
2. 반응형 그래프만 테스트하기 — 오늘 되는 것
섹션 제목: “2. 반응형 그래프만 테스트하기 — 오늘 되는 것”백엔드 설치 없이 quad-base만 있으면 됩니다. 가장 단순한 구성은 quad_base
하나만 까는 별도의 luau 타깃 pesde 프로젝트입니다(게임 본체 프로젝트와
분리해도 되고, 같은 워크스페이스 안에 둬도 됩니다) — 실제로 레지스트리에서
설치해 확인한 구성입니다.
name = "you/headless_demo"version = "0.1.0"
[indices]default = "https://github.com/pesde-pkg/index"
[target]environment = "luau"
[dependencies]quad_base = { name = "qwreey/quad_base", version = "^4.0.0" }pesde installpesde install이 만드는 luau_packages/는 이렇습니다(실측 그대로).
luau_packages/├── quad_base.luau -- 링커: require("./.pesde/.../quad_base/src")로 위임└── .pesde/qwreey+quad_base/4.0.0/quad_base/src/...quad_base.luau도 그 아래 .pesde/ 실체도 전부 일반 파일입니다 —
심볼릭 링크가 아니라서 순정 luau CLI가 별다른 준비 없이 그대로 따라갑니다.
(이 저장소 자신을 고칠 때 필요한 scripts/relink.sh는 다른 문제입니다 — 이
저장소가 여러 패키지를 한 워크스페이스로 묶어 두고 서로 참조하는 데서 생기는
심볼릭을 되돌리는 절차라, 여러분의 프로젝트에는 해당하지 않습니다. 자세한 건
CONTRIBUTING.md.)
00. 설치대로 environment = "roblox"로 구성한 게임
프로젝트의 roblox_packages/ 사본을 luau CLI에서 바로 require하는 것은 확인하지 않았습니다 —
헤드리스 검증은 위처럼 luau 타깃 프로젝트를 따로 두는 것이 확인된 길입니다.
local Quad = require("../luau_packages/quad_base")
local count = Quad.Source(0)local isEven = count:Compute(function(c) return c:Get() % 2 == 0end)
assert(count:Get() == 0, "초기값은 0")assert(isEven:Get() == true, "0은 짝수")
count:Set(1)assert(count:Get() == 1 and isEven:Get() == false, "파생 상태가 따라온다")Blocker도 같은 층입니다. 다만 Blocker는 state:Apply(blocker)로 붙인
노드만 게이트한다는 점에 주의하세요 — 그냥 만들어 두기만 하면 아무것도
막히지 않습니다. 그리고 막히는 것은 전파이지 값이 아닙니다.
local raw = Quad.Source(0)local gate = Quad.Blocker()local gated = raw:Apply(gate)
gate:On()raw:Set(1)raw:Set(2)assert(gated:Get() == 2, "값 자체는 항상 최신 — 막히는 것은 아래로의 전파다")gate:Off() -- 여기서 밀린 전파가 정확히 한 번-- gate:OffWithoutEmit() -- 밀린 전파를 버리며 연다Observer/Effect도 이 층에서 등록은 됩니다 — hp:Observer(fn)을 만들면
곧바로 1회 발화합니다. 다만 그 이상은 이 층의 일이 아닙니다: 프로바이더가
아예 없는 채로 그 원천에 다시 :Set을 부르면 조용히 보류되는 게 아니라
Quad0108 quad: isHeld is not available - no backend has installed the lifetime primitives / engine ops …
에러로 그 자리에서 던집니다(생명주기 op가 하나도 안 심긴 상태라는 안내). 인스턴스 수명에
묶이는 나머지 동작(보류·재생·정지)은 §3에서 다룹니다.
3. 백엔드가 필요한 것 — 공개 mock은 아직 없습니다
섹션 제목: “3. 백엔드가 필요한 것 — 공개 mock은 아직 없습니다”⚠️ 공개 mock 패키지는 아직 없습니다. 이 저장소가 쓰는
quad-base/test/mock.luau는 테스트 내부물이고 배포되지 않습니다 — 물리 트리 조작·판정·훅·생명주기 hold op 넷·태그/어트리뷰트·시간 op를 전부 심지만 그 위에 공개 표면(q.Elem등)은 없습니다. 범용 렌더 디버깅 도구quad-mock은 백로그입니다 — 언젠가 사용자 대면 패키지가 될 예정이지만 지금은 아닙니다. 지금Observer/Effect/Slot을 헤드리스로 검증하려면 자기 프로바이더를 직접 쓰거나, 이 저장소의 spec을 본보기로 삼으세요. 프로바이더가 채워야 하는 계약 전체는 백엔드 프로바이더 규약이 다룹니다.
Observer/Effect/Slot은 인스턴스 수명에 묶여 동작하므로, 생명주기 op를
심는 프로바이더가 있어야 §2에서 못 본 나머지가 돕니다. 설치 표면은 하나입니다.
strict 한 줄 — 자기 프로바이더 함수의 반환 타입을
any나 인덱서 테이블({ [any]: any })로 주석하지 마세요.:UseProvider는Self & P를 돌려주므로(P가 프로바이더 반환 타입) 그 뒤 모든q.<멤버>호출이 strict에서Cannot call a value of type *error-type* in union으로 죽습니다({}·구체 필드 테이블·실 프로바이더는 문제없음 — [2026-09-27 실측]). 이 저장소의mock.luau프로바이더가 바로 그 인덱서 모양이라 본보기로 옮길 때 주석은 떼거나 구체 타입으로 바꾸세요.
local q = Quad.NewModule():UseProvider(myProvider)UseProvider는 모듈당 한 슬롯입니다. 같은 프로바이더 함수로 다시 부르면 아무 일도 안 하고(멱등), 다른 함수를 넣으면Quad0213 UseProvider: this Quad module already has a provider - a module cannot serve two backends에러입니다.require(quad-base)가 돌려주는 값은 이미 만들어진 기본 인스턴스입니다. 테스트마다 격리된 인스턴스가 필요하면Quad.NewModule()를 쓰세요.
테스트할 코드가 q:AssertProvider(...)를 부른다면(컴포넌트 킷이 그렇습니다) 프로바이더가 q.Backend.provider에 이름과 버전을 적어야 합니다 — q:AssertProvider와 q.Backend.provider를 보세요. "quad-roblox"를 확인하는 킷을 테스트할 때는 그 이름을 빌려 쓴 가짜 프로바이더 대신 실제 quad-roblox 프로바이더를 쓰세요.
생명주기에 묶이기 전까지 Observer는 발화하지 않는다
섹션 제목: “생명주기에 묶이기 전까지 Observer는 발화하지 않는다”프로바이더가 있는 채로도 헤드리스 테스트에서 가장 자주 걸리는 함정입니다.
local hp = q.Source(100)local runs = 0local observer = hp:Observer(function() runs += 1end)assert(runs == 1, "등록 즉시 1회 발화한다")
hp:Set(90)assert(runs == 1, "아직 아무 인스턴스에도 안 묶였다 — 변경은 보류된다")
q.Backend.bindLifetime(host, observer) -- host는 이 백엔드가 소유한 인스턴스assert(runs == 2, "묶는 순간 보류분이 한 번 재생된다")
hp:Set(80)assert(runs == 3, "이후 변경은 그대로 발화")
q.dispose(host) -- quad가 관리하는 값을 버리는 정본 경로(밖에서 :Destroy()해도 결과는 같다)hp:Set(70)assert(runs == 3, "인스턴스가 죽으면 더 이상 발화하지 않는다")실제 컴포넌트 코드에서는 bindLifetime을 직접 부르지 않습니다 —
Observer/Effect를 props의 숫자 키 자리에 넣으면 quad가 그 인스턴스 수명에
묶어 줍니다.
트리 조립 검증
섹션 제목: “트리 조립 검증”백엔드가 설치된 인스턴스라면 Slot의 재조정을 물리 자식 수로 직접 검증할 수
있습니다.
local data = q.Source({ { id = "a" }, { id = "b" } })-- makeElement()는 이 백엔드가 만드는 요소, parent는 이 백엔드가 소유한 부모 인스턴스local slot = q.Slot<<Instance>>():List(data, function(ctx: any): (any, any) if ctx.Item == q.KeyGone then return nil, ctx.UserData end return ctx.Prev or makeElement(), ctx.UserDataend, function(item) return item.idend)
q.Dispatch.drive(parent, { slot })assert(#parent:GetChildren() == 2, "두 요소가 붙었다")
data:Set({ { id = "b" } })assert(#parent:GetChildren() == 1, "사라진 키의 요소는 파괴된다")q.Dispatch.drive는 이 저장소의 spec이 쓰는 디스패치 진입점입니다 — 컴포넌트
코드에서 직접 부를 일은 없고, 안정된 테스트 API로 약속된 표면도 아닙니다.
Roblox 프로퍼티/이벤트까지 태우려면 quad-roblox를 설치한 채 반사(reflection)
조회와 Instance.new를 테스트용으로 갈아끼워야 합니다 — 이 저장소의
quad-roblox/test/spec.*.luau가 그 관용구를 보여줍니다.
4. 이 저장소 자체를 테스트하려면
섹션 제목: “4. 이 저장소 자체를 테스트하려면”이 저장소 자신의 ./scripts/test.sh(relink, 타입 검사 그룹, spec 관용구)는
사용자가 아니라 기여자를 위한 절차입니다 — CONTRIBUTING.md를
보세요.
이해 점검
섹션 제목: “이해 점검”`Blocker`가 막는 것
raw:Apply(gate)로 gated를 만들고 gate:On()한 뒤 raw:Set(1), raw:Set(2)를 차례로 부르면 어떻게 되나요?
-
gated:Get()은 이미2이고, 막힌 것은 아래로의 전파뿐입니다 —gate:Off()에서 밀린 전파가 정확히 한 번 나갑니다 -
gated:Get()은gate:Off()전까지0에 머물고, 열면1과2가 차례로 전파됩니다 -
q.Blocker()로 만들어:On()하기만 하면raw에서 파생된 모든 노드가 막힙니다
Blocker는 state:Apply(blocker)로 붙인 노드만 게이트하고, 값은 항상 최신입니다. 밀린 전파를 내보내지 않고 버리며 열려면 gate:OffWithoutEmit()을 씁니다.
생명주기에 묶이기 전의 Observer
프로바이더가 설치돼 있지만 아직 아무 인스턴스에도 묶이지 않은 Observer는 어떻게 동작하나요?
- 등록 즉시 1회 발화하고, 묶이기 전의 변경은 보류됐다가 묶는 순간 한 번 재생됩니다
- 묶기 전에는 한 번도 발화하지 않다가, 묶는 순간부터 새 변경만 받습니다
- 묶인 인스턴스가 파괴된 뒤에도 변경을 계속 받습니다
헤드리스 테스트에서 가장 자주 걸리는 자리입니다. 인스턴스가 죽으면 그 뒤의 변경에는 더 이상 발화하지 않고, 실제 컴포넌트 코드에서는 Observer를 props의 숫자 키 자리에 넣으면 quad가 대신 묶어 줍니다. (프로바이더가 아예 없으면 이야기가 다릅니다 — §2 끝을 보세요.)
Quiz Progress
0 / 0 questions answered (0%)
0 correct
Your score
0%
0 / 0 correct
Answered: 0 / 0 (0%)