@things-factory/figure-ui 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.
Files changed (46) hide show
  1. package/client/figure-assistant.ts +26 -10
  2. package/client/graphql/index.ts +0 -58
  3. package/client/modeller/figure-v3-properties.ts +6 -1
  4. package/client/modeller/v3-ai-proposal.ts +111 -0
  5. package/client/modeller/v3-product-session.ts +10 -1
  6. package/client/pages/figure-v3-modeller-page.ts +39 -4
  7. package/client/types.ts +0 -39
  8. package/dist-client/figure-assistant.js +22 -10
  9. package/dist-client/figure-assistant.js.map +1 -1
  10. package/dist-client/graphql/index.d.ts +1 -19
  11. package/dist-client/graphql/index.js +0 -46
  12. package/dist-client/graphql/index.js.map +1 -1
  13. package/dist-client/modeller/figure-v3-properties.js +6 -1
  14. package/dist-client/modeller/figure-v3-properties.js.map +1 -1
  15. package/dist-client/modeller/v3-ai-proposal.d.ts +51 -0
  16. package/dist-client/modeller/v3-ai-proposal.js +64 -0
  17. package/dist-client/modeller/v3-ai-proposal.js.map +1 -0
  18. package/dist-client/modeller/v3-product-session.d.ts +35 -1
  19. package/dist-client/modeller/v3-product-session.js +8 -0
  20. package/dist-client/modeller/v3-product-session.js.map +1 -1
  21. package/dist-client/pages/figure-v3-modeller-page.d.ts +7 -1
  22. package/dist-client/pages/figure-v3-modeller-page.js +46 -5
  23. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  24. package/dist-client/route.d.ts +1 -1
  25. package/dist-client/tsconfig.fui.tsbuildinfo +1 -1
  26. package/dist-client/tsconfig.tsbuildinfo +1 -1
  27. package/dist-client/types.d.ts +0 -44
  28. package/dist-client/types.js.map +1 -1
  29. package/package.json +5 -5
  30. package/test/figure-assistant-surface.test.ts +9 -6
  31. package/test/v3-ai-proposal.test.ts +112 -0
  32. package/test/v3-refusals.test.ts +5 -0
  33. package/translations/en.json +6 -1
  34. package/translations/ja.json +6 -1
  35. package/translations/ko.json +6 -1
  36. package/translations/ms.json +6 -1
  37. package/translations/zh.json +6 -1
  38. package/client/modeller/proposal-session.ts +0 -98
  39. package/client/modeller/proposal.ts +0 -171
  40. package/dist-client/modeller/proposal-session.d.ts +0 -28
  41. package/dist-client/modeller/proposal-session.js +0 -66
  42. package/dist-client/modeller/proposal-session.js.map +0 -1
  43. package/dist-client/modeller/proposal.d.ts +0 -45
  44. package/dist-client/modeller/proposal.js +0 -114
  45. package/dist-client/modeller/proposal.js.map +0 -1
  46. package/test/ai-proposal-contract.test.ts +0 -563
@@ -1,563 +0,0 @@
1
- /*
2
- * AI 후보가 저작 화면의 정본에 닿는 마지막 경계다.
3
- *
4
- * 모델의 응답은 신뢰하지 않는다. `proposal.ts`는 사람이 고른 변경만 옮기고, 저장 식별자인
5
- * Figure type과 이름으로 연결된 부품의 의미를 보존해야 한다. 이 순수 로직을 Lit 화면 밖에서
6
- * 시험해 두면 DOM 배선이 바뀌어도 후보 수락 규칙이 조용히 약해지지 않는다.
7
- */
8
- import assert from 'node:assert/strict'
9
- import { readFileSync } from 'node:fs'
10
- import { fileURLToPath } from 'node:url'
11
- import { createRequire } from 'node:module'
12
- import { test } from 'node:test'
13
-
14
- import { applyProposal, diffProposal, takeProposal } from '../dist-client/modeller/proposal.js'
15
- import { feedbackFromSession, openAiProposal, openSizingFix, toggleProposalChange } from '../dist-client/modeller/proposal-session.js'
16
-
17
- const current = () => ({
18
- type: 'MIXER',
19
- base: { x: 100, y: 100, z: 100 },
20
- detailLevel: 'M',
21
- parts: [
22
- {
23
- name: 'body',
24
- primitive: 'cube',
25
- transform: { position: { x: 0, y: 0, z: 0 }, size: { x: 100, y: 100, z: 100 } },
26
- material: { token: 'palette.primary' }
27
- },
28
- {
29
- name: 'motor',
30
- primitive: 'cube',
31
- transform: { position: { x: 0, y: 60, z: 0 }, size: { x: 20, y: 20, z: 20 } },
32
- material: { token: 'palette.neutral' }
33
- }
34
- ]
35
- })
36
-
37
- const proposal = () => ({
38
- type: 'FORKLIFT', // 모델이 이 값을 지어내도 이미 저장된 Figure의 type은 바꾸지 않는다.
39
- base: { x: 200, y: 100, z: 100 },
40
- detailLevel: 'L',
41
- parts: [
42
- {
43
- name: 'body',
44
- primitive: 'cube',
45
- transform: { position: { x: 0, y: 0, z: 0 }, size: { x: 200, y: 100, z: 100 } },
46
- material: { token: 'palette.accent' }
47
- },
48
- {
49
- name: 'guard',
50
- primitive: 'cube',
51
- transform: { position: { x: 0, y: 50, z: 0 }, size: { x: 100, y: 10, z: 10 } },
52
- material: { token: 'palette.primary' }
53
- }
54
- ]
55
- })
56
-
57
- test('AI 후보는 모든 변경을 이름 단위로 드러낸다 — 사람에게 숨은 변경이 없다', () => {
58
- const changes = diffProposal(current() as any, proposal() as any)
59
-
60
- assert.deepEqual(
61
- changes.map(change => `${change.kind}:${change.key}`).sort(),
62
- ['added:guard', 'base:base', 'changed:body', 'detail:detailLevel', 'removed:motor']
63
- )
64
- })
65
-
66
- test('선택하지 않은 후보 변경은 정본에 들어가지 않는다', () => {
67
- const result = applyProposal(current() as any, proposal() as any, new Set(['body', 'base']))
68
-
69
- assert.deepEqual(result.base, { x: 200, y: 100, z: 100 })
70
- assert.equal(result.detailLevel, 'M')
71
- assert.deepEqual(result.parts.map(part => part.name), ['body', 'motor'])
72
- assert.equal(result.parts[0].material.token, 'palette.accent')
73
- })
74
-
75
- test('후보가 다른 type을 내도 이미 존재하는 Figure의 저장 식별자는 보존한다', () => {
76
- const result = takeProposal(current() as any, proposal() as any)
77
-
78
- assert.equal(result.type, 'MIXER')
79
- })
80
-
81
- test('새 Figure의 후보는 사람의 수락 전까지 입력 source를 바꾸지 않는다', () => {
82
- const candidate = proposal()
83
- const result = takeProposal(undefined, candidate as any)
84
-
85
- assert.notEqual(result, candidate)
86
- assert.equal(candidate.type, 'FORKLIFT')
87
- assert.equal(result.type, 'FORKLIFT')
88
- })
89
-
90
- test('AI 후보의 검토 상태는 하나의 세션으로 열리고, 선택은 불변으로 바뀐다', () => {
91
- const candidate = openAiProposal(current() as any, {
92
- source: JSON.stringify(proposal()),
93
- grade: 'L',
94
- triangles: 42,
95
- groups: 2,
96
- attempts: 1,
97
- quality: { status: 'ready', summary: 'ready', findings: [], visualRegions: 1, visualCoverage: 1, visualReadability: 1 }
98
- } as any)
99
-
100
- const toggled = toggleProposalChange(candidate, 'body')
101
- assert.notEqual(toggled, candidate)
102
- assert.equal(candidate.picked.has('body'), true)
103
- assert.equal(toggled.picked.has('body'), false)
104
- assert.equal(toggled.note, '')
105
- })
106
-
107
- test('발행 게이트 수정과 AI 후보는 같은 검토 세션 경계를 쓴다', () => {
108
- const session = openSizingFix(current() as any, { part: 'body', axis: 'x', from: 'auto', to: 'max' } as any)
109
- assert.equal(session?.metrics, undefined)
110
- assert.equal(session?.source.parts[0].anchor?.x, 'max')
111
- assert.equal(openSizingFix(current() as any, { part: 'missing', axis: 'x', to: 'max' } as any), undefined)
112
- })
113
-
114
- test('피드백은 후보 세션의 선택과 측정값만 전송한다', () => {
115
- const session = openAiProposal(current() as any, {
116
- source: JSON.stringify(proposal()),
117
- grade: 'L',
118
- triangles: 42,
119
- groups: 2,
120
- attempts: 1,
121
- quality: { status: 'review', summary: 'check', findings: [{ dimension: 'shape', code: 'S1', message: 'check' }], visualRegions: 1, visualCoverage: 1, visualReadability: 1 }
122
- } as any)
123
- const feedback = feedbackFromSession(current() as any, { ...session, note: ' keep body ' }, 'accepted')
124
- assert.deepEqual(feedback, {
125
- outcome: 'accepted', selectedChanges: session.picked.size, totalChanges: session.picked.size,
126
- note: 'keep body', grade: 'L', quality: { status: 'review', findingCodes: ['S1'] }
127
- })
128
- })
129
-
130
- /*
131
- * The modeller opens no AI door of its own. The application header already has one and it opens the
132
- * same dock, and two doors to one room on one screen make a reader look for the difference between
133
- * them (2026-09-18, on the author's reading of the screen). `figure-ask` — a second, inline form
134
- * that called `proposeFigure` directly — went on 2026-09-16.
135
- *
136
- * What the page owes is unchanged and checked here: when the dock asks, it answers with the source
137
- * as it stands right now — unsaved edits included — so the candidate is a revision of what is open
138
- * rather than something invented from the prompt alone. And it saves nothing by itself.
139
- */
140
- test('그림이 붙은 AI 요청은 JSON이 아니라 GraphQL multipart Upload 링크를 탄다', () => {
141
- const path = fileURLToPath(new URL('../client/graphql/index.ts', import.meta.url))
142
- const source = readFileSync(path, 'utf8')
143
- const propose = source.slice(source.indexOf('export async function proposeFigure'), source.indexOf('/**\n * 발행한다'))
144
-
145
- assert.match(propose, /context: \{ hasUpload: !!request\.image \}/)
146
- })
147
-
148
- /*
149
- * ── The whole round trip, not just one hop ───────────────────────────────────
150
- *
151
- * Everything above checks a single step. What broke on 2026-09-15 was the sequence: a candidate
152
- * that only added animations produced an EMPTY change list, so the take button — disabled while
153
- * nothing is picked — could never be pressed. The author could neither accept the candidate nor
154
- * get rid of it. Asking again did not help, because the second candidate had the same shape.
155
- *
156
- * `FIGURE_FIELDS` in proposal.ts decides which figure-level fields are visible at all, and it had
157
- * fallen behind the format: `animations`, `capabilities` and `placement` were missing, while
158
- * `anchor` — removed from the format on 2026-09-10 — was still listed. The tests below walk the
159
- * sequence an author actually performs, so a gap in that list shows up as a stuck session rather
160
- * than as a silently dropped field.
161
- */
162
-
163
- /** A candidate whose only change is movement — the shape that could not be finished. */
164
- const animationOnly = () => ({
165
- ...current(),
166
- animations: [
167
- {
168
- name: 'door',
169
- channels: [
170
- {
171
- target: 'motor',
172
- path: 'rotation',
173
- keys: [
174
- { at: 0, value: { x: 0, y: 0, z: 0 } },
175
- { at: 2, value: { x: 0, y: 90, z: 0 } }
176
- ]
177
- }
178
- ]
179
- }
180
- ]
181
- })
182
-
183
- /**
184
- * A candidate whose only change is a value-driven pose.
185
- *
186
- * The same shape as the movement-only case above, and it went wrong the same way: a field the
187
- * change list does not know about diffs to nothing, so the take button stays disabled and the
188
- * author can neither accept the candidate nor clear it. `parameters` arrived on 2026-09-16 and
189
- * would have landed in that hole.
190
- */
191
- const parameterOnly = () => ({
192
- ...current(),
193
- parameters: [
194
- {
195
- name: 'hoist',
196
- range: { unit: 'mm', min: 0, max: 1800 },
197
- clip: {
198
- channels: [
199
- {
200
- target: 'motor',
201
- path: 'translation',
202
- keys: [
203
- { at: 0, value: { x: 0, y: 0, z: 0 } },
204
- { at: 2.5, value: { x: 0, y: -1800, z: 0 } }
205
- ]
206
- }
207
- ]
208
- }
209
- }
210
- ]
211
- })
212
-
213
- test('a candidate that only adds a value-driven pose is something the author can act on', () => {
214
- const changes = diffProposal(current() as any, parameterOnly() as any)
215
-
216
- assert.deepEqual(changes.map(c => `${c.kind}:${c.key}`), ['parameters:parameters'])
217
-
218
- const taken = applyProposal(current() as any, parameterOnly() as any, new Set(['parameters'])) as any
219
- assert.equal(taken.parameters?.length, 1, 'the parameter has to survive being taken')
220
- assert.equal(taken.parameters[0].range.max, 1800, 'the range travels with it -- without it no value can be given')
221
- })
222
-
223
- test('a candidate that only adds movement is something the author can act on', () => {
224
- const changes = diffProposal(current() as any, animationOnly() as any)
225
-
226
- assert.deepEqual(
227
- changes.map(c => `${c.kind}:${c.key}`),
228
- ['animations:animations'],
229
- 'movement-only candidates used to diff to nothing, which left the take button disabled forever'
230
- )
231
-
232
- const taken = applyProposal(current() as any, animationOnly() as any, new Set(['animations'])) as any
233
- assert.equal(taken.animations?.length, 1, 'the clip has to survive being taken')
234
- assert.equal(taken.animations[0].channels[0].target, 'motor')
235
- })
236
-
237
- test('figure-level fields the format has are all offered, and ones it dropped are not', () => {
238
- const before = { ...current(), placement: 'floor', capabilities: ['Holdable'] }
239
- const after = {
240
- ...current(),
241
- placement: 'ceiling',
242
- capabilities: ['Holdable', 'FlowNode'],
243
- styleKit: 'factory',
244
- detailLevel: 'L',
245
- base: { x: 120, y: 100, z: 100 },
246
- animations: animationOnly().animations
247
- }
248
-
249
- const kinds = diffProposal(before as any, after as any).map(c => c.kind)
250
- for (const kind of ['base', 'placement', 'detail', 'styleKit', 'animations', 'capabilities']) {
251
- assert.ok(kinds.includes(kind as any), `${kind} changed and the author was never shown it`)
252
- }
253
- assert.ok(!kinds.includes('anchor' as any), 'figure-level anchor left the format on 2026-09-10')
254
- })
255
-
256
- test('propose, revise, then accept — each round starts from what the author has now', () => {
257
- /* Round one: the author keeps the new guard but not the wider base. */
258
- const first = openAiProposal(current() as any, { source: JSON.stringify(proposal()) } as any)
259
- assert.ok(first.picked.size > 0, 'a fresh session starts with every change picked')
260
-
261
- const withoutBase = toggleProposalChange(first, 'base')
262
- assert.ok(!withoutBase.picked.has('base'))
263
-
264
- const afterFirst = applyProposal(current() as any, proposal() as any, withoutBase.picked) as any
265
- assert.deepEqual(afterFirst.base, current().base, 'the change left unpicked must not be applied')
266
- assert.ok(afterFirst.parts.some((p: any) => p.name === 'guard'), 'the picked addition must be applied')
267
-
268
- /* Round two: asked again, this time about movement. The base the author refused stays refused. */
269
- const revised = { ...afterFirst, animations: animationOnly().animations }
270
- const second = openAiProposal(afterFirst, { source: JSON.stringify(revised) } as any)
271
- assert.deepEqual(
272
- [...second.picked],
273
- ['animations'],
274
- 'the second round compares against the accepted draft, not the original — already-taken changes are not offered again'
275
- )
276
-
277
- const afterSecond = applyProposal(afterFirst, revised as any, second.picked) as any
278
- assert.equal(afterSecond.animations?.length, 1)
279
- assert.ok(afterSecond.parts.some((p: any) => p.name === 'guard'), 'round one survives round two')
280
- assert.deepEqual(afterSecond.base, current().base, 'and so does the refusal')
281
- })
282
-
283
- test('cancelling leaves the draft exactly as it was', () => {
284
- const draft = current()
285
- const session = openAiProposal(draft as any, { source: JSON.stringify(proposal()) } as any)
286
-
287
- /* Cancelling is dropping the session. The draft is a separate value and must be untouched. */
288
- assert.deepEqual(draft, current(), 'opening a candidate for review must not mutate the draft')
289
- assert.ok(session.source !== (draft as any), 'and the session must hold its own copy')
290
-
291
- /* Unpicking everything is the other way to refuse: the take button disables and nothing moves. */
292
- let emptied = session
293
- for (const key of [...session.picked]) emptied = toggleProposalChange(emptied, key)
294
- assert.equal(emptied.picked.size, 0)
295
- assert.deepEqual(applyProposal(draft as any, proposal() as any, emptied.picked) as any, {
296
- ...draft,
297
- parts: draft.parts
298
- }, 'with nothing picked the result is the draft itself')
299
- })
300
-
301
- /*
302
- * ── The cases the four tests above did not reach ─────────────────────────────
303
- *
304
- * Those covered the happy sequence. What follows are the ones an author actually hits and that a
305
- * happy-path test never touches: refusing a deletion, a candidate that takes movement away, the
306
- * draft moving under an open candidate, and every change kind surviving being applied rather than
307
- * merely being listed. The animation defect was in exactly that gap — the kind was listed nowhere,
308
- * so nothing that only listed kinds could have caught it.
309
- */
310
-
311
- const withAnimation = () => ({ ...current(), animations: animationOnly().animations })
312
-
313
- test('refusing a deletion keeps the part, and taking it removes it', () => {
314
- const dropsMotor = { ...current(), parts: current().parts.filter(p => p.name !== 'motor') }
315
-
316
- const changes = diffProposal(current() as any, dropsMotor as any)
317
- assert.deepEqual(changes.map(c => `${c.kind}:${c.key}`), ['removed:motor'])
318
-
319
- const refused = applyProposal(current() as any, dropsMotor as any, new Set()) as any
320
- assert.ok(refused.parts.some((p: any) => p.name === 'motor'), 'an unpicked removal must not delete anything')
321
-
322
- const taken = applyProposal(current() as any, dropsMotor as any, new Set(['motor'])) as any
323
- assert.ok(!taken.parts.some((p: any) => p.name === 'motor'))
324
- })
325
-
326
- test('a candidate that takes movement away is shown, not applied behind the author', () => {
327
- /* The reverse direction of the defect: the draft has clips and the candidate comes back without. */
328
- const stripped = { ...withAnimation(), animations: undefined }
329
-
330
- const changes = diffProposal(withAnimation() as any, stripped as any)
331
- assert.deepEqual(changes.map(c => c.kind), ['animations'], 'losing the clips has to appear as a change')
332
-
333
- const refused = applyProposal(withAnimation() as any, stripped as any, new Set()) as any
334
- assert.equal(refused.animations?.length, 1, 'unpicked, the clips stay')
335
-
336
- const taken = applyProposal(withAnimation() as any, stripped as any, new Set(['animations'])) as any
337
- assert.equal(taken.animations, undefined, 'picked, they go — but only because the author said so')
338
- })
339
-
340
- test('every change kind survives being applied, not just being listed', () => {
341
- const before = { ...current(), placement: 'floor', capabilities: ['Holdable'], styleKit: 'plain' }
342
- const after = {
343
- ...current(),
344
- placement: 'ceiling',
345
- capabilities: ['Holdable', 'FlowNode'],
346
- styleKit: 'factory',
347
- detailLevel: 'L',
348
- base: { x: 120, y: 100, z: 100 },
349
- animations: animationOnly().animations
350
- }
351
-
352
- const all = new Set(diffProposal(before as any, after as any).map(c => c.key))
353
- const taken = applyProposal(before as any, after as any, all) as any
354
-
355
- assert.equal(taken.placement, 'ceiling')
356
- assert.deepEqual(taken.capabilities, ['Holdable', 'FlowNode'])
357
- assert.equal(taken.styleKit, 'factory')
358
- assert.equal(taken.detailLevel, 'L')
359
- assert.deepEqual(taken.base, { x: 120, y: 100, z: 100 })
360
- assert.equal(taken.animations?.length, 1)
361
- })
362
-
363
- test('each figure-level change can be taken on its own without dragging the others along', () => {
364
- const before = { ...current(), placement: 'floor', capabilities: ['Holdable'] }
365
- const after = {
366
- ...current(),
367
- placement: 'ceiling',
368
- capabilities: ['Holdable', 'FlowNode'],
369
- animations: animationOnly().animations
370
- }
371
-
372
- for (const only of ['placement', 'capabilities', 'animations']) {
373
- const taken = applyProposal(before as any, after as any, new Set([only])) as any
374
- assert.equal(taken.placement, only === 'placement' ? 'ceiling' : 'floor', `${only}: placement leaked`)
375
- assert.equal(
376
- taken.capabilities.length,
377
- only === 'capabilities' ? 2 : 1,
378
- `${only}: capabilities leaked`
379
- )
380
- assert.equal(taken.animations === undefined, only !== 'animations', `${only}: animations leaked`)
381
- }
382
- })
383
-
384
- test('taking part of a candidate and then asking again offers only what is still different', () => {
385
- const after = {
386
- ...current(),
387
- placement: 'ceiling',
388
- animations: animationOnly().animations,
389
- parts: [...current().parts, proposal().parts[1]]
390
- }
391
-
392
- /* The author takes the movement only. */
393
- const firstRound = applyProposal(current() as any, after as any, new Set(['animations'])) as any
394
- assert.equal(firstRound.animations?.length, 1)
395
- assert.equal(firstRound.placement, undefined)
396
-
397
- /* Asking again with the same candidate must still offer what was left behind — and not movement. */
398
- const second = openAiProposal(firstRound, { source: JSON.stringify(after) } as any)
399
- assert.deepEqual([...second.picked].sort(), ['guard', 'placement'])
400
- })
401
-
402
- test('a malformed candidate is refused at the session boundary, not half-opened', () => {
403
- for (const bad of ['{"type":"MIXER"}', '{"parts":"not an array"}', 'not json at all']) {
404
- assert.throws(
405
- () => openAiProposal(current() as any, { source: bad } as any),
406
- 'a candidate without a usable parts array must not open a review session'
407
- )
408
- }
409
- })
410
-
411
- test('an identical candidate leaves nothing to decide', () => {
412
- const same = openAiProposal(current() as any, { source: JSON.stringify(current()) } as any)
413
- assert.equal(same.picked.size, 0, 'no differences means no picks')
414
- assert.deepEqual(
415
- applyProposal(current() as any, current() as any, same.picked) as any,
416
- current(),
417
- 'and applying it changes nothing'
418
- )
419
- })
420
-
421
- test('taking a candidate twice does not compound — the second time there is nothing left', () => {
422
- const once = applyProposal(current() as any, proposal() as any) as any
423
- const twice = applyProposal(once, proposal() as any) as any
424
- assert.deepEqual(twice, once, 'apply has to be idempotent, or a double click duplicates parts')
425
- assert.equal(
426
- once.parts.filter((p: any) => p.name === 'guard').length,
427
- 1,
428
- 'the added part must not appear twice'
429
- )
430
- })
431
-
432
- test('part names are matched exactly — a renamed part is an addition plus a removal', () => {
433
- const renamed = {
434
- ...current(),
435
- parts: current().parts.map(p => (p.name === 'motor' ? { ...p, name: 'Motor' } : p))
436
- }
437
-
438
- const kinds = diffProposal(current() as any, renamed as any).map(c => `${c.kind}:${c.key}`).sort()
439
- assert.deepEqual(kinds, ['added:Motor', 'removed:motor'], 'case differences are different parts — bindings are exact')
440
- })
441
-
442
- /*
443
- * ── The three properties this path has to keep, stated as properties ─────────
444
- *
445
- * Everything above is a case. Cases are written by hand and a hand-written list stops where its
446
- * author stopped thinking — which is how a whole feature (movement) went unreachable while every
447
- * test stayed green. These are closed over the format instead: they read what a FigureSource may
448
- * contain and assert something that must hold for all of it, so the format growing is what makes
449
- * them fail.
450
- *
451
- * They are also the contract another authoring surface would have to meet. Nothing below knows
452
- * about figures specifically — only that there is a canonical document, a candidate for it, and a
453
- * person deciding between the two.
454
- */
455
-
456
- /** 형식이 선언한 저작 칸. 손으로 적지 않는다 — 형식이 자라면 이 시험도 같이 자란다. */
457
- function figureFields(): string[] {
458
- /* 패키지가 호이스팅되면 이 패키지 아래에 없다. 해석으로 찾는다 — 경로를 적어 두지 않는다. */
459
- const entry = createRequire(import.meta.url).resolve('@hatiolab/figure-model/package.json')
460
- const source = readFileSync(entry.replace(/package\.json$/, 'dist/types.d.ts'), 'utf8')
461
- const body = source.match(/interface FigureSource \{([\s\S]*?)\n\}/)
462
- assert.ok(body, 'FigureSource 를 형식에서 못 찾았다')
463
- return [...body![1].matchAll(/^ {4}([a-zA-Z]+)\??:/gm)].map(m => m[1]!)
464
- }
465
-
466
- test('구조 · 저작 가능한 모든 칸은 검토를 지난다 — 조용히 지나가는 칸이 없다', () => {
467
- /*
468
- `type` 은 저장 식별자라 후보가 바꿔도 받지 않고, `parts` 는 부품 단위로 따로 센다.
469
- `version` 은 저장할 때 서버가 찍는다. 나머지는 전부 사람이 보고 고를 수 있어야 한다.
470
- */
471
- const decidedElsewhere = new Set(['type', 'parts', 'version'])
472
- const mustBeReviewable = figureFields().filter(f => !decidedElsewhere.has(f))
473
-
474
- const before: any = { ...current() }
475
- const after: any = { ...current() }
476
- /* 각 칸을 실제로 다르게 만들어 본다 — 이름만 맞추는 시험은 값이 안 옮겨져도 통과한다. */
477
- const sample: Record<string, [unknown, unknown]> = {
478
- base: [{ x: 100, y: 100, z: 100 }, { x: 120, y: 100, z: 100 }],
479
- placement: ['floor', 'ceiling'],
480
- detailLevel: ['M', 'L'],
481
- styleKit: ['plain', 'factory'],
482
- animations: [undefined, animationOnly().animations],
483
- parameters: [undefined, parameterOnly().parameters],
484
- capabilities: [['Holdable'], ['Holdable', 'FlowNode']],
485
- joints: [
486
- undefined,
487
- [{ name: 'motor-joint', child: 'motor', type: 'revolute', origin: { x: 0, y: 50, z: 0 }, axis: { x: 0, y: 1, z: 0 }, limits: { min: -90, max: 90 } }]
488
- ]
489
- }
490
-
491
- const unsampled = mustBeReviewable.filter(f => !(f in sample))
492
- assert.deepEqual(unsampled, [], '형식에 칸이 늘었다 — 여기에 다른 값 한 쌍을 더해 검토를 확인하라')
493
-
494
- for (const field of mustBeReviewable) {
495
- const [b, a] = sample[field]!
496
- before[field] = b
497
- after[field] = a
498
- }
499
-
500
- const changes = diffProposal(before, after)
501
- const shown = new Set(changes.map(c => c.key))
502
- const hidden = mustBeReviewable.filter(f => !shown.has(f))
503
- assert.deepEqual(hidden, [], '바뀌었는데 사람에게 안 보이는 칸이다 — 받아도 반영되지 않고 아무 말도 안 난다')
504
-
505
- const taken = applyProposal(before, after, new Set(mustBeReviewable)) as any
506
- for (const field of mustBeReviewable) {
507
- assert.deepEqual(taken[field], sample[field]![1], `${field}: 목록에는 떴는데 받아도 안 옮겨졌다`)
508
- }
509
- })
510
-
511
- test('구조 · 고른 것만 옮겨진다 — 어떤 부분집합에도', () => {
512
- const before: any = { ...current(), placement: 'floor', capabilities: ['Holdable'] }
513
- const after: any = {
514
- ...current(),
515
- placement: 'ceiling',
516
- capabilities: ['Holdable', 'FlowNode'],
517
- animations: animationOnly().animations,
518
- parts: [...current().parts, proposal().parts[1]]
519
- }
520
-
521
- const keys = diffProposal(before, after).map(c => c.key)
522
- /* 부분집합 전수. 칸이 늘면 경우도 자동으로 는다. */
523
- for (let mask = 0; mask < 1 << keys.length; mask++) {
524
- const picked = new Set(keys.filter((_, i) => mask & (1 << i)))
525
- const taken = applyProposal(before, after, picked) as any
526
- const left = diffProposal(taken, after).map(c => c.key)
527
-
528
- for (const key of keys) {
529
- const wasPicked = picked.has(key)
530
- assert.equal(
531
- left.includes(key),
532
- !wasPicked,
533
- `고른 것은 남으면 안 되고 안 고른 것은 남아야 한다 (${[...picked].join(',') || '없음'} 에서 ${key})`
534
- )
535
- }
536
- }
537
- })
538
-
539
- test('구조 · 검토는 정본을 건드리지 않는다 — 받기 전까지', () => {
540
- const draft = Object.freeze({
541
- ...current(),
542
- parts: Object.freeze(current().parts.map(p => Object.freeze(p)))
543
- }) as any
544
- const candidate = { ...current(), animations: animationOnly().animations, placement: 'ceiling' } as any
545
-
546
- /* 얼린 것에 쓰면 던진다. 던지지 않는다는 것이 곧 안 건드렸다는 것이다. */
547
- const session = openAiProposal(draft, { source: JSON.stringify(candidate) } as any)
548
- diffProposal(draft, candidate)
549
- applyProposal(draft, candidate, session.picked)
550
- toggleProposalChange(session, 'placement')
551
-
552
- assert.deepEqual(draft.animations, undefined, '검토를 열었다고 정본이 달라지면 안 된다')
553
- assert.equal(draft.placement, undefined)
554
- })
555
-
556
- test('구조 · 같은 후보를 두 번 받아도 한 번 받은 것과 같다', () => {
557
- const after: any = { ...current(), animations: animationOnly().animations, placement: 'ceiling' }
558
- const once = applyProposal(current() as any, after, undefined) as any
559
- const twice = applyProposal(once, after, undefined) as any
560
-
561
- assert.deepEqual(twice, once, '두 번 눌러도 달라지면 안 된다')
562
- assert.deepEqual(diffProposal(once, after), [], '한 번 받으면 남는 변경이 없다')
563
- })