@things-factory/figure-service 10.1.63 → 10.1.65
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-server/service/figure/figure-mutation.d.ts +4 -8
- package/dist-server/service/figure/figure-mutation.js +13 -27
- package/dist-server/service/figure/figure-mutation.js.map +1 -1
- package/dist-server/service/figure/figure-propose-type.d.ts +8 -59
- package/dist-server/service/figure/figure-propose-type.js +15 -150
- package/dist-server/service/figure/figure-propose-type.js.map +1 -1
- package/dist-server/service/figure/figure-propose-v3.d.ts +50 -0
- package/dist-server/service/figure/figure-propose-v3.js +141 -0
- package/dist-server/service/figure/figure-propose-v3.js.map +1 -0
- package/dist-server/service/figure/figure-tools.js +64 -90
- package/dist-server/service/figure/figure-tools.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/server/service/figure/figure-llm-smoke.test.ts +28 -32
- package/server/service/figure/figure-mutation.ts +14 -28
- package/server/service/figure/figure-propose-type.ts +16 -124
- package/server/service/figure/figure-propose-v3.test.ts +113 -0
- package/server/service/figure/figure-propose-v3.ts +172 -0
- package/server/service/figure/figure-tools.test.ts +86 -133
- package/server/service/figure/figure-tools.ts +60 -91
- package/dist-server/service/figure/figure-propose.d.ts +0 -91
- package/dist-server/service/figure/figure-propose.js +0 -439
- package/dist-server/service/figure/figure-propose.js.map +0 -1
- package/dist-server/service/figure/figure-quality.d.ts +0 -29
- package/dist-server/service/figure/figure-quality.js +0 -62
- package/dist-server/service/figure/figure-quality.js.map +0 -1
- package/server/service/figure/figure-ai-flow.test.ts +0 -93
- package/server/service/figure/figure-e2e-smoke.test.ts +0 -72
- package/server/service/figure/figure-propose.test.ts +0 -719
- package/server/service/figure/figure-propose.ts +0 -551
- package/server/service/figure/figure-quality.test.ts +0 -43
- package/server/service/figure/figure-quality.ts +0 -98
|
@@ -1,719 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* figure-propose — AI 가 낸 것을 무엇으로 거르나.
|
|
3
|
-
*
|
|
4
|
-
* 이 파일이 지키는 것은 하나다. **형식을 어긴 후보는 사람에게 가지 않는다.**
|
|
5
|
-
*
|
|
6
|
-
* AI 생성이 3D 에서 대개 잘 안 되는 이유는 「틀렸는지 기계가 판정할 수 없어서」인데,
|
|
7
|
-
* 여기는 판정기가 있다. 그 판정기를 실제로 통과시키는지, 틀렸을 때 사유를 모델에
|
|
8
|
-
* 돌려주고 다시 시키는지, 끝내 못 만들면 **못 만들었다고 말하는지**를 본다.
|
|
9
|
-
*
|
|
10
|
-
* 모델은 부르지 않는다. 부르면 시험이 느리고 답이 매번 다르다 — 여기서 볼 것은
|
|
11
|
-
* 모델의 솜씨가 아니라 **우리 거름망**이다.
|
|
12
|
-
*/
|
|
13
|
-
import {
|
|
14
|
-
ANCHOR_RULES,
|
|
15
|
-
DETAIL_LEVELS,
|
|
16
|
-
CAPABILITY_NEEDS,
|
|
17
|
-
CHANNEL_PATHS,
|
|
18
|
-
FIGURE_CAPABILITIES,
|
|
19
|
-
FIGURE_PLACEMENTS,
|
|
20
|
-
INTERPOLATIONS,
|
|
21
|
-
LABEL_WHENS,
|
|
22
|
-
LIMITS,
|
|
23
|
-
MATERIAL_SLOTS,
|
|
24
|
-
PART_LIMIT,
|
|
25
|
-
PART_ROLES,
|
|
26
|
-
PRIMITIVE_KINDS,
|
|
27
|
-
REPEAT_LIMIT,
|
|
28
|
-
SEGMENT_PRESETS,
|
|
29
|
-
SIZING_RULES
|
|
30
|
-
} from '@hatiolab/figure-model'
|
|
31
|
-
import { setDefaultAIClient } from '@things-factory/ai-client-base'
|
|
32
|
-
import type { AIClient } from '@things-factory/ai-client-base'
|
|
33
|
-
|
|
34
|
-
import { readFileSync } from 'fs'
|
|
35
|
-
|
|
36
|
-
import * as model from '@hatiolab/figure-model'
|
|
37
|
-
|
|
38
|
-
import { ProposeFailure, inspect, proposeFigure, rulesPrompt } from './figure-propose'
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* 형식이 선언한 칸 이름을 **형식 소스에서** 읽는다.
|
|
43
|
-
*
|
|
44
|
-
* 손으로 옮겨 적으면 형식이 자라도 이 파일은 그대로 있고, 그러면 이 시험이 지키려는 것을
|
|
45
|
-
* 스스로 어긴다. 타입은 런타임에 안 남으므로 선언을 읽는다.
|
|
46
|
-
*/
|
|
47
|
-
function declaredFields(name: string): string[] {
|
|
48
|
-
const path = require.resolve('@hatiolab/figure-model/package.json')
|
|
49
|
-
const root = path.replace(/package\.json$/, '')
|
|
50
|
-
const source = readFileSync(`${root}dist/types.d.ts`, 'utf8')
|
|
51
|
-
const body = source.match(new RegExp(`interface ${name} \\{([\\s\\S]*?)\\n\\}`))
|
|
52
|
-
if (!body) throw new Error(`형식에서 ${name} 을 못 찾았다 — 이름이 바뀌었으면 시험도 따라가야 한다`)
|
|
53
|
-
return [...body[1].matchAll(/^\s{4}([a-zA-Z]+)\??:/gm)].map(m => m[1]!)
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const PALETTE = ['palette.primary', 'palette.accent', 'palette.neutral']
|
|
57
|
-
|
|
58
|
-
/** 온전한 후보. 시험할 곳만 덮어쓴다. */
|
|
59
|
-
function candidate(over: Record<string, unknown> = {}) {
|
|
60
|
-
return {
|
|
61
|
-
version: 2,
|
|
62
|
-
type: 'MIXER',
|
|
63
|
-
base: { x: 300, y: 500, z: 300 },
|
|
64
|
-
detailLevel: 'M',
|
|
65
|
-
parts: [
|
|
66
|
-
{
|
|
67
|
-
name: 'body',
|
|
68
|
-
primitive: 'cylinder',
|
|
69
|
-
segments: 12,
|
|
70
|
-
transform: { position: { x: 0, y: 250, z: 0 }, size: { x: 300, y: 400, z: 300 } },
|
|
71
|
-
material: { token: 'palette.primary' }
|
|
72
|
-
}
|
|
73
|
-
],
|
|
74
|
-
...over
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* 정해 둔 답을 차례로 내놓는 가짜 모델.
|
|
80
|
-
*
|
|
81
|
-
* **받은 말을 적어 둔다.** 프롬프트에 실려 나가는 지시 중에 지켜져야 할 것이 있어서다 —
|
|
82
|
-
* 고치는 요청에서 「부품 이름을 바꾸지 말라」가 빠지면 AI 가 이름을 갈아 버리고, 그러면
|
|
83
|
-
* 이미 배치된 도면의 바인딩이 끊긴다. 답만 보는 모형으로는 그것이 나갔는지 알 수 없다.
|
|
84
|
-
*/
|
|
85
|
-
function stubClient(answers: unknown[]): AIClient & { calls: number; sent: any[][]; options: any[]; system?: string } {
|
|
86
|
-
const client = {
|
|
87
|
-
id: 'stub',
|
|
88
|
-
calls: 0,
|
|
89
|
-
sent: [] as any[][],
|
|
90
|
-
options: [] as any[],
|
|
91
|
-
system: undefined as string | undefined,
|
|
92
|
-
async chat() {
|
|
93
|
-
return ''
|
|
94
|
-
},
|
|
95
|
-
async generateJSON(messages: any[], options?: { systemPrompt?: string; maxTokens?: number }) {
|
|
96
|
-
client.sent.push(JSON.parse(JSON.stringify(messages)))
|
|
97
|
-
client.options.push(options)
|
|
98
|
-
client.system = options?.systemPrompt
|
|
99
|
-
const answer = answers[client.calls]
|
|
100
|
-
client.calls++
|
|
101
|
-
if (answer instanceof Error) throw answer
|
|
102
|
-
return answer
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
return client as unknown as AIClient & { calls: number; sent: any[][]; options: any[]; system?: string }
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/** 첫 요청에 실려 나간 사람 쪽 말. 이어 붙인 것까지 한 덩어리로 본다. */
|
|
109
|
-
function firstAsk(client: { sent: any[][] }): string {
|
|
110
|
-
const content = client.sent[0]![0]!.content
|
|
111
|
-
return typeof content === 'string' ? content : JSON.stringify(content)
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
afterEach(() => setDefaultAIClient(undefined))
|
|
115
|
-
|
|
116
|
-
describe('규칙을 프롬프트로 옮길 때', () => {
|
|
117
|
-
it('값을 손으로 적지 않고 figure-model 에서 가져온다', () => {
|
|
118
|
-
const prompt = rulesPrompt(PALETTE)
|
|
119
|
-
|
|
120
|
-
for (const kind of PRIMITIVE_KINDS) {
|
|
121
|
-
expect(prompt).toContain(kind)
|
|
122
|
-
}
|
|
123
|
-
for (const preset of SEGMENT_PRESETS) {
|
|
124
|
-
expect(prompt).toContain(String(preset))
|
|
125
|
-
}
|
|
126
|
-
expect(prompt).toContain(`<= ${LIMITS.materialGroups}`)
|
|
127
|
-
expect(prompt).toContain(`M=${PART_LIMIT.M}`)
|
|
128
|
-
})
|
|
129
|
-
|
|
130
|
-
it('쓸 수 있는 팔레트 토큰만 알려 준다', () => {
|
|
131
|
-
const prompt = rulesPrompt(PALETTE)
|
|
132
|
-
|
|
133
|
-
expect(prompt).toContain('palette.primary | palette.accent | palette.neutral')
|
|
134
|
-
})
|
|
135
|
-
})
|
|
136
|
-
|
|
137
|
-
/*
|
|
138
|
-
* ── Every authorable feature has to reach the model ──────────────────────────
|
|
139
|
-
*
|
|
140
|
-
* The prompt is the only place the model learns what a figure may contain. Until 2026-09-16 it
|
|
141
|
-
* taught `{ type, base, placement, detailLevel, parts }` and one stray line naming the animation
|
|
142
|
-
* paths, so anchors, repeats, capabilities, labels, lamps, hollows and movement were unreachable:
|
|
143
|
-
* the model was told there is a limit of two animated parts without ever being told where an
|
|
144
|
-
* animation goes. What came back was coloured boxes.
|
|
145
|
-
*
|
|
146
|
-
* These check per feature rather than per word, because a vocabulary list the model cannot place
|
|
147
|
-
* in the JSON is worse than no mention at all — it produces confident, invalid output.
|
|
148
|
-
*/
|
|
149
|
-
/*
|
|
150
|
-
* ── The format is the only vocabulary, and the prompt must follow it ─────────
|
|
151
|
-
*
|
|
152
|
-
* Checking feature by feature does not hold: a list of features has to be extended by hand every
|
|
153
|
-
* time the format grows, which is the failure it is supposed to prevent. Until 2026-09-16 the
|
|
154
|
-
* prompt taught five fields out of thirteen and a single stray line naming the animation paths,
|
|
155
|
-
* and nothing failed — because nothing compared the prompt against the format.
|
|
156
|
-
*
|
|
157
|
-
* So these walk `@hatiolab/figure-model` itself. Every enum it exports is either something an
|
|
158
|
-
* author writes into a FigureSource, in which case the model has to be taught all of its values,
|
|
159
|
-
* or it is not, in which case it is named below with the reason. Adding an enum to the format
|
|
160
|
-
* forces that decision instead of letting it be forgotten.
|
|
161
|
-
*/
|
|
162
|
-
describe('프롬프트는 형식을 따라간다 — 손으로 적은 목록이 아니라', () => {
|
|
163
|
-
/*
|
|
164
|
-
저작자가 FigureSource 에 적는 값이 아닌 열거. 여기 없는 새 열거가 형식에 생기면 아래 시험이
|
|
165
|
-
깨지고, 가르칠지 말지를 그때 정하게 된다.
|
|
166
|
-
*/
|
|
167
|
-
const NOT_AUTHORED: Record<string, string> = {
|
|
168
|
-
ERROR_CODES: '검증 결과다 — 저작자가 적는 값이 아니다',
|
|
169
|
-
VIOLATION_CODES: '같다',
|
|
170
|
-
RELEASE_BLOCKING_VIOLATIONS: '위반 코드의 부분집합이다 — 어느 것이 발행을 막나는 관문이 알 일이지 저작자가 적는 값이 아니다',
|
|
171
|
-
AXES: "'x'·'y'·'z' 는 축 이름이고 값이 아니다 — 쓰는 자리에서 anchor.<axis> 로 가르친다",
|
|
172
|
-
SURFACE_KINDS: 'material.surface 는 things-scene 에 소비처가 0건이다 — 그릴 수 없는 것을 시키지 않는다',
|
|
173
|
-
/*
|
|
174
|
-
V3 커널의 연산자 이름이다. 제안기는 아직 V2 FigureSource 를 쓰므로 저작자가 적는 값이 아니다.
|
|
175
|
-
제안기가 V3 자산을 쓰게 되는 날 이 세 줄을 지우고 프롬프트가 연산자를 가르쳐야 한다(ADR-0087).
|
|
176
|
-
*/
|
|
177
|
-
V3_OPERATORS: 'V3 커널 연산자 이름이다 — 제안기가 V2 정본을 쓰는 동안은 저작자가 적는 값이 아니다',
|
|
178
|
-
V3_NON_AFFINE_OPERATORS: '위 목록의 부분집합이다 — 어느 연산이 다중선형을 안 나르나는 관문이 알 일이다',
|
|
179
|
-
V3_STATE_NON_AFFINE_OPERATORS: '같다',
|
|
180
|
-
V3_BUDGET_KEYS: 'V3 등급이 재는 예산의 이름이다 — 채점 결과이지 저작자가 적는 값이 아니다'
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
it('저작에 쓰이는 열거는 값 하나도 빠짐없이 프롬프트에 있다', () => {
|
|
184
|
-
const prompt = rulesPrompt(PALETTE)
|
|
185
|
-
const missing: string[] = []
|
|
186
|
-
|
|
187
|
-
for (const [name, value] of Object.entries(model as Record<string, unknown>)) {
|
|
188
|
-
if (!Array.isArray(value) || value.some(v => typeof v !== 'string')) continue
|
|
189
|
-
if (name in NOT_AUTHORED) continue
|
|
190
|
-
for (const member of value as string[]) {
|
|
191
|
-
if (!prompt.includes(member)) missing.push(`${name}.${member}`)
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
expect(missing).toEqual([])
|
|
196
|
-
})
|
|
197
|
-
|
|
198
|
-
it('저작에 안 쓰이는 열거는 이유가 적혀 있다 — 조용히 빠지지 않는다', () => {
|
|
199
|
-
const enums = Object.entries(model as Record<string, unknown>)
|
|
200
|
-
.filter(([, v]) => Array.isArray(v) && v.length > 0 && v.every(x => typeof x === 'string'))
|
|
201
|
-
.map(([k]) => k)
|
|
202
|
-
|
|
203
|
-
/* 형식에 열거가 늘면 가르치거나 NOT_AUTHORED 에 이유와 함께 적어야 한다. */
|
|
204
|
-
const prompt = rulesPrompt(PALETTE)
|
|
205
|
-
const unclassified = enums.filter(
|
|
206
|
-
name => !(name in NOT_AUTHORED) && !(model as any)[name].every((m: string) => prompt.includes(m))
|
|
207
|
-
)
|
|
208
|
-
|
|
209
|
-
expect(unclassified).toEqual([])
|
|
210
|
-
})
|
|
211
|
-
|
|
212
|
-
it('한도는 형식의 값을 그대로 인용한다 — 프롬프트에 숫자를 적어 두지 않는다', () => {
|
|
213
|
-
const prompt = rulesPrompt(PALETTE)
|
|
214
|
-
|
|
215
|
-
for (const [key, limit] of Object.entries(LIMITS)) {
|
|
216
|
-
if (typeof limit !== 'number') continue
|
|
217
|
-
/* 한도를 안 가르치는 것은 괜찮다. 다른 수를 가르치는 것은 안 된다. */
|
|
218
|
-
const wrong = new RegExp(`${key}[^\\n]*<=\\s*(?!${limit}\\b)\\d+`, 'i')
|
|
219
|
-
expect(prompt).not.toMatch(wrong)
|
|
220
|
-
}
|
|
221
|
-
expect(prompt).toContain(String(REPEAT_LIMIT))
|
|
222
|
-
for (const level of DETAIL_LEVELS) expect(prompt).toContain(`${level}=${PART_LIMIT[level]}`)
|
|
223
|
-
})
|
|
224
|
-
|
|
225
|
-
/*
|
|
226
|
-
가르치지 않는 칸. 열거와 같은 규율이다 — 여기 없는 새 칸이 형식에 생기면 아래가 깨지고,
|
|
227
|
-
그때 가르칠지 말지를 정하게 된다. 「아직 안 가르쳤다」가 조용히 남지 않는다.
|
|
228
|
-
*/
|
|
229
|
-
const NOT_TAUGHT: Record<string, Record<string, string>> = {
|
|
230
|
-
FigureSource: {
|
|
231
|
-
type: '요청에서 온다 — 모델이 지어내면 거절한다',
|
|
232
|
-
parts: '모양 첫 줄에 이미 있다',
|
|
233
|
-
styleKit: '읽는 쪽이 0건이다 — 그릴 수 없는 것을 시키면 무동작 설정이 된다'
|
|
234
|
-
},
|
|
235
|
-
FigurePart: {
|
|
236
|
-
name: '모양 첫 줄에 있다',
|
|
237
|
-
primitive: '같다',
|
|
238
|
-
transform: '같다',
|
|
239
|
-
material: '같다'
|
|
240
|
-
},
|
|
241
|
-
PartMaterial: {
|
|
242
|
-
token: '모양 첫 줄에 있다',
|
|
243
|
-
preset: '같다',
|
|
244
|
-
surface: 'things-scene 에 소비처가 0건이다 — 그릴 수 없는 것을 시키지 않는다'
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
for (const iface of ['FigureSource', 'FigurePart', 'PartMaterial']) {
|
|
249
|
-
it(`${iface} 의 칸은 가르치거나 이유를 적었거나 둘 중 하나다`, () => {
|
|
250
|
-
const prompt = rulesPrompt(PALETTE)
|
|
251
|
-
const silent = declaredFields(iface).filter(
|
|
252
|
-
field => !(field in NOT_TAUGHT[iface]!) && !prompt.includes(field)
|
|
253
|
-
)
|
|
254
|
-
|
|
255
|
-
expect(silent).toEqual([])
|
|
256
|
-
})
|
|
257
|
-
|
|
258
|
-
it(`${iface} 의 제외 목록에 죽은 이름이 없다`, () => {
|
|
259
|
-
/* 형식에서 없어진 칸을 제외 목록이 계속 들고 있으면, 그 목록이 형식을 안 따라간 것이다. */
|
|
260
|
-
const declared = new Set(declaredFields(iface))
|
|
261
|
-
const stale = Object.keys(NOT_TAUGHT[iface]!).filter(field => !declared.has(field))
|
|
262
|
-
|
|
263
|
-
expect(stale).toEqual([])
|
|
264
|
-
})
|
|
265
|
-
}
|
|
266
|
-
})
|
|
267
|
-
|
|
268
|
-
describe('joints (ADR-0066)', () => {
|
|
269
|
-
it('teaches the joint shape with the type vocabulary, and a joint channel with no path and a number per key', () => {
|
|
270
|
-
const prompt = rulesPrompt(PALETTE)
|
|
271
|
-
expect(prompt).toContain(`type: ${(model as any).JOINT_TYPES.join(' | ')}`)
|
|
272
|
-
expect(prompt).toMatch(/NO path, NO pivot/)
|
|
273
|
-
expect(prompt).toMatch(/limits must include 0/)
|
|
274
|
-
expect(prompt).toMatch(/ONLY place a parent is written/)
|
|
275
|
-
})
|
|
276
|
-
})
|
|
277
|
-
|
|
278
|
-
describe('고치라는 요청일 때', () => {
|
|
279
|
-
it('편집 후보에는 JSON 완결에 충분한 출력 한도를 준다', async () => {
|
|
280
|
-
const client = stubClient([candidate()])
|
|
281
|
-
setDefaultAIClient(client as any)
|
|
282
|
-
|
|
283
|
-
await proposeFigure({ prompt: '색을 바꿔 줘', base: candidate() as any, palette: PALETTE })
|
|
284
|
-
|
|
285
|
-
expect((client as any).options?.[0]?.maxTokens).toBe(16384)
|
|
286
|
-
})
|
|
287
|
-
|
|
288
|
-
it('새 Figure 후보에도 같은 출력 한도를 준다', async () => {
|
|
289
|
-
const client = stubClient([candidate()])
|
|
290
|
-
setDefaultAIClient(client as any)
|
|
291
|
-
|
|
292
|
-
await proposeFigure({ prompt: '교반기', type: 'MIXER', palette: PALETTE })
|
|
293
|
-
|
|
294
|
-
expect((client as any).options?.[0]?.maxTokens).toBe(16384)
|
|
295
|
-
})
|
|
296
|
-
|
|
297
|
-
it('요청이 안 건드린 것을 그대로 실으라고 못 박는다', async () => {
|
|
298
|
-
/*
|
|
299
|
-
이 문장이 없으면 애니메이션이 있는 도형을 「색만 바꿔 줘」로 고쳤을 때 모델이 clip 을
|
|
300
|
-
안 실어 보내고, 그것이 오류 없이 통과해 조용히 사라진다.
|
|
301
|
-
*/
|
|
302
|
-
const client = stubClient([candidate()])
|
|
303
|
-
setDefaultAIClient(client as any)
|
|
304
|
-
await proposeFigure({ prompt: '색을 바꿔 줘', base: candidate() as any, palette: PALETTE })
|
|
305
|
-
|
|
306
|
-
const sent = firstAsk(client)
|
|
307
|
-
expect(sent).toMatch(/Carry over everything the request did not ask you to change/)
|
|
308
|
-
expect(sent).toMatch(/dropping one is silent/)
|
|
309
|
-
|
|
310
|
-
/* 잃기 쉬운 칸은 이름으로 짚어 준다 — 「전부 지켜라」만으로는 모델이 빠뜨린다. */
|
|
311
|
-
for (const field of ['animations', 'parameters', 'joints', 'capabilities', 'parent', 'anchor', 'repeat', 'label', 'materialSlot']) {
|
|
312
|
-
expect(sent).toContain(field)
|
|
313
|
-
}
|
|
314
|
-
})
|
|
315
|
-
})
|
|
316
|
-
|
|
317
|
-
describe('후보를 받아 볼 때', () => {
|
|
318
|
-
it('온전하면 통과시킨다', () => {
|
|
319
|
-
const looked = inspect(candidate(), PALETTE)
|
|
320
|
-
|
|
321
|
-
expect(looked.source).toBeDefined()
|
|
322
|
-
expect(looked.reasons).toEqual([])
|
|
323
|
-
})
|
|
324
|
-
|
|
325
|
-
it('형식을 어기면 어디가 왜 틀렸는지 말한다', () => {
|
|
326
|
-
const looked = inspect(candidate({ parts: [] }), PALETTE)
|
|
327
|
-
|
|
328
|
-
expect(looked.source).toBeUndefined()
|
|
329
|
-
expect(looked.reasons.length).toBeGreaterThan(0)
|
|
330
|
-
expect(looked.reasons[0]).toContain('parts')
|
|
331
|
-
})
|
|
332
|
-
|
|
333
|
-
it('JSON 이 아니면 통과시키지 않는다', () => {
|
|
334
|
-
const looked = inspect('그럼요, 만들어 드릴게요', PALETTE)
|
|
335
|
-
|
|
336
|
-
expect(looked.source).toBeUndefined()
|
|
337
|
-
expect(looked.reasons.length).toBeGreaterThan(0)
|
|
338
|
-
})
|
|
339
|
-
|
|
340
|
-
/*
|
|
341
|
-
토큰은 형식상 자유 문자열이라 validate 를 지난다. 막지 않으면 저장까지 되고
|
|
342
|
-
화면에서 resolveToken 이 던진다 — 가장 늦게 드러나는 자리다.
|
|
343
|
-
*/
|
|
344
|
-
it('팔레트에 없는 색을 지어내면 막는다', () => {
|
|
345
|
-
const bad = candidate()
|
|
346
|
-
bad.parts[0].material = { token: 'palette.brand' }
|
|
347
|
-
|
|
348
|
-
const looked = inspect(bad, PALETTE)
|
|
349
|
-
|
|
350
|
-
expect(looked.source).toBeUndefined()
|
|
351
|
-
expect(looked.reasons[0]).toContain('palette.brand')
|
|
352
|
-
expect(looked.reasons[0]).toContain('palette.primary')
|
|
353
|
-
})
|
|
354
|
-
|
|
355
|
-
it('요청한 저장 식별자와 다른 type을 지어내면 막는다', () => {
|
|
356
|
-
const looked = inspect(candidate({ type: 'FORKLIFT' }), PALETTE, 'MIXER')
|
|
357
|
-
|
|
358
|
-
expect(looked.source).toBeUndefined()
|
|
359
|
-
expect(looked.reasons).toEqual([expect.stringContaining('"MIXER"')])
|
|
360
|
-
expect(looked.reasons[0]).toContain('"FORKLIFT"')
|
|
361
|
-
})
|
|
362
|
-
})
|
|
363
|
-
|
|
364
|
-
describe('후보를 만들 때', () => {
|
|
365
|
-
it('한 번에 되면 점수와 무게를 함께 낸다', async () => {
|
|
366
|
-
setDefaultAIClient(stubClient([candidate()]))
|
|
367
|
-
|
|
368
|
-
const result = await proposeFigure({ prompt: '교반기', palette: PALETTE, type: 'MIXER' })
|
|
369
|
-
|
|
370
|
-
expect(result.attempts).toBe(1)
|
|
371
|
-
expect(result.source.parts).toHaveLength(1)
|
|
372
|
-
expect(result.score.grade).toMatch(/^[ABCD]$/)
|
|
373
|
-
expect(result.cost.triangles).toBeGreaterThan(0)
|
|
374
|
-
expect(result.quality.status).toMatch(/^(ready|review)$/)
|
|
375
|
-
})
|
|
376
|
-
|
|
377
|
-
it('요청하면 측정된 품질 근거를 다음 후보 개선에 돌려준다', async () => {
|
|
378
|
-
const generic = candidate({ parts: [
|
|
379
|
-
{ name: 'part-1', primitive: 'cube', transform: { position: { x: -100, y: 250, z: 0 }, size: { x: 100, y: 100, z: 100 } }, material: { token: 'palette.primary' } },
|
|
380
|
-
{ name: 'part-2', primitive: 'cube', transform: { position: { x: 0, y: 250, z: 0 }, size: { x: 100, y: 100, z: 100 } }, material: { token: 'palette.primary' } },
|
|
381
|
-
{ name: 'part-3', primitive: 'cube', transform: { position: { x: 100, y: 250, z: 0 }, size: { x: 100, y: 100, z: 100 } }, material: { token: 'palette.primary' } },
|
|
382
|
-
{ name: 'part-4', primitive: 'cube', transform: { position: { x: 150, y: 250, z: 0 }, size: { x: 50, y: 100, z: 100 } }, material: { token: 'palette.primary' } }
|
|
383
|
-
] })
|
|
384
|
-
const refined = candidate({ parts: [{
|
|
385
|
-
name: 'vessel', primitive: 'cylinder', segments: 12,
|
|
386
|
-
transform: { position: { x: 0, y: 250, z: 0 }, size: { x: 280, y: 400, z: 280 } }, material: { token: 'palette.primary' }
|
|
387
|
-
}] })
|
|
388
|
-
const client = stubClient([generic, refined])
|
|
389
|
-
setDefaultAIClient(client)
|
|
390
|
-
|
|
391
|
-
const result = await proposeFigure({ prompt: '교반기', palette: PALETTE, type: 'MIXER', refine: true })
|
|
392
|
-
|
|
393
|
-
expect(result.attempts).toBe(2)
|
|
394
|
-
expect(result.source.parts[0]!.name).toBe('vessel')
|
|
395
|
-
expect(client.sent[1]![2]!.content).toContain('evidence-grounded refinement')
|
|
396
|
-
})
|
|
397
|
-
|
|
398
|
-
it('명시적 세션 피드백은 제한된 참고 데이터로 다음 후보 요청에 들어간다', async () => {
|
|
399
|
-
const client = stubClient([candidate()])
|
|
400
|
-
setDefaultAIClient(client)
|
|
401
|
-
|
|
402
|
-
await proposeFigure({
|
|
403
|
-
prompt: '포트가 더 눈에 띄게',
|
|
404
|
-
palette: PALETTE,
|
|
405
|
-
type: 'MIXER',
|
|
406
|
-
feedback: [
|
|
407
|
-
{
|
|
408
|
-
outcome: 'discarded', selectedChanges: 0, totalChanges: 4,
|
|
409
|
-
note: '실루엣은 유지하고 포트만 더 잘 보이게',
|
|
410
|
-
quality: { status: 'review', findingCodes: ['generic-part-name', 'bad code!'] }
|
|
411
|
-
}
|
|
412
|
-
]
|
|
413
|
-
})
|
|
414
|
-
|
|
415
|
-
const ask = firstAsk(client)
|
|
416
|
-
expect(ask).toContain('session-only author feedback')
|
|
417
|
-
expect(ask).toContain('실루엣은 유지하고 포트만 더 잘 보이게')
|
|
418
|
-
expect(ask).toContain('generic-part-name')
|
|
419
|
-
expect(ask).not.toContain('bad code!')
|
|
420
|
-
})
|
|
421
|
-
|
|
422
|
-
it('틀리면 사유를 돌려주고 다시 시킨다', async () => {
|
|
423
|
-
const client = stubClient([candidate({ parts: [] }), candidate()])
|
|
424
|
-
setDefaultAIClient(client)
|
|
425
|
-
|
|
426
|
-
const result = await proposeFigure({ prompt: '교반기', palette: PALETTE, type: 'MIXER' })
|
|
427
|
-
|
|
428
|
-
expect(result.attempts).toBe(2)
|
|
429
|
-
expect(client.calls).toBe(2)
|
|
430
|
-
})
|
|
431
|
-
|
|
432
|
-
it('다른 type을 낸 후보도 다시 시킨다 — Figure 행과 source가 갈라지지 않게', async () => {
|
|
433
|
-
const client = stubClient([candidate({ type: 'FORKLIFT' }), candidate()])
|
|
434
|
-
setDefaultAIClient(client)
|
|
435
|
-
|
|
436
|
-
const result = await proposeFigure({ prompt: '교반기', palette: PALETTE, type: 'MIXER' })
|
|
437
|
-
|
|
438
|
-
expect(result.attempts).toBe(2)
|
|
439
|
-
expect(result.source.type).toBe('MIXER')
|
|
440
|
-
expect(client.sent[1]![2]!.content).toContain('The figure type must remain "MIXER"')
|
|
441
|
-
})
|
|
442
|
-
|
|
443
|
-
it('수정 후보는 원래 type을 바꿀 수 없다', async () => {
|
|
444
|
-
const client = stubClient([candidate({ type: 'FORKLIFT' }), candidate({ type: 'MIXER' })])
|
|
445
|
-
setDefaultAIClient(client)
|
|
446
|
-
|
|
447
|
-
const result = await proposeFigure({ prompt: '재질을 줄여줘', palette: PALETTE, base: candidate() as never })
|
|
448
|
-
|
|
449
|
-
expect(result.attempts).toBe(2)
|
|
450
|
-
expect(result.source.type).toBe('MIXER')
|
|
451
|
-
})
|
|
452
|
-
|
|
453
|
-
it('끝내 못 만들면 못 만들었다고 말한다 — 반쯤 만든 것을 내밀지 않는다', async () => {
|
|
454
|
-
setDefaultAIClient(stubClient([candidate({ parts: [] }), candidate({ parts: [] }), candidate({ parts: [] })]))
|
|
455
|
-
|
|
456
|
-
await expect(proposeFigure({ prompt: '교반기', palette: PALETTE, type: 'MIXER' })).rejects.toThrow(ProposeFailure)
|
|
457
|
-
})
|
|
458
|
-
|
|
459
|
-
it('못 만들었을 때 마지막 사유를 함께 낸다', async () => {
|
|
460
|
-
setDefaultAIClient(stubClient([candidate({ parts: [] }), candidate({ parts: [] }), candidate({ parts: [] })]))
|
|
461
|
-
|
|
462
|
-
await proposeFigure({ prompt: '교반기', palette: PALETTE, type: 'MIXER' }).catch((e: ProposeFailure) => {
|
|
463
|
-
expect(e.reasons.length).toBeGreaterThan(0)
|
|
464
|
-
expect(e.attempts).toBe(3)
|
|
465
|
-
})
|
|
466
|
-
})
|
|
467
|
-
|
|
468
|
-
it('모델이 없으면 있는 척하지 않는다', async () => {
|
|
469
|
-
setDefaultAIClient(undefined)
|
|
470
|
-
|
|
471
|
-
await expect(proposeFigure({ prompt: '교반기', palette: PALETTE, type: 'MIXER' })).rejects.toThrow(
|
|
472
|
-
/설정돼 있지 않습니다/
|
|
473
|
-
)
|
|
474
|
-
})
|
|
475
|
-
})
|
|
476
|
-
|
|
477
|
-
describe('무엇을 시키는지가 프롬프트에 실린다', () => {
|
|
478
|
-
/*
|
|
479
|
-
고치는 요청에서 이 지시가 빠지면 AI 가 부품 이름을 갈아 버린다. 부품 이름은 **저장되는
|
|
480
|
-
식별자**라서, 이미 배치된 도면의 바인딩·애니메이션이 그 이름을 가리키고 있다. 조용히
|
|
481
|
-
끊기고, 끊긴 것은 그 도면을 열어 봐야 드러난다.
|
|
482
|
-
*/
|
|
483
|
-
it('고치는 요청이면 지금 정본과 「이름을 바꾸지 말라」가 함께 나간다', async () => {
|
|
484
|
-
const client = stubClient([candidate()])
|
|
485
|
-
setDefaultAIClient(client)
|
|
486
|
-
|
|
487
|
-
const base = candidate({ type: 'MIXER' }) as never
|
|
488
|
-
await proposeFigure({ prompt: '재질을 줄여줘', palette: PALETTE, base })
|
|
489
|
-
|
|
490
|
-
const ask = firstAsk(client)
|
|
491
|
-
expect(ask).toContain('Revise this existing figure')
|
|
492
|
-
expect(ask).toMatch(/renaming a part breaks bindings/)
|
|
493
|
-
expect(ask).toContain('Measured evidence for the existing figure')
|
|
494
|
-
expect(ask).toContain('top-view readability')
|
|
495
|
-
// 지금 정본이 실제로 실려 나가야 한다 — 없으면 모델이 처음부터 만든다.
|
|
496
|
-
expect(ask).toContain('"name":"body"')
|
|
497
|
-
})
|
|
498
|
-
|
|
499
|
-
/*
|
|
500
|
-
타입 이름을 안 주면 `FIGURE` 로 나간다. **조용히 지어지는 값**이라 못 박아 둔다 — 저작면이
|
|
501
|
-
타입을 늘 주므로 이 자리는 평소 안 보이지만, 안 주는 부르는 쪽이 생기면 모델이 만든 것이
|
|
502
|
-
전부 `FIGURE` 라는 이름을 갖게 된다. 타입 이름은 저장되는 식별자다.
|
|
503
|
-
*/
|
|
504
|
-
it('타입을 안 주면 FIGURE 로 나간다 — 조용히 빈 이름을 보내지 않는다', async () => {
|
|
505
|
-
const client = stubClient([candidate()])
|
|
506
|
-
setDefaultAIClient(client)
|
|
507
|
-
|
|
508
|
-
await proposeFigure({ prompt: '무언가', palette: PALETTE })
|
|
509
|
-
|
|
510
|
-
expect(firstAsk(client)).toContain('type "FIGURE"')
|
|
511
|
-
})
|
|
512
|
-
|
|
513
|
-
it('새로 만드는 요청이면 타입 이름이 나가고 고치라는 말은 안 나간다', async () => {
|
|
514
|
-
const client = stubClient([candidate()])
|
|
515
|
-
setDefaultAIClient(client)
|
|
516
|
-
|
|
517
|
-
await proposeFigure({ prompt: '교반기', palette: PALETTE, type: 'MIXER' })
|
|
518
|
-
|
|
519
|
-
const ask = firstAsk(client)
|
|
520
|
-
expect(ask).toContain('type "MIXER"')
|
|
521
|
-
expect(ask).not.toContain('Revise this existing figure')
|
|
522
|
-
})
|
|
523
|
-
|
|
524
|
-
/*
|
|
525
|
-
그림은 **비례를 보라고** 주는 것이지 표면 무늬를 베끼라고 주는 것이 아니다. 그 말이 빠지면
|
|
526
|
-
모델이 사진의 로고·녹·글자까지 부품으로 만들려 들고, 로우폴리에서 그것은 부품 낭비다.
|
|
527
|
-
*/
|
|
528
|
-
it('그림을 주면 비례를 보라는 말과 함께 나간다', async () => {
|
|
529
|
-
const client = stubClient([candidate()])
|
|
530
|
-
setDefaultAIClient(client)
|
|
531
|
-
|
|
532
|
-
await proposeFigure({
|
|
533
|
-
prompt: '이 설비',
|
|
534
|
-
palette: PALETTE,
|
|
535
|
-
type: 'MIXER',
|
|
536
|
-
image: { data: Buffer.from('89504e47', 'hex'), mediaType: 'image/png' }
|
|
537
|
-
})
|
|
538
|
-
|
|
539
|
-
const ask = firstAsk(client)
|
|
540
|
-
expect(ask).toMatch(/proportions and masses, not for surface detail/)
|
|
541
|
-
// 그림 자체도 실려야 한다 — 말만 나가고 그림이 빠지면 모델이 볼 것이 없다.
|
|
542
|
-
expect(ask).toContain('"type":"image"')
|
|
543
|
-
})
|
|
544
|
-
})
|
|
545
|
-
|
|
546
|
-
describe('모델이 JSON 을 못 낼 때', () => {
|
|
547
|
-
/*
|
|
548
|
-
어댑터가 던지는 경우다. 그것도 사유이므로 **돌려주고 다시 시킨다** — 던져 버리면 저작자는
|
|
549
|
-
「안 됐다」만 보고, 무엇이 문제였는지도 모델이 고칠 기회도 없다.
|
|
550
|
-
*/
|
|
551
|
-
it('던진 사유를 돌려주고 다시 시킨다', async () => {
|
|
552
|
-
const client = stubClient([new Error('unterminated string'), candidate()])
|
|
553
|
-
setDefaultAIClient(client)
|
|
554
|
-
|
|
555
|
-
const result = await proposeFigure({ prompt: '교반기', palette: PALETTE, type: 'MIXER' })
|
|
556
|
-
|
|
557
|
-
expect(result.attempts).toBe(2)
|
|
558
|
-
expect(client.calls).toBe(2)
|
|
559
|
-
|
|
560
|
-
// 두 번째 요청에 그 사유가 실려야 한다.
|
|
561
|
-
const retry = JSON.stringify(client.sent[1])
|
|
562
|
-
expect(retry).toContain('unterminated string')
|
|
563
|
-
expect(retry).toMatch(/Answer with the JSON object only/)
|
|
564
|
-
})
|
|
565
|
-
|
|
566
|
-
it('세 번 다 던지면 못 만들었다고 말한다', async () => {
|
|
567
|
-
setDefaultAIClient(stubClient([new Error('a'), new Error('b'), new Error('c')]))
|
|
568
|
-
|
|
569
|
-
await expect(proposeFigure({ prompt: '교반기', palette: PALETTE, type: 'MIXER' })).rejects.toThrow(ProposeFailure)
|
|
570
|
-
})
|
|
571
|
-
})
|
|
572
|
-
|
|
573
|
-
describe('정책을 넘은 후보는 막지 않고 알린다', () => {
|
|
574
|
-
/*
|
|
575
|
-
형식의 두 갈래를 그대로 지킨다 — `errors` 는 막고 `violations` 는 알린다. 정책 위반으로
|
|
576
|
-
후보를 버리면 저작자가 판단할 기회가 없어진다. 랜드마크 설비 하나는 정교해도 되고, 그
|
|
577
|
-
대가가 숫자로 보이면 된다.
|
|
578
|
-
*/
|
|
579
|
-
it('등급 한도를 넘겨도 후보로 오고 위반이 함께 온다', async () => {
|
|
580
|
-
const many = Array.from({ length: PART_LIMIT.S + 1 }, (_, at) => ({
|
|
581
|
-
name: `part-${at}`,
|
|
582
|
-
primitive: PRIMITIVE_KINDS[0],
|
|
583
|
-
transform: { position: { x: at * 10, y: 250, z: 0 }, size: { x: 20, y: 20, z: 20 } },
|
|
584
|
-
material: { token: PALETTE[0] }
|
|
585
|
-
}))
|
|
586
|
-
setDefaultAIClient(stubClient([candidate({ detailLevel: 'S', parts: many })]))
|
|
587
|
-
|
|
588
|
-
const result = await proposeFigure({ prompt: '단순한 것', palette: PALETTE, type: 'MIXER' })
|
|
589
|
-
|
|
590
|
-
expect(result.source.parts).toHaveLength(PART_LIMIT.S + 1)
|
|
591
|
-
expect(result.violations.map(one => one.code)).toContain('too-many-parts')
|
|
592
|
-
})
|
|
593
|
-
})
|
|
594
|
-
|
|
595
|
-
/*
|
|
596
|
-
* `parameters` — the one field that answers "expose a value an instance can be given".
|
|
597
|
-
*
|
|
598
|
-
* A user asked for a hoist that lowers and raises, with the travel exposed as a variable. The
|
|
599
|
-
* model answered with `sizing`/`anchor` on the figure's own height and invented
|
|
600
|
-
* `capabilities: ["hoist"]`, which threw at render. It had not declined; it had gone looking and
|
|
601
|
-
* not found -- because the door was hidden inside animations as `drive: 'hold'`, and the prompt
|
|
602
|
-
* described that as a playback mode ("hold plays once and stays") when what it actually said was
|
|
603
|
-
* WHO SUPPLIES THE VALUE.
|
|
604
|
-
*
|
|
605
|
-
* ADR-0051 moved it into its own field with a declared physical range, which is what makes the
|
|
606
|
-
* value nameable: an instance gives 1200mm, not 0.67.
|
|
607
|
-
*
|
|
608
|
-
* These guard the wording, because the wording is the defect. A prompt that hides the one door
|
|
609
|
-
* leaves the model to invent a door.
|
|
610
|
-
*/
|
|
611
|
-
/*
|
|
612
|
-
* The capability vocabulary, which the prompt used to leave open.
|
|
613
|
-
*
|
|
614
|
-
* It said "as plain names the scene knows" -- an instruction to guess. The model guessed
|
|
615
|
-
* `capabilities: ['hoist']`, and because the list lived only in things-scene, figure-model's
|
|
616
|
-
* validate could not refuse it: the asset passed validation, passed publication, and threw at
|
|
617
|
-
* render. ADR-0051 (4) moved the list into the format, so the prompt can now state it.
|
|
618
|
-
*/
|
|
619
|
-
describe('능력 이름을 형식에서 받아 적는다 — 「씬이 아는 이름」은 지어내라는 말이다', () => {
|
|
620
|
-
const prompt = () => rulesPrompt(PALETTE)
|
|
621
|
-
|
|
622
|
-
it('닫힌 목록이라고 못 박는다', () => {
|
|
623
|
-
const text = prompt()
|
|
624
|
-
expect(text).toMatch(/capabilities \(top level\)[\s\S]*CLOSED LIST/)
|
|
625
|
-
expect(text).not.toMatch(/plain names the scene knows/)
|
|
626
|
-
})
|
|
627
|
-
|
|
628
|
-
it('목록을 형식에서 읽는다 — 손으로 적지 않는다', () => {
|
|
629
|
-
/* 이름 하나를 여기 적어 두면 형식에 능력이 늘 때 이 시험이 조용히 통과한다. */
|
|
630
|
-
const text = prompt()
|
|
631
|
-
for (const name of FIGURE_CAPABILITIES) {
|
|
632
|
-
expect(text).toContain(name)
|
|
633
|
-
}
|
|
634
|
-
})
|
|
635
|
-
|
|
636
|
-
it('혼자 못 서는 능력도 함께 적는다 — 그것도 형식의 선언이다', () => {
|
|
637
|
-
const text = prompt()
|
|
638
|
-
for (const [name, needs] of Object.entries(CAPABILITY_NEEDS)) {
|
|
639
|
-
expect(text).toContain(`${name} cannot stand alone`)
|
|
640
|
-
for (const one of needs) expect(text).toContain(one)
|
|
641
|
-
}
|
|
642
|
-
})
|
|
643
|
-
|
|
644
|
-
it('움직임을 능력으로 답하지 말라고 한다 — 실제로 나왔던 답이다', () => {
|
|
645
|
-
expect(prompt()).toMatch(/Do NOT answer it with a capability/)
|
|
646
|
-
})
|
|
647
|
-
})
|
|
648
|
-
|
|
649
|
-
describe('값이 만드는 자세를 parameters 로 가르친다', () => {
|
|
650
|
-
const prompt = () => rulesPrompt(PALETTE)
|
|
651
|
-
|
|
652
|
-
it('재생 방식으로 적지 않는다', () => {
|
|
653
|
-
expect(prompt()).not.toMatch(/plays once|repeats forever/i)
|
|
654
|
-
expect(prompt()).toMatch(/Nothing is played back/)
|
|
655
|
-
})
|
|
656
|
-
|
|
657
|
-
it('두 갈래를 갈라서 적는다 — 어느 쪽이 무엇을 받는지까지', () => {
|
|
658
|
-
const text = prompt()
|
|
659
|
-
expect(text).toMatch(/TWO kinds and they are different fields/)
|
|
660
|
-
expect(text).toMatch(/animations\s+TIME RUNS\. The instance supplies a SPEED/)
|
|
661
|
-
expect(text).toMatch(/parameters\s+A VALUE MAKES THE POSE/)
|
|
662
|
-
})
|
|
663
|
-
|
|
664
|
-
it('인스턴스가 받는 값을 물으면 parameters 라고 못 박는다', () => {
|
|
665
|
-
/* 이 문장이 없으면 모델이 크기 반응 쪽으로 간다 — 실제로 갔다. */
|
|
666
|
-
expect(prompt()).toMatch(/USE parameters WHENEVER THE REQUEST ASKS FOR A VALUE THE INSTANCE IS GIVEN/)
|
|
667
|
-
expect(prompt()).toMatch(/EXPOSE something AS A VARIABLE/)
|
|
668
|
-
})
|
|
669
|
-
|
|
670
|
-
it('세 가지 잘못된 답을 각각 막는다 — 크기 반응 · 애니메이션 · 능력', () => {
|
|
671
|
-
const text = prompt()
|
|
672
|
-
expect(text).toMatch(/Do NOT answer that request with sizing or anchor/)
|
|
673
|
-
expect(text).toMatch(/Do NOT answer it with animations either/)
|
|
674
|
-
/* `capabilities: ['hoist']` 가 실제로 나왔던 답이다. */
|
|
675
|
-
expect(text).toMatch(/Do NOT answer it with a capability/)
|
|
676
|
-
})
|
|
677
|
-
|
|
678
|
-
it('범위를 사람이 말하는 단위로 적으라고 한다 — 0~1 이 아니라', () => {
|
|
679
|
-
/*
|
|
680
|
-
곡선의 값 축은 0~1 이지만 그것은 런타임의 말이다. 저작자와 인스턴스가 주고받는 것은
|
|
681
|
-
mm 이고, 그 선언이 없으면 「길이값을 변수로 노출」이 성립하지 않는다.
|
|
682
|
-
*/
|
|
683
|
-
const text = prompt()
|
|
684
|
-
expect(text).toMatch(/range: \{ unit, min, max \}/)
|
|
685
|
-
expect(text).toMatch(/not 0 to 1/)
|
|
686
|
-
})
|
|
687
|
-
|
|
688
|
-
/*
|
|
689
|
-
Source edition 2 measures y up from the bottom face of the base box (ADR-0065). The prompt taught
|
|
690
|
-
the box-centre origin before; a model following that would write sources whose parts sit half a
|
|
691
|
-
box too low and that the release gate refuses.
|
|
692
|
-
*/
|
|
693
|
-
it('teaches y from the bottom face of the base box and asks for version 2', () => {
|
|
694
|
-
const text = prompt()
|
|
695
|
-
expect(text).toMatch(/y is measured UP from its bottom face/)
|
|
696
|
-
expect(text).toMatch(/floor: the lowest part reaches\s+y = 0; ceiling: the highest reaches y = base\.y/)
|
|
697
|
-
expect(text).toMatch(/\{ version: 2, type, base/)
|
|
698
|
-
expect(text).not.toMatch(/-base\.y ?\/ ?2/)
|
|
699
|
-
expect(text).not.toMatch(/origin \(0,0,0\) is the centre of the base box/)
|
|
700
|
-
})
|
|
701
|
-
|
|
702
|
-
it('tells the model a parameter key sits on the value span', () => {
|
|
703
|
-
/*
|
|
704
|
-
Keys sit on the 0..1 value span and the format refuses a key above 1 (ADR-0051); the transition
|
|
705
|
-
time is clip.duration. The prompt once taught that the last at doubles as seconds, which led the
|
|
706
|
-
model to write keys in seconds and produce sources the format refuses.
|
|
707
|
-
*/
|
|
708
|
-
const text = prompt()
|
|
709
|
-
expect(text).toMatch(/it is the\s+value span, 0 to 1/)
|
|
710
|
-
expect(text).toMatch(/An `at` above 1 is refused/)
|
|
711
|
-
expect(text).toMatch(/clip: \{ duration\?, channels/)
|
|
712
|
-
expect(text).toMatch(/Leave it\s+out and a new value takes effect at once/)
|
|
713
|
-
expect(text).not.toMatch(/doubles as the seconds/)
|
|
714
|
-
})
|
|
715
|
-
|
|
716
|
-
it('두 목록이 이름을 나눠 쓴다고 말한다 — 겹치면 형식이 막는다', () => {
|
|
717
|
-
expect(prompt()).toMatch(/may not be called the same thing/)
|
|
718
|
-
})
|
|
719
|
-
})
|