quad/quad-base/test/mock.luau
qwreey-agent-selene 205af32da4
tooling: 프로젝트 셋업 문서화 + wally→pesde 전환, M1 스캐폴딩 골격
pesde 워크스페이스 실제 설치·검증(패키지명 하이픈 금지, workspace 의존성
문법, per-package pesde.lock), init.luau의 @self require 규칙(Luau RFC
확인), 워크스페이스 의존성이 심볼릭 링크라 luau CLI의 require-by-string과
충돌하는 함정을 base/project-setup-plan.md로 정리. architecture.md
패키징 방식 절도 같이 정정. Relate.luau/New()-InitXxx 골격/mock 하네스는
이 구조를 실제로 검증하는 과정에서 나온 최소 스캐폴딩.

Co-authored-by: qwreey <me@qwreey.moe>
2026-08-19 14:01:07 +09:00

227 lines
5.6 KiB
Text

--[[
quad-base용 최소 mock — Vide `test/mock.luau` 선례를 따르되 범위를 더
좁힘(`.claude/base/architecture.md` "테스트 전략" 절): parent/children
트리 + 타입 검증 없는 property bag + property별 변경 시그널만. `IsA()`/
클래스별 프로퍼티 스키마/`WaitForChild`/`DataModel`은 만들지 않는다 —
quad-base 코어는 `inst`를 `any`로 취급하고 Instance 특정 동작을 참조하지
않으므로 mock이 실제 Roblox 충실도를 가질 이유가 없다(같은 절).
Vide의 mock과 달리 GC-독립 userdata 프록시 트릭을 안 씀 — 그건 Roblox
엔진 userdata의 GC 동일성을 흉내내려는 것이고, 그 동일성 문제는
quad-roblox의 LifetimeHandle(gcconn 트릭)이 다루는 자리라 quad-base
정적 스냅샷 테스트 범위 밖.
]]
export type Signal = {
Connect: (self: Signal, fn: (...any) -> ()) -> Connection,
Fire: (self: Signal, ...any) -> (),
}
export type Connection = {
Connected: boolean,
Disconnect: (self: Connection) -> (),
}
local Signal = {}
Signal.__index = Signal
local function newSignal(): Signal
return (setmetatable({ connections = {} }, Signal) :: any) :: Signal
end
function Signal:Connect(fn: (...any) -> ()): Connection
local connections = (self :: any).connections
local conn = { Connected = true, fn = fn }
function conn.Disconnect(self2)
if not self2.Connected then
return
end
self2.Connected = false
local i = table.find(connections, conn)
if i then
table.remove(connections, i)
end
end
table.insert(connections, conn)
return (conn :: any) :: Connection
end
function Signal:Fire(...: any)
-- 발화 도중 연결이 끊기는 경우를 대비해 스냅샷을 뜬 뒤 순회(Vide 선례)
local snapshot = table.clone((self :: any).connections)
for i = #snapshot, 1, -1 do
local conn = snapshot[i]
if conn.Connected then
conn.fn(...)
end
end
end
export type MockInstance = {
ClassName: string,
Name: string,
Parent: MockInstance?,
Destroying: Signal,
GetPropertyChangedSignal: (self: MockInstance, property: string) -> Signal,
GetChildren: (self: MockInstance) -> { MockInstance },
FindFirstChild: (self: MockInstance, name: string) -> MockInstance?,
Destroy: (self: MockInstance) -> (),
[string]: any, -- 타입 검증 없는 property bag
}
type Data = {
className: string,
name: string,
parent: Data?,
children: { Data },
changed: { [any]: Signal },
properties: { [any]: any },
destroying: Signal,
}
local dataOf = setmetatable({}, { __mode = "k" }) :: { [any]: Data }
local proxyOf = setmetatable({}, { __mode = "v" }) :: { [Data]: any }
local methods = {}
local function fireChanged(data: Data, key: any)
local sig = data.changed[key]
if sig then
sig:Fire()
end
end
local function getData(inst: any): Data
local data = dataOf[inst]
if data == nil then
error("not a mock instance", 2)
end
return data
end
local function getProxy(data: Data): any
local existing = proxyOf[data]
if existing then
return existing
end
local proxy = setmetatable({}, {
__index = function(_, key)
if methods[key] then
return methods[key]
elseif key == "Name" then
return data.name
elseif key == "ClassName" then
return data.className
elseif key == "Parent" then
if data.parent == nil then
return nil
end
return getProxy(data.parent)
elseif key == "Destroying" then
return data.destroying
else
return data.properties[key]
end
end,
__newindex = function(_, key, value)
if key == "Name" then
if type(value) ~= "string" then
error("Name must be a string", 2)
end
data.name = value
elseif key == "Parent" then
assert(value == nil or dataOf[value] ~= nil, "attempt to set non-instance as Parent")
if data.parent then
local siblings = data.parent.children
local i = table.find(siblings, data)
if i then
table.remove(siblings, i)
end
end
if value == nil then
data.parent = nil
else
local newParentData = getData(value)
data.parent = newParentData
table.insert(newParentData.children, data)
end
else
data.properties[key] = value
end
fireChanged(data, key)
end,
})
dataOf[proxy] = data
proxyOf[data] = proxy
return proxy
end
local Instance = {}
function Instance.new(className: string): MockInstance
local data: Data = {
className = className,
name = className,
parent = nil,
children = {},
changed = {},
properties = {},
destroying = newSignal(),
}
return (getProxy(data) :: any) :: MockInstance
end
function methods.GetPropertyChangedSignal(inst: any, property: string): Signal
local data = getData(inst)
local sig = data.changed[property]
if sig == nil then
sig = newSignal()
data.changed[property] = sig
end
return sig
end
function methods.GetChildren(inst: any): { any }
local data = getData(inst)
local out = table.create(#data.children)
for i, childData in data.children do
out[i] = getProxy(childData)
end
return out
end
function methods.FindFirstChild(inst: any, name: string): any?
local data = getData(inst)
for _, childData in data.children do
if childData.name == name then
return getProxy(childData)
end
end
return nil
end
function methods.Destroy(inst: any)
local data = getData(inst)
data.destroying:Fire()
if data.parent then
local siblings = data.parent.children
local i = table.find(siblings, data)
if i then
table.remove(siblings, i)
end
data.parent = nil
fireChanged(data, "Parent")
end
end
local function isMockInstance(value: any): boolean
return dataOf[value] ~= nil
end
return {
Instance = Instance,
newSignal = newSignal,
isMockInstance = isMockInstance,
}