콘텐츠로 이동

06. Roblox Studio 없이 헤드리스로 테스트하기

대상 독자: 반응형 로직을 Studio를 켜지 않고 검증하고 싶은 개발자 다루는 개념: quad-base의 엔진 무관성, Source/Compute/Observer/Store/Blocker의 등록 의미론, 프로바이더 주입, Slot 조립 범위: 반응형 그래프(§2)는 여러분의 프로젝트에 이미 깔려 있는 quad_base만으로 평범한 luau CLI에서 그대로 됩니다. 백엔드가 있어야 하는 부분(§3 — 프로바이더 주입, Observer/Effect/Slot의 생명주기 결합, 트리 조립)은 갖다 쓸 수 있는 공개 mock 패키지가 아직 없어서 자기 프로바이더를 직접 붙이거나 이 저장소의 spec을 본보기로 삼아야 합니다. 실제 Roblox 프로퍼티/이벤트/Tween은 Roblox Studio에서 직접 확인해야 합니다.


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 프로젝트입니다(게임 본체 프로젝트와 분리해도 되고, 같은 워크스페이스 안에 둬도 됩니다) — 실제로 레지스트리에서 설치해 확인한 구성입니다.

pesde.toml
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 install

pesde 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 == 0
end)
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 = 0
local observer = hp:Observer(function()
runs += 1
end)
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.UserData
end, function(item)
return item.id
end)
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 끝을 보세요.)

퀴즈 진행 상황

0 / 0 답변한 문제 (0%)

0 정답