@things-factory/figure-ui 10.1.15 → 10.1.17
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/client/modeller/figure-animations.ts +12 -12
- package/client/modeller/figure-canvas.ts +32 -48
- package/client/modeller/figure-capabilities.ts +10 -10
- package/client/modeller/figure-inspector.ts +39 -28
- package/client/modeller/figure-parts.ts +1 -1
- package/client/modeller/figure-preview.ts +122 -25
- package/client/modeller/figure-report.ts +24 -6
- package/client/modeller/figure-settings.ts +45 -2
- package/client/modeller/figure-side.ts +6 -6
- package/client/modeller/figure-source.ts +84 -38
- package/client/modeller/figure-thumbnail.ts +5 -5
- package/client/modeller/figure-view.ts +53 -0
- package/client/modeller/part-edits.ts +36 -36
- package/client/modeller/proposal.ts +25 -4
- package/client/pages/figure-modeller-page.ts +142 -98
- package/dist-client/modeller/figure-animations.d.ts +4 -4
- package/dist-client/modeller/figure-animations.js +10 -10
- package/dist-client/modeller/figure-animations.js.map +1 -1
- package/dist-client/modeller/figure-canvas.d.ts +7 -7
- package/dist-client/modeller/figure-canvas.js +27 -40
- package/dist-client/modeller/figure-canvas.js.map +1 -1
- package/dist-client/modeller/figure-capabilities.d.ts +2 -2
- package/dist-client/modeller/figure-capabilities.js +9 -9
- package/dist-client/modeller/figure-capabilities.js.map +1 -1
- package/dist-client/modeller/figure-inspector.d.ts +16 -5
- package/dist-client/modeller/figure-inspector.js +38 -27
- package/dist-client/modeller/figure-inspector.js.map +1 -1
- package/dist-client/modeller/figure-parts.d.ts +1 -1
- package/dist-client/modeller/figure-parts.js +1 -1
- package/dist-client/modeller/figure-parts.js.map +1 -1
- package/dist-client/modeller/figure-preview.d.ts +39 -0
- package/dist-client/modeller/figure-preview.js +112 -23
- package/dist-client/modeller/figure-preview.js.map +1 -1
- package/dist-client/modeller/figure-report.d.ts +9 -0
- package/dist-client/modeller/figure-report.js +23 -5
- package/dist-client/modeller/figure-report.js.map +1 -1
- package/dist-client/modeller/figure-settings.js +42 -2
- package/dist-client/modeller/figure-settings.js.map +1 -1
- package/dist-client/modeller/figure-side.d.ts +2 -2
- package/dist-client/modeller/figure-side.js +5 -5
- package/dist-client/modeller/figure-side.js.map +1 -1
- package/dist-client/modeller/figure-source.d.ts +38 -23
- package/dist-client/modeller/figure-source.js +40 -21
- package/dist-client/modeller/figure-source.js.map +1 -1
- package/dist-client/modeller/figure-thumbnail.d.ts +2 -2
- package/dist-client/modeller/figure-thumbnail.js +3 -3
- package/dist-client/modeller/figure-thumbnail.js.map +1 -1
- package/dist-client/modeller/figure-view.d.ts +45 -0
- package/dist-client/modeller/figure-view.js +27 -0
- package/dist-client/modeller/figure-view.js.map +1 -1
- package/dist-client/modeller/part-edits.d.ts +16 -16
- package/dist-client/modeller/part-edits.js +35 -35
- package/dist-client/modeller/part-edits.js.map +1 -1
- package/dist-client/modeller/proposal.d.ts +1 -1
- package/dist-client/modeller/proposal.js +15 -3
- package/dist-client/modeller/proposal.js.map +1 -1
- package/dist-client/pages/figure-modeller-page.d.ts +44 -9
- package/dist-client/pages/figure-modeller-page.js +134 -92
- package/dist-client/pages/figure-modeller-page.js.map +1 -1
- package/dist-client/route.d.ts +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/test/ai-proposal-contract.test.ts +393 -12
- package/test/expect.ts +55 -0
- package/test/figure-source.test.ts +562 -0
- package/test/i18n-prefix-guard.test.ts +1 -1
- package/{client/modeller → test}/part-edits.test.ts +50 -45
- package/translations/en.json +31 -28
- package/translations/ja.json +16 -21
- package/translations/ko.json +41 -38
- package/translations/ms.json +16 -21
- package/translations/zh.json +16 -21
- package/client/modeller/figure-ask.ts +0 -265
- package/client/modeller/figure-source.test.ts +0 -352
- package/dist-client/modeller/figure-ask.d.ts +0 -40
- package/dist-client/modeller/figure-ask.js +0 -282
- package/dist-client/modeller/figure-ask.js.map +0 -1
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import assert from 'node:assert/strict'
|
|
9
9
|
import { readFileSync } from 'node:fs'
|
|
10
10
|
import { fileURLToPath } from 'node:url'
|
|
11
|
+
import { createRequire } from 'node:module'
|
|
11
12
|
import { test } from 'node:test'
|
|
12
13
|
|
|
13
14
|
import { applyProposal, diffProposal, takeProposal } from '../dist-client/modeller/proposal.js'
|
|
@@ -126,17 +127,24 @@ test('피드백은 후보 세션의 선택과 측정값만 전송한다', () =>
|
|
|
126
127
|
})
|
|
127
128
|
})
|
|
128
129
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
130
|
+
/*
|
|
131
|
+
* The modeller has one AI entrance now: the toolbar button opens the asidebar dock, and the dock
|
|
132
|
+
* runs the whole exchange. `figure-ask` — a second, inline form that called `proposeFigure`
|
|
133
|
+
* directly — was removed on 2026-09-16. It had already stopped being rendered, so it was dead
|
|
134
|
+
* code a reader would still have to understand before touching this path.
|
|
135
|
+
*
|
|
136
|
+
* What the entrance owes the author is unchanged and checked here: it must not save anything by
|
|
137
|
+
* itself, and it must hand the server the current source and palette so the candidate is a
|
|
138
|
+
* revision of what is open rather than something invented from the prompt alone.
|
|
139
|
+
*/
|
|
140
|
+
test('the modeller has exactly one AI entrance, and it opens the dock', () => {
|
|
141
|
+
const path = fileURLToPath(new URL('../client/pages/figure-modeller-page.ts', import.meta.url))
|
|
132
142
|
const source = readFileSync(path, 'utf8')
|
|
133
143
|
|
|
134
|
-
assert.match(source, /
|
|
135
|
-
assert.match(source, /
|
|
136
|
-
assert.
|
|
137
|
-
assert.
|
|
138
|
-
assert.match(source, /new CustomEvent\('proposed', \{ detail: \{ proposal, baseSource \}/)
|
|
139
|
-
assert.match(source, /this\.failure = \(err as Error\)\.message/)
|
|
144
|
+
assert.match(source, /requestFigureAI\(\{ figureId: this\.figure\?\.id, source: this\.folded \}\)/)
|
|
145
|
+
assert.match(source, /openOverlay\('figure-ai-dock'/)
|
|
146
|
+
assert.doesNotMatch(source, /<figure-ask/, 'the inline ask form is gone; the dock is the entrance')
|
|
147
|
+
assert.doesNotMatch(source, /proposeFigure\(/, 'the page must not call the propose mutation itself')
|
|
140
148
|
})
|
|
141
149
|
|
|
142
150
|
test('그림이 붙은 AI 요청은 JSON이 아니라 GraphQL multipart Upload 링크를 탄다', () => {
|
|
@@ -189,7 +197,7 @@ test('새 Figure 경로는 MDI에 남아 있는 직전 Figure를 재사용하지
|
|
|
189
197
|
assert.match(pageUpdated, /if \(this\.figure\.id\)[\s\S]*this\.startNew\(\)/)
|
|
190
198
|
// 새 초안은 저장 식별자·선택·기존 부품·진단을 모두 초기화한다.
|
|
191
199
|
assert.match(startNew, /this\.figure = \{ id: '', type: draftType, name: '' \}/)
|
|
192
|
-
assert.match(startNew, /const \{
|
|
200
|
+
assert.match(startNew, /const \{ draft, parts \} = startingModel\(draftType\)/)
|
|
193
201
|
assert.match(startNew, /this\.selected = -1/)
|
|
194
202
|
assert.match(startNew, /this\.dirty = true/)
|
|
195
203
|
assert.match(startNew, /this\.violations = \[\]/)
|
|
@@ -200,7 +208,7 @@ test('빈 새 Figure는 캔버스의 직전 씬을 폐기하고 안내 상태로
|
|
|
200
208
|
const source = readFileSync(path, 'utf8')
|
|
201
209
|
const standUp = source.slice(source.indexOf(' private standUp()'), source.indexOf(' /**\n * 지금 판 위에'))
|
|
202
210
|
|
|
203
|
-
assert.match(standUp, /if \(!
|
|
211
|
+
assert.match(standUp, /if \(!draft\) \{[\s\S]*this\.teardown\(\)/)
|
|
204
212
|
assert.match(standUp, /if \(this\.parts\.length === 0\) \{[\s\S]*this\.teardown\(\)[\s\S]*canvas-appears-once-a-part-exists/)
|
|
205
213
|
})
|
|
206
214
|
|
|
@@ -212,7 +220,7 @@ test('캔버스는 figureId를 판별에 포함하고 없어진 부품을 씬에
|
|
|
212
220
|
|
|
213
221
|
// figureId 변경 시 새 씬을 세우도록 signature에 figureId가 포함된다
|
|
214
222
|
assert.match(canvasSource, /@property\(\{ type: String \}\) figureId\?: string/)
|
|
215
|
-
assert.match(canvasSource, /signature = `\$\{this\.figureId \?\? ''\}:\$\{
|
|
223
|
+
assert.match(canvasSource, /signature = `\$\{this\.figureId \?\? ''\}:\$\{draft\.width\}x\$\{draft\.height\}x\$\{draft\.depth\}/)
|
|
216
224
|
|
|
217
225
|
// 없어진 부품은 root.removeComponent와 component.dispose로 제거된다
|
|
218
226
|
assert.match(canvasSource, /root\.removeComponent\?\.\(component\)/)
|
|
@@ -222,3 +230,376 @@ test('캔버스는 figureId를 판별에 포함하고 없어진 부품을 씬에
|
|
|
222
230
|
assert.match(modellerSource, /\.figureId=\$\{this\.figure\?\.id \|\| this\.figure\?\.type\}/)
|
|
223
231
|
})
|
|
224
232
|
|
|
233
|
+
|
|
234
|
+
/*
|
|
235
|
+
* ── The whole round trip, not just one hop ───────────────────────────────────
|
|
236
|
+
*
|
|
237
|
+
* Everything above checks a single step. What broke on 2026-09-15 was the sequence: a candidate
|
|
238
|
+
* that only added animations produced an EMPTY change list, so the take button — disabled while
|
|
239
|
+
* nothing is picked — could never be pressed. The author could neither accept the candidate nor
|
|
240
|
+
* get rid of it. Asking again did not help, because the second candidate had the same shape.
|
|
241
|
+
*
|
|
242
|
+
* `FIGURE_FIELDS` in proposal.ts decides which figure-level fields are visible at all, and it had
|
|
243
|
+
* fallen behind the format: `animations`, `capabilities` and `placement` were missing, while
|
|
244
|
+
* `anchor` — removed from the format on 2026-09-10 — was still listed. The tests below walk the
|
|
245
|
+
* sequence an author actually performs, so a gap in that list shows up as a stuck session rather
|
|
246
|
+
* than as a silently dropped field.
|
|
247
|
+
*/
|
|
248
|
+
|
|
249
|
+
/** A candidate whose only change is movement — the shape that could not be finished. */
|
|
250
|
+
const animationOnly = () => ({
|
|
251
|
+
...current(),
|
|
252
|
+
animations: [
|
|
253
|
+
{
|
|
254
|
+
name: 'door',
|
|
255
|
+
drive: 'loop',
|
|
256
|
+
channels: [
|
|
257
|
+
{
|
|
258
|
+
target: 'motor',
|
|
259
|
+
path: 'rotation',
|
|
260
|
+
keys: [
|
|
261
|
+
{ at: 0, value: { x: 0, y: 0, z: 0 } },
|
|
262
|
+
{ at: 2, value: { x: 0, y: 90, z: 0 } }
|
|
263
|
+
]
|
|
264
|
+
}
|
|
265
|
+
]
|
|
266
|
+
}
|
|
267
|
+
]
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
test('a candidate that only adds movement is something the author can act on', () => {
|
|
271
|
+
const changes = diffProposal(current() as any, animationOnly() as any)
|
|
272
|
+
|
|
273
|
+
assert.deepEqual(
|
|
274
|
+
changes.map(c => `${c.kind}:${c.key}`),
|
|
275
|
+
['animations:animations'],
|
|
276
|
+
'movement-only candidates used to diff to nothing, which left the take button disabled forever'
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
const taken = applyProposal(current() as any, animationOnly() as any, new Set(['animations'])) as any
|
|
280
|
+
assert.equal(taken.animations?.length, 1, 'the clip has to survive being taken')
|
|
281
|
+
assert.equal(taken.animations[0].channels[0].target, 'motor')
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
test('figure-level fields the format has are all offered, and ones it dropped are not', () => {
|
|
285
|
+
const before = { ...current(), placement: 'floor', capabilities: ['Holdable'] }
|
|
286
|
+
const after = {
|
|
287
|
+
...current(),
|
|
288
|
+
placement: 'ceiling',
|
|
289
|
+
capabilities: ['Holdable', 'FlowNode'],
|
|
290
|
+
styleKit: 'factory',
|
|
291
|
+
detailLevel: 'L',
|
|
292
|
+
base: { x: 120, y: 100, z: 100 },
|
|
293
|
+
animations: animationOnly().animations
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const kinds = diffProposal(before as any, after as any).map(c => c.kind)
|
|
297
|
+
for (const kind of ['base', 'placement', 'detail', 'styleKit', 'animations', 'capabilities']) {
|
|
298
|
+
assert.ok(kinds.includes(kind as any), `${kind} changed and the author was never shown it`)
|
|
299
|
+
}
|
|
300
|
+
assert.ok(!kinds.includes('anchor' as any), 'figure-level anchor left the format on 2026-09-10')
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
test('propose, revise, then accept — each round starts from what the author has now', () => {
|
|
304
|
+
/* Round one: the author keeps the new guard but not the wider base. */
|
|
305
|
+
const first = openAiProposal(current() as any, { source: JSON.stringify(proposal()) } as any)
|
|
306
|
+
assert.ok(first.picked.size > 0, 'a fresh session starts with every change picked')
|
|
307
|
+
|
|
308
|
+
const withoutBase = toggleProposalChange(first, 'base')
|
|
309
|
+
assert.ok(!withoutBase.picked.has('base'))
|
|
310
|
+
|
|
311
|
+
const afterFirst = applyProposal(current() as any, proposal() as any, withoutBase.picked) as any
|
|
312
|
+
assert.deepEqual(afterFirst.base, current().base, 'the change left unpicked must not be applied')
|
|
313
|
+
assert.ok(afterFirst.parts.some((p: any) => p.name === 'guard'), 'the picked addition must be applied')
|
|
314
|
+
|
|
315
|
+
/* Round two: asked again, this time about movement. The base the author refused stays refused. */
|
|
316
|
+
const revised = { ...afterFirst, animations: animationOnly().animations }
|
|
317
|
+
const second = openAiProposal(afterFirst, { source: JSON.stringify(revised) } as any)
|
|
318
|
+
assert.deepEqual(
|
|
319
|
+
[...second.picked],
|
|
320
|
+
['animations'],
|
|
321
|
+
'the second round compares against the accepted draft, not the original — already-taken changes are not offered again'
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
const afterSecond = applyProposal(afterFirst, revised as any, second.picked) as any
|
|
325
|
+
assert.equal(afterSecond.animations?.length, 1)
|
|
326
|
+
assert.ok(afterSecond.parts.some((p: any) => p.name === 'guard'), 'round one survives round two')
|
|
327
|
+
assert.deepEqual(afterSecond.base, current().base, 'and so does the refusal')
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
test('cancelling leaves the draft exactly as it was', () => {
|
|
331
|
+
const draft = current()
|
|
332
|
+
const session = openAiProposal(draft as any, { source: JSON.stringify(proposal()) } as any)
|
|
333
|
+
|
|
334
|
+
/* Cancelling is dropping the session. The draft is a separate value and must be untouched. */
|
|
335
|
+
assert.deepEqual(draft, current(), 'opening a candidate for review must not mutate the draft')
|
|
336
|
+
assert.ok(session.source !== (draft as any), 'and the session must hold its own copy')
|
|
337
|
+
|
|
338
|
+
/* Unpicking everything is the other way to refuse: the take button disables and nothing moves. */
|
|
339
|
+
let emptied = session
|
|
340
|
+
for (const key of [...session.picked]) emptied = toggleProposalChange(emptied, key)
|
|
341
|
+
assert.equal(emptied.picked.size, 0)
|
|
342
|
+
assert.deepEqual(applyProposal(draft as any, proposal() as any, emptied.picked) as any, {
|
|
343
|
+
...draft,
|
|
344
|
+
parts: draft.parts
|
|
345
|
+
}, 'with nothing picked the result is the draft itself')
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
/*
|
|
349
|
+
* ── The cases the four tests above did not reach ─────────────────────────────
|
|
350
|
+
*
|
|
351
|
+
* Those covered the happy sequence. What follows are the ones an author actually hits and that a
|
|
352
|
+
* happy-path test never touches: refusing a deletion, a candidate that takes movement away, the
|
|
353
|
+
* draft moving under an open candidate, and every change kind surviving being applied rather than
|
|
354
|
+
* merely being listed. The animation defect was in exactly that gap — the kind was listed nowhere,
|
|
355
|
+
* so nothing that only listed kinds could have caught it.
|
|
356
|
+
*/
|
|
357
|
+
|
|
358
|
+
const withAnimation = () => ({ ...current(), animations: animationOnly().animations })
|
|
359
|
+
|
|
360
|
+
test('refusing a deletion keeps the part, and taking it removes it', () => {
|
|
361
|
+
const dropsMotor = { ...current(), parts: current().parts.filter(p => p.name !== 'motor') }
|
|
362
|
+
|
|
363
|
+
const changes = diffProposal(current() as any, dropsMotor as any)
|
|
364
|
+
assert.deepEqual(changes.map(c => `${c.kind}:${c.key}`), ['removed:motor'])
|
|
365
|
+
|
|
366
|
+
const refused = applyProposal(current() as any, dropsMotor as any, new Set()) as any
|
|
367
|
+
assert.ok(refused.parts.some((p: any) => p.name === 'motor'), 'an unpicked removal must not delete anything')
|
|
368
|
+
|
|
369
|
+
const taken = applyProposal(current() as any, dropsMotor as any, new Set(['motor'])) as any
|
|
370
|
+
assert.ok(!taken.parts.some((p: any) => p.name === 'motor'))
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
test('a candidate that takes movement away is shown, not applied behind the author', () => {
|
|
374
|
+
/* The reverse direction of the defect: the draft has clips and the candidate comes back without. */
|
|
375
|
+
const stripped = { ...withAnimation(), animations: undefined }
|
|
376
|
+
|
|
377
|
+
const changes = diffProposal(withAnimation() as any, stripped as any)
|
|
378
|
+
assert.deepEqual(changes.map(c => c.kind), ['animations'], 'losing the clips has to appear as a change')
|
|
379
|
+
|
|
380
|
+
const refused = applyProposal(withAnimation() as any, stripped as any, new Set()) as any
|
|
381
|
+
assert.equal(refused.animations?.length, 1, 'unpicked, the clips stay')
|
|
382
|
+
|
|
383
|
+
const taken = applyProposal(withAnimation() as any, stripped as any, new Set(['animations'])) as any
|
|
384
|
+
assert.equal(taken.animations, undefined, 'picked, they go — but only because the author said so')
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
test('every change kind survives being applied, not just being listed', () => {
|
|
388
|
+
const before = { ...current(), placement: 'floor', capabilities: ['Holdable'], styleKit: 'plain' }
|
|
389
|
+
const after = {
|
|
390
|
+
...current(),
|
|
391
|
+
placement: 'ceiling',
|
|
392
|
+
capabilities: ['Holdable', 'FlowNode'],
|
|
393
|
+
styleKit: 'factory',
|
|
394
|
+
detailLevel: 'L',
|
|
395
|
+
base: { x: 120, y: 100, z: 100 },
|
|
396
|
+
animations: animationOnly().animations
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const all = new Set(diffProposal(before as any, after as any).map(c => c.key))
|
|
400
|
+
const taken = applyProposal(before as any, after as any, all) as any
|
|
401
|
+
|
|
402
|
+
assert.equal(taken.placement, 'ceiling')
|
|
403
|
+
assert.deepEqual(taken.capabilities, ['Holdable', 'FlowNode'])
|
|
404
|
+
assert.equal(taken.styleKit, 'factory')
|
|
405
|
+
assert.equal(taken.detailLevel, 'L')
|
|
406
|
+
assert.deepEqual(taken.base, { x: 120, y: 100, z: 100 })
|
|
407
|
+
assert.equal(taken.animations?.length, 1)
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
test('each figure-level change can be taken on its own without dragging the others along', () => {
|
|
411
|
+
const before = { ...current(), placement: 'floor', capabilities: ['Holdable'] }
|
|
412
|
+
const after = {
|
|
413
|
+
...current(),
|
|
414
|
+
placement: 'ceiling',
|
|
415
|
+
capabilities: ['Holdable', 'FlowNode'],
|
|
416
|
+
animations: animationOnly().animations
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
for (const only of ['placement', 'capabilities', 'animations']) {
|
|
420
|
+
const taken = applyProposal(before as any, after as any, new Set([only])) as any
|
|
421
|
+
assert.equal(taken.placement, only === 'placement' ? 'ceiling' : 'floor', `${only}: placement leaked`)
|
|
422
|
+
assert.equal(
|
|
423
|
+
taken.capabilities.length,
|
|
424
|
+
only === 'capabilities' ? 2 : 1,
|
|
425
|
+
`${only}: capabilities leaked`
|
|
426
|
+
)
|
|
427
|
+
assert.equal(taken.animations === undefined, only !== 'animations', `${only}: animations leaked`)
|
|
428
|
+
}
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
test('taking part of a candidate and then asking again offers only what is still different', () => {
|
|
432
|
+
const after = {
|
|
433
|
+
...current(),
|
|
434
|
+
placement: 'ceiling',
|
|
435
|
+
animations: animationOnly().animations,
|
|
436
|
+
parts: [...current().parts, proposal().parts[1]]
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/* The author takes the movement only. */
|
|
440
|
+
const firstRound = applyProposal(current() as any, after as any, new Set(['animations'])) as any
|
|
441
|
+
assert.equal(firstRound.animations?.length, 1)
|
|
442
|
+
assert.equal(firstRound.placement, undefined)
|
|
443
|
+
|
|
444
|
+
/* Asking again with the same candidate must still offer what was left behind — and not movement. */
|
|
445
|
+
const second = openAiProposal(firstRound, { source: JSON.stringify(after) } as any)
|
|
446
|
+
assert.deepEqual([...second.picked].sort(), ['guard', 'placement'])
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
test('a malformed candidate is refused at the session boundary, not half-opened', () => {
|
|
450
|
+
for (const bad of ['{"type":"MIXER"}', '{"parts":"not an array"}', 'not json at all']) {
|
|
451
|
+
assert.throws(
|
|
452
|
+
() => openAiProposal(current() as any, { source: bad } as any),
|
|
453
|
+
'a candidate without a usable parts array must not open a review session'
|
|
454
|
+
)
|
|
455
|
+
}
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
test('an identical candidate leaves nothing to decide', () => {
|
|
459
|
+
const same = openAiProposal(current() as any, { source: JSON.stringify(current()) } as any)
|
|
460
|
+
assert.equal(same.picked.size, 0, 'no differences means no picks')
|
|
461
|
+
assert.deepEqual(
|
|
462
|
+
applyProposal(current() as any, current() as any, same.picked) as any,
|
|
463
|
+
current(),
|
|
464
|
+
'and applying it changes nothing'
|
|
465
|
+
)
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
test('taking a candidate twice does not compound — the second time there is nothing left', () => {
|
|
469
|
+
const once = applyProposal(current() as any, proposal() as any) as any
|
|
470
|
+
const twice = applyProposal(once, proposal() as any) as any
|
|
471
|
+
assert.deepEqual(twice, once, 'apply has to be idempotent, or a double click duplicates parts')
|
|
472
|
+
assert.equal(
|
|
473
|
+
once.parts.filter((p: any) => p.name === 'guard').length,
|
|
474
|
+
1,
|
|
475
|
+
'the added part must not appear twice'
|
|
476
|
+
)
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
test('part names are matched exactly — a renamed part is an addition plus a removal', () => {
|
|
480
|
+
const renamed = {
|
|
481
|
+
...current(),
|
|
482
|
+
parts: current().parts.map(p => (p.name === 'motor' ? { ...p, name: 'Motor' } : p))
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const kinds = diffProposal(current() as any, renamed as any).map(c => `${c.kind}:${c.key}`).sort()
|
|
486
|
+
assert.deepEqual(kinds, ['added:Motor', 'removed:motor'], 'case differences are different parts — bindings are exact')
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
/*
|
|
490
|
+
* ── The three properties this path has to keep, stated as properties ─────────
|
|
491
|
+
*
|
|
492
|
+
* Everything above is a case. Cases are written by hand and a hand-written list stops where its
|
|
493
|
+
* author stopped thinking — which is how a whole feature (movement) went unreachable while every
|
|
494
|
+
* test stayed green. These are closed over the format instead: they read what a FigureSource may
|
|
495
|
+
* contain and assert something that must hold for all of it, so the format growing is what makes
|
|
496
|
+
* them fail.
|
|
497
|
+
*
|
|
498
|
+
* They are also the contract another authoring surface would have to meet. Nothing below knows
|
|
499
|
+
* about figures specifically — only that there is a canonical document, a candidate for it, and a
|
|
500
|
+
* person deciding between the two.
|
|
501
|
+
*/
|
|
502
|
+
|
|
503
|
+
/** 형식이 선언한 저작 칸. 손으로 적지 않는다 — 형식이 자라면 이 시험도 같이 자란다. */
|
|
504
|
+
function figureFields(): string[] {
|
|
505
|
+
/* 패키지가 호이스팅되면 이 패키지 아래에 없다. 해석으로 찾는다 — 경로를 적어 두지 않는다. */
|
|
506
|
+
const entry = createRequire(import.meta.url).resolve('@hatiolab/figure-model/package.json')
|
|
507
|
+
const source = readFileSync(entry.replace(/package\.json$/, 'dist/types.d.ts'), 'utf8')
|
|
508
|
+
const body = source.match(/interface FigureSource \{([\s\S]*?)\n\}/)
|
|
509
|
+
assert.ok(body, 'FigureSource 를 형식에서 못 찾았다')
|
|
510
|
+
return [...body![1].matchAll(/^ {4}([a-zA-Z]+)\??:/gm)].map(m => m[1]!)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
test('구조 · 저작 가능한 모든 칸은 검토를 지난다 — 조용히 지나가는 칸이 없다', () => {
|
|
514
|
+
/*
|
|
515
|
+
`type` 은 저장 식별자라 후보가 바꿔도 받지 않고, `parts` 는 부품 단위로 따로 센다.
|
|
516
|
+
`version` 은 저장할 때 서버가 찍는다. 나머지는 전부 사람이 보고 고를 수 있어야 한다.
|
|
517
|
+
*/
|
|
518
|
+
const decidedElsewhere = new Set(['type', 'parts', 'version'])
|
|
519
|
+
const mustBeReviewable = figureFields().filter(f => !decidedElsewhere.has(f))
|
|
520
|
+
|
|
521
|
+
const before: any = { ...current() }
|
|
522
|
+
const after: any = { ...current() }
|
|
523
|
+
/* 각 칸을 실제로 다르게 만들어 본다 — 이름만 맞추는 시험은 값이 안 옮겨져도 통과한다. */
|
|
524
|
+
const sample: Record<string, [unknown, unknown]> = {
|
|
525
|
+
base: [{ x: 100, y: 100, z: 100 }, { x: 120, y: 100, z: 100 }],
|
|
526
|
+
placement: ['floor', 'ceiling'],
|
|
527
|
+
detailLevel: ['M', 'L'],
|
|
528
|
+
styleKit: ['plain', 'factory'],
|
|
529
|
+
animations: [undefined, animationOnly().animations],
|
|
530
|
+
capabilities: [['Holdable'], ['Holdable', 'FlowNode']]
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const unsampled = mustBeReviewable.filter(f => !(f in sample))
|
|
534
|
+
assert.deepEqual(unsampled, [], '형식에 칸이 늘었다 — 여기에 다른 값 한 쌍을 더해 검토를 확인하라')
|
|
535
|
+
|
|
536
|
+
for (const field of mustBeReviewable) {
|
|
537
|
+
const [b, a] = sample[field]!
|
|
538
|
+
before[field] = b
|
|
539
|
+
after[field] = a
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const changes = diffProposal(before, after)
|
|
543
|
+
const shown = new Set(changes.map(c => c.key))
|
|
544
|
+
const hidden = mustBeReviewable.filter(f => !shown.has(f))
|
|
545
|
+
assert.deepEqual(hidden, [], '바뀌었는데 사람에게 안 보이는 칸이다 — 받아도 반영되지 않고 아무 말도 안 난다')
|
|
546
|
+
|
|
547
|
+
const taken = applyProposal(before, after, new Set(mustBeReviewable)) as any
|
|
548
|
+
for (const field of mustBeReviewable) {
|
|
549
|
+
assert.deepEqual(taken[field], sample[field]![1], `${field}: 목록에는 떴는데 받아도 안 옮겨졌다`)
|
|
550
|
+
}
|
|
551
|
+
})
|
|
552
|
+
|
|
553
|
+
test('구조 · 고른 것만 옮겨진다 — 어떤 부분집합에도', () => {
|
|
554
|
+
const before: any = { ...current(), placement: 'floor', capabilities: ['Holdable'] }
|
|
555
|
+
const after: any = {
|
|
556
|
+
...current(),
|
|
557
|
+
placement: 'ceiling',
|
|
558
|
+
capabilities: ['Holdable', 'FlowNode'],
|
|
559
|
+
animations: animationOnly().animations,
|
|
560
|
+
parts: [...current().parts, proposal().parts[1]]
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const keys = diffProposal(before, after).map(c => c.key)
|
|
564
|
+
/* 부분집합 전수. 칸이 늘면 경우도 자동으로 는다. */
|
|
565
|
+
for (let mask = 0; mask < 1 << keys.length; mask++) {
|
|
566
|
+
const picked = new Set(keys.filter((_, i) => mask & (1 << i)))
|
|
567
|
+
const taken = applyProposal(before, after, picked) as any
|
|
568
|
+
const left = diffProposal(taken, after).map(c => c.key)
|
|
569
|
+
|
|
570
|
+
for (const key of keys) {
|
|
571
|
+
const wasPicked = picked.has(key)
|
|
572
|
+
assert.equal(
|
|
573
|
+
left.includes(key),
|
|
574
|
+
!wasPicked,
|
|
575
|
+
`고른 것은 남으면 안 되고 안 고른 것은 남아야 한다 (${[...picked].join(',') || '없음'} 에서 ${key})`
|
|
576
|
+
)
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
test('구조 · 검토는 정본을 건드리지 않는다 — 받기 전까지', () => {
|
|
582
|
+
const draft = Object.freeze({
|
|
583
|
+
...current(),
|
|
584
|
+
parts: Object.freeze(current().parts.map(p => Object.freeze(p)))
|
|
585
|
+
}) as any
|
|
586
|
+
const candidate = { ...current(), animations: animationOnly().animations, placement: 'ceiling' } as any
|
|
587
|
+
|
|
588
|
+
/* 얼린 것에 쓰면 던진다. 던지지 않는다는 것이 곧 안 건드렸다는 것이다. */
|
|
589
|
+
const session = openAiProposal(draft, { source: JSON.stringify(candidate) } as any)
|
|
590
|
+
diffProposal(draft, candidate)
|
|
591
|
+
applyProposal(draft, candidate, session.picked)
|
|
592
|
+
toggleProposalChange(session, 'placement')
|
|
593
|
+
|
|
594
|
+
assert.deepEqual(draft.animations, undefined, '검토를 열었다고 정본이 달라지면 안 된다')
|
|
595
|
+
assert.equal(draft.placement, undefined)
|
|
596
|
+
})
|
|
597
|
+
|
|
598
|
+
test('구조 · 같은 후보를 두 번 받아도 한 번 받은 것과 같다', () => {
|
|
599
|
+
const after: any = { ...current(), animations: animationOnly().animations, placement: 'ceiling' }
|
|
600
|
+
const once = applyProposal(current() as any, after, undefined) as any
|
|
601
|
+
const twice = applyProposal(once, after, undefined) as any
|
|
602
|
+
|
|
603
|
+
assert.deepEqual(twice, once, '두 번 눌러도 달라지면 안 된다')
|
|
604
|
+
assert.deepEqual(diffProposal(once, after), [], '한 번 받으면 남는 변경이 없다')
|
|
605
|
+
})
|
package/test/expect.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* A thin `expect` over `node:assert`, so Jest-shaped tests can run here.
|
|
3
|
+
*
|
|
4
|
+
* ── Why this exists ──────────────────────────────────────────────────────────
|
|
5
|
+
* This package runs `node --test test/*.test.ts`. Two suites were written in Jest's shape and
|
|
6
|
+
* left under `client/modeller/`, where **neither runner picked them up** — `npm test` does not
|
|
7
|
+
* look there, and the repository's Jest is not configured for this package. They had not executed
|
|
8
|
+
* since they were written.
|
|
9
|
+
*
|
|
10
|
+
* That mattered more than the count. `figure-source.ts` carries a comment saying the round-trip
|
|
11
|
+
* guard "now reads the field list the format declares rather than trusting the fixture", and that
|
|
12
|
+
* guard lives in one of those files. The source leaned on a test that never ran.
|
|
13
|
+
*
|
|
14
|
+
* Rewriting 662 lines of assertions by hand would have changed what they mean in places nobody
|
|
15
|
+
* would notice. Eight matchers cover both files, so the cheaper and safer move is to give them
|
|
16
|
+
* the eight.
|
|
17
|
+
*
|
|
18
|
+
* Only add a matcher here when a test needs it. This is a bridge for suites that already exist,
|
|
19
|
+
* not a second assertion library for new ones — new tests in this package use `node:assert`
|
|
20
|
+
* directly, like every other file under `test/`.
|
|
21
|
+
*/
|
|
22
|
+
import assert from 'node:assert/strict'
|
|
23
|
+
|
|
24
|
+
export interface Expectation {
|
|
25
|
+
toBe(expected: unknown): void
|
|
26
|
+
toEqual(expected: unknown): void
|
|
27
|
+
toMatchObject(expected: Record<string, unknown>): void
|
|
28
|
+
toHaveLength(expected: number): void
|
|
29
|
+
toBeUndefined(): void
|
|
30
|
+
toBeGreaterThan(expected: number): void
|
|
31
|
+
toBeLessThan(expected: number): void
|
|
32
|
+
toBeLessThanOrEqual(expected: number): void
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function expect(actual: any): Expectation {
|
|
36
|
+
return {
|
|
37
|
+
/* Jest's toBe is Object.is; assert.strictEqual is the same comparison. */
|
|
38
|
+
toBe: expected => assert.strictEqual(actual, expected),
|
|
39
|
+
toEqual: expected => assert.deepStrictEqual(actual, expected),
|
|
40
|
+
toMatchObject: expected => {
|
|
41
|
+
for (const [key, value] of Object.entries(expected)) {
|
|
42
|
+
assert.deepStrictEqual(
|
|
43
|
+
actual?.[key],
|
|
44
|
+
value,
|
|
45
|
+
`${key}: ${JSON.stringify(actual?.[key])} !== ${JSON.stringify(value)}`
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
toHaveLength: expected => assert.strictEqual(actual?.length, expected, `length ${actual?.length} !== ${expected}`),
|
|
50
|
+
toBeUndefined: () => assert.strictEqual(actual, undefined),
|
|
51
|
+
toBeGreaterThan: expected => assert.ok(actual > expected, `${actual} is not greater than ${expected}`),
|
|
52
|
+
toBeLessThan: expected => assert.ok(actual < expected, `${actual} is not less than ${expected}`),
|
|
53
|
+
toBeLessThanOrEqual: expected => assert.ok(actual <= expected, `${actual} is not <= ${expected}`)
|
|
54
|
+
}
|
|
55
|
+
}
|