@things-factory/figure-ui 10.1.79 → 10.1.82

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 (145) hide show
  1. package/client/figure-assistant.ts +2 -2
  2. package/client/graphql/index.ts +99 -10
  3. package/client/graphql/reached.ts +25 -0
  4. package/client/graphql/store.ts +27 -4
  5. package/client/modeller/ai-proposal.ts +74 -9
  6. package/client/modeller/align.ts +2 -3
  7. package/client/modeller/canvas.ts +18 -5
  8. package/client/modeller/command-preview.ts +113 -0
  9. package/client/modeller/editor.ts +826 -54
  10. package/client/modeller/fields.ts +12 -4
  11. package/client/modeller/figure-facts.ts +2 -2
  12. package/client/modeller/figure-first-save.ts +36 -14
  13. package/client/modeller/figure-history-panel.ts +133 -14
  14. package/client/modeller/figure-links.ts +385 -68
  15. package/client/modeller/figure-parts.ts +56 -2
  16. package/client/modeller/figure-preview.ts +19 -1
  17. package/client/modeller/figure-properties.ts +3 -3
  18. package/client/modeller/figure-revert-choice.ts +49 -0
  19. package/client/modeller/first-save.ts +16 -0
  20. package/client/modeller/gizmo.ts +0 -16
  21. package/client/modeller/links.ts +142 -15
  22. package/client/modeller/motion-ends.ts +83 -0
  23. package/client/modeller/occupancy.ts +28 -2
  24. package/client/modeller/product-session.ts +154 -18
  25. package/client/modeller/refusals.ts +15 -0
  26. package/client/modeller/revert-request.ts +26 -0
  27. package/client/modeller/side-panel-styles.ts +12 -0
  28. package/client/modeller/stage-model.ts +25 -3
  29. package/client/pages/figure-modeller-page.ts +68 -29
  30. package/dist-client/figure-assistant.js +2 -2
  31. package/dist-client/figure-assistant.js.map +1 -1
  32. package/dist-client/graphql/index.d.ts +39 -2
  33. package/dist-client/graphql/index.js +63 -9
  34. package/dist-client/graphql/index.js.map +1 -1
  35. package/dist-client/graphql/reached.d.ts +10 -0
  36. package/dist-client/graphql/reached.js +28 -0
  37. package/dist-client/graphql/reached.js.map +1 -0
  38. package/dist-client/graphql/store.js +27 -4
  39. package/dist-client/graphql/store.js.map +1 -1
  40. package/dist-client/modeller/ai-proposal.d.ts +31 -2
  41. package/dist-client/modeller/ai-proposal.js +65 -6
  42. package/dist-client/modeller/ai-proposal.js.map +1 -1
  43. package/dist-client/modeller/align.d.ts +1 -1
  44. package/dist-client/modeller/align.js +0 -2
  45. package/dist-client/modeller/align.js.map +1 -1
  46. package/dist-client/modeller/canvas.d.ts +11 -0
  47. package/dist-client/modeller/canvas.js +33 -4
  48. package/dist-client/modeller/canvas.js.map +1 -1
  49. package/dist-client/modeller/clips.d.ts +1 -1
  50. package/dist-client/modeller/command-preview.d.ts +35 -0
  51. package/dist-client/modeller/command-preview.js +95 -0
  52. package/dist-client/modeller/command-preview.js.map +1 -0
  53. package/dist-client/modeller/editor.d.ts +113 -1
  54. package/dist-client/modeller/editor.js +850 -50
  55. package/dist-client/modeller/editor.js.map +1 -1
  56. package/dist-client/modeller/fields.d.ts +3 -2
  57. package/dist-client/modeller/fields.js +10 -3
  58. package/dist-client/modeller/fields.js.map +1 -1
  59. package/dist-client/modeller/figure-facts.js +2 -2
  60. package/dist-client/modeller/figure-facts.js.map +1 -1
  61. package/dist-client/modeller/figure-first-save.d.ts +3 -4
  62. package/dist-client/modeller/figure-first-save.js +41 -14
  63. package/dist-client/modeller/figure-first-save.js.map +1 -1
  64. package/dist-client/modeller/figure-history-panel.d.ts +31 -0
  65. package/dist-client/modeller/figure-history-panel.js +130 -14
  66. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  67. package/dist-client/modeller/figure-links.d.ts +51 -1
  68. package/dist-client/modeller/figure-links.js +392 -72
  69. package/dist-client/modeller/figure-links.js.map +1 -1
  70. package/dist-client/modeller/figure-parts.d.ts +7 -0
  71. package/dist-client/modeller/figure-parts.js +55 -2
  72. package/dist-client/modeller/figure-parts.js.map +1 -1
  73. package/dist-client/modeller/figure-preview.d.ts +6 -0
  74. package/dist-client/modeller/figure-preview.js +22 -1
  75. package/dist-client/modeller/figure-preview.js.map +1 -1
  76. package/dist-client/modeller/figure-properties.js +3 -3
  77. package/dist-client/modeller/figure-properties.js.map +1 -1
  78. package/dist-client/modeller/figure-revert-choice.d.ts +17 -0
  79. package/dist-client/modeller/figure-revert-choice.js +59 -0
  80. package/dist-client/modeller/figure-revert-choice.js.map +1 -0
  81. package/dist-client/modeller/first-save.d.ts +7 -0
  82. package/dist-client/modeller/first-save.js +17 -0
  83. package/dist-client/modeller/first-save.js.map +1 -1
  84. package/dist-client/modeller/gizmo.d.ts +0 -6
  85. package/dist-client/modeller/gizmo.js +0 -18
  86. package/dist-client/modeller/gizmo.js.map +1 -1
  87. package/dist-client/modeller/links.d.ts +49 -4
  88. package/dist-client/modeller/links.js +141 -15
  89. package/dist-client/modeller/links.js.map +1 -1
  90. package/dist-client/modeller/motion-ends.d.ts +12 -0
  91. package/dist-client/modeller/motion-ends.js +65 -0
  92. package/dist-client/modeller/motion-ends.js.map +1 -0
  93. package/dist-client/modeller/occupancy.d.ts +14 -1
  94. package/dist-client/modeller/occupancy.js +14 -1
  95. package/dist-client/modeller/occupancy.js.map +1 -1
  96. package/dist-client/modeller/product-session.d.ts +43 -2
  97. package/dist-client/modeller/product-session.js +146 -12
  98. package/dist-client/modeller/product-session.js.map +1 -1
  99. package/dist-client/modeller/refusals.d.ts +13 -0
  100. package/dist-client/modeller/refusals.js +16 -0
  101. package/dist-client/modeller/refusals.js.map +1 -1
  102. package/dist-client/modeller/revert-request.d.ts +16 -0
  103. package/dist-client/modeller/revert-request.js +11 -0
  104. package/dist-client/modeller/revert-request.js.map +1 -0
  105. package/dist-client/modeller/side-panel-styles.js +12 -0
  106. package/dist-client/modeller/side-panel-styles.js.map +1 -1
  107. package/dist-client/modeller/stage-model.d.ts +18 -2
  108. package/dist-client/modeller/stage-model.js +21 -3
  109. package/dist-client/modeller/stage-model.js.map +1 -1
  110. package/dist-client/pages/figure-modeller-page.d.ts +8 -1
  111. package/dist-client/pages/figure-modeller-page.js +72 -34
  112. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  113. package/dist-client/route.d.ts +1 -1
  114. package/dist-client/tsconfig.tsbuildinfo +1 -1
  115. package/package.json +6 -6
  116. package/test/ai-proposal.test.ts +2 -2
  117. package/test/align.test.ts +50 -5
  118. package/test/browser/acceptance-page.js +187 -0
  119. package/test/browser/build.mjs +5 -1
  120. package/test/browser/revert-page.js +47 -0
  121. package/test/browser/serve-acceptance.mjs +85 -0
  122. package/test/browser/serve-revert.mjs +119 -0
  123. package/test/clip-ai-proposal.test.ts +104 -0
  124. package/test/clip-consent.test.ts +88 -0
  125. package/test/command-preview.test.ts +288 -0
  126. package/test/fields.test.ts +4 -3
  127. package/test/figure-assistant-surface.test.ts +4 -1
  128. package/test/first-minute.test.ts +96 -0
  129. package/test/first-save-suggest.test.ts +42 -0
  130. package/test/gizmo.test.ts +20 -16
  131. package/test/joint-fan.test.ts +2 -1
  132. package/test/layout.test.ts +9 -2
  133. package/test/links.test.ts +7 -4
  134. package/test/proposal-record.test.ts +126 -0
  135. package/test/release-entry.test.ts +28 -0
  136. package/test/release-panel.test.ts +3 -2
  137. package/test/resize-with-placement.test.ts +225 -0
  138. package/test/revert-choice.test.ts +39 -0
  139. package/test/revert-keeping.test.ts +102 -0
  140. package/test/save-landed.test.ts +198 -0
  141. package/translations/en.json +222 -10
  142. package/translations/ja.json +223 -11
  143. package/translations/ko.json +223 -11
  144. package/translations/ms.json +222 -10
  145. package/translations/zh.json +223 -11
@@ -0,0 +1,96 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { readFileSync } from 'node:fs'
4
+ import { addFigureBox, applyFigureAuthoring, applyFigureProposal, createFigureAsset, planFigureResize, proposeFigureOccupancy } from '@hatiolab/figure-model'
5
+ import { partSummaryOf } from '../client/modeller/links.js'
6
+
7
+ /*
8
+ * ADR-0097 B, the first minute (designer 2026-09-27, U04 · U05 · U09 · U11): a new figure asks what to start with; a
9
+ * selected part says what it is, how its size answers and what holds it; the side tabs say their names; and a narrow
10
+ * screen gives the canvas room by folding the parts list, said once.
11
+ */
12
+
13
+ const read = (path: string) => readFileSync(new URL(path, import.meta.url), 'utf-8')
14
+ const say = (key: string, params: Record<string, string> = {}) => `${key}${Object.keys(params).length ? JSON.stringify(params) : ''}`
15
+
16
+ test('a selected part in three lines: its kind, how its size answers, what holds it', () => {
17
+ let a = addFigureBox(createFigureAsset('UX'), { id: 'box', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
18
+ a = applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(a), placement: 'floor' })
19
+ assert.deepEqual(partSummaryOf(a, 'box', say), { kind: 'figure.label.primitive-cube', size: 'figure.text.summary-size-fixed', relation: 'figure.text.summary-free' })
20
+ const only = applyFigureProposal(a, planFigureResize(a, { part: 'box', axes: ['x'] }).steps)
21
+ const s = partSummaryOf(only, 'box', say)
22
+ assert.equal(s.size, 'figure.text.summary-follows{"axes":"figure.label.resize-x"}')
23
+ assert.match(s.relation, /^figure\.text\.held-on-plane/)
24
+ assert.match(s.relation, /"gap":"0"/)
25
+ })
26
+
27
+ test('a new figure asks what to start with -- a shape, a recipe, an AI draft where offered -- and adds nothing by itself', () => {
28
+ const editor = read('../client/modeller/editor.ts')
29
+ const start = editor.slice(editor.indexOf(' private renderStart() {'), editor.indexOf(' /**\n * Which part the panel speaks of'))
30
+ assert.ok(start.includes("['box', 'cylinder', 'plate']") && start.includes('@click=${() => this.add(kind)}'), 'a shape, on the author\'s click')
31
+ assert.ok(start.includes('this.catalogOpen = true'), 'a recipe')
32
+ assert.ok(start.includes('${this.aiDraft') && start.includes("new CustomEvent('ask-ai-draft'"), 'an AI draft, only where the page offers it')
33
+ assert.ok(editor.includes('${view.geometry.length === 0 && !unavailable ? this.renderStart() : nothing}'), 'only while the figure is empty')
34
+ assert.ok(editor.includes('<summary>${i18next.t(\'figure.label.figure-info-later\')}</summary>'), 'the figure\'s own settings wait, folded')
35
+ const page = read('../client/pages/figure-modeller-page.ts')
36
+ assert.ok(page.includes("aiDraft @ask-ai-draft=${() => askAssistant({ draft: i18next.t('figure.text.ai-draft-request') })}"), 'the app puts a draft request in the dock, not sent')
37
+ const parts = read('../client/modeller/figure-parts.ts')
38
+ assert.ok(parts.includes('if (!b || (!this.parts.length && !b.level)) return nothing'), 'the detail level comes after the first part (U09)')
39
+ })
40
+
41
+ test('the side tabs say their names, in every language', () => {
42
+ const editor = read('../client/modeller/editor.ts')
43
+ assert.ok(editor.includes("const name = i18next.t('figure.label.tab-short-' + tab)"))
44
+ assert.ok(editor.includes('aria-label=${name}'), 'its accessible name is the name it shows')
45
+ assert.ok(editor.includes('<span tab-label aria-hidden="true">${name}</span>'))
46
+ for (const lang of ['ko', 'en', 'ja', 'zh', 'ms']) {
47
+ const table = JSON.parse(read(`../translations/${lang}.json`))
48
+ for (const tab of ['properties', 'capability', 'motion', 'verdict', 'view', 'history']) assert.ok(table[`figure.label.tab-short-${tab}`], `${lang} ${tab}`)
49
+ }
50
+ })
51
+
52
+ test('narrow, the parts list folds for the canvas, said once, and stays open once the author opens it', () => {
53
+ const editor = read('../client/modeller/editor.ts')
54
+ const fit = editor.slice(editor.indexOf(' private fitPanesTo(width: number) {'), editor.indexOf(' /**\n * Refusals and notices'))
55
+ assert.ok(fit.includes('if (this.foldedForWidth && !panes.foldStart) this.keptOpenByAuthor = true'), 'opened by the author, left open')
56
+ assert.ok(fit.includes("notify({ level: 'info', message: i18next.t('figure.text.parts-panel-folded-narrow') })"), 'said')
57
+ assert.ok(fit.includes('if (this.foldedForWidth && panes.foldStart) panes.foldStart = false'), 'wide again, opened again')
58
+ assert.ok(editor.includes('private static readonly FOLD_START_UNDER = 1100'))
59
+ })
60
+
61
+ test('a palette part\'s own sizes are its shape\'s fields, not a rule\'s: no "set by a rule" size row for them', () => {
62
+ const editor = read('../client/modeller/editor.ts')
63
+ assert.ok(editor.includes("sizeRuled: (['x', 'y', 'z'] as const).every(axis => followOf(parsed, ownerOf(this.selected), axis).of !== 'own')"))
64
+ assert.ok(editor.includes('if (!held?.sizeRuled ||'))
65
+ })
66
+
67
+ /* Designer 2026-09-27: the placement is chosen before any part, as the figure's own setting -- no room, no part made up. */
68
+ test('the start card and the figure panel choose the placement as the figure\'s own setting', async () => {
69
+ const { createFigureAsset, applyFigureAuthoring } = await import('@hatiolab/figure-model')
70
+ const hung = applyFigureAuthoring(createFigureAsset('New'), { kind: 'set-placement', placement: 'ceiling' })
71
+ assert.equal(hung.placement, 'ceiling')
72
+ assert.equal(hung.occupancy, undefined)
73
+ const editor = read('../client/modeller/editor.ts')
74
+ assert.ok(editor.includes("this.linkCommand({ kind: 'set-placement', placement: one })"), 'on the start card')
75
+ const properties = read('../client/modeller/figure-properties.ts')
76
+ assert.ok(properties.includes("this.command({ kind: 'set-placement', placement: select.value as Placement })"), 'in the figure panel')
77
+ assert.ok(!properties.includes('?disabled=${!facts.parts.length}\n .value=${live(current)}'), 'not waiting for a part')
78
+ })
79
+
80
+ /* B.1 and the designer's items (2026-09-27): each target's panel is found as it was left; the fastening form says which it is. */
81
+ test('the panel remembers its scroll and folds per model and part, and forgets them for another model', () => {
82
+ const editor = read('../client/modeller/editor.ts')
83
+ assert.ok(editor.includes("if (panel) this.panelMemory.set(was || '@model', { top: panel.scrollTop, open: this.foldsOf(panel).map(d => d.open) })"))
84
+ assert.ok(editor.includes('panel.scrollTop = kept?.top ?? 0'))
85
+ assert.ok(editor.includes('this.panelMemory.clear()'), 'another model starts clean')
86
+ assert.ok(editor.includes("position: sticky;"), 'the path stays in view whatever the scroll')
87
+ })
88
+
89
+ test('the fastening form says whether it edits the current relation or fastens anew', () => {
90
+ const links = read('../client/modeller/figure-links.ts')
91
+ assert.ok(links.includes("i18next.t('figure.text.fasten-editing', {"))
92
+ assert.ok(links.includes("i18next.t(current ? 'figure.button.fasten-update' : 'figure.button.fasten')"))
93
+ const ko = JSON.parse(read('../translations/ko.json'))
94
+ assert.equal(ko['figure.text.fasten-new'], '새로 붙이기')
95
+ assert.equal(ko['figure.button.fasten-update'], '이 붙임 수정')
96
+ })
@@ -0,0 +1,42 @@
1
+ /*
2
+ * Designer 2026-09-27, Q-D3: the first save asks for the name first, with a valid identifier already offered -- for a
3
+ * Korean name too: a valid prefix and a random part, no transliteration guessed.
4
+ */
5
+ import { test } from 'node:test'
6
+ import assert from 'node:assert/strict'
7
+ import { readFileSync } from 'node:fs'
8
+ import { fileURLToPath } from 'node:url'
9
+ import { isPlaceholderType, TYPE_LENGTH, TYPE_PATTERN } from '@hatiolab/figure-model'
10
+
11
+ import { suggestTypeOf, releasableType } from '../client/modeller/first-save.js'
12
+
13
+ const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
14
+
15
+ test('an identifier is offered for any name: plain letters as they are, anything else a prefix and a random part', () => {
16
+ assert.equal(suggestTypeOf('Lift table', []), 'LIFT_TABLE')
17
+ assert.equal(suggestTypeOf('roller-conveyor 2', []), 'ROLLER_CONVEYOR_2')
18
+ for (const name of ['리프트 테이블', '새 Figure', '', '___', '2층 선반']) {
19
+ const made = suggestTypeOf(name, [])
20
+ assert.match(made, /^FIG_[0-9A-Z]{6}$/, name)
21
+ assert.ok(releasableType(made) && TYPE_PATTERN.test(made) && made.length >= TYPE_LENGTH.min && !isPlaceholderType(made))
22
+ }
23
+ /* Never one already used: a taken name falls back, a taken random part is drawn again. */
24
+ assert.match(suggestTypeOf('Lift table', ['LIFT_TABLE']), /^FIG_/)
25
+ let n = 0
26
+ const draws = [0, 0, 0, 0, 0, 0, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5]
27
+ assert.equal(suggestTypeOf('선반', ['FIG_000000'], () => draws[n++ % draws.length]!), 'FIG_IIIIII')
28
+ })
29
+
30
+ test('the first save asks the name first; the identifier offered stays through retries, and renaming does not change it', () => {
31
+ const popup = read('../client/modeller/figure-first-save.ts')
32
+ assert.ok(popup.indexOf('<label for="name">') < popup.indexOf('<label for="type">'), 'the name first')
33
+ assert.ok(/id="name"\s+autofocus/.test(popup), 'the name has the focus')
34
+ assert.ok(!popup.includes('this.type = this.suggested = suggestTypeOf(this.name, this.taken)\n }}'), 'typing a name does not redraw it')
35
+ assert.ok(popup.includes('if (!this.type || offeredAndTaken) this.type = this.suggested = suggestTypeOf(this.name, this.taken)'), 'offered again only when empty, or offered and taken')
36
+ const page = read('../client/pages/figure-modeller-page.ts')
37
+ assert.ok(page.includes('if (answer) this.row = { ...this.row, type: answer.type }'), 'a retry opens on the identifier answered before')
38
+ for (const lang of ['en', 'ja', 'ko', 'ms', 'zh']) {
39
+ const t = JSON.parse(read(`../translations/${lang}.json`))
40
+ assert.ok(t['figure.label.figure-identifier'] && t['figure.text.identifier-suggested'], lang)
41
+ }
42
+ })
@@ -140,33 +140,37 @@ test('원기둥 · 구도 크기 기즈모가 먹는다 — 단면은 원으로'
140
140
  })
141
141
 
142
142
  /*
143
- * 높이를 숫자로 적어도 아랫면이 그 자리에 있다 — 손잡이로 키울 때와 같게. 2200 을 적은 기둥이 바닥 아래로 933 mm
144
- * 묻혔다(09-26). 돌려 세운 부품은 그 높이가 위아래가 아니라 건드리지 않는다.
143
+ * 높이를 숫자로 적거나 손잡이로 키워도, 도형의 배치가 지키는 면이 그 자리에 있다 — figure-model 의 한 규칙
144
+ * (`figureSizeEditChanges`, ADR-0097). 전에는 화면이 제 산수로 배치와 상관없이 아랫면을 지켰다. 2200 을 적은 기둥이
145
+ * 바닥 아래로 933 mm 묻혔던 일(09-26)은 바닥 도형에서 그대로 막힌다. 돌려 세운 부품은 그 높이가 위아래가 아니라 건드리지 않는다.
145
146
  */
146
- test('높이 칸 — 아랫면을 지킨다, 손잡이처럼', async () => {
147
- const { keepBottomChanges } = await import('../client/modeller/gizmo.ts')
147
+ test('크기 칸과 손잡이 — 배치가 지키는 면을 지킨다, 커널의 한 규칙으로', async () => {
148
+ const { figureSizeEditChanges, applyFigureAuthoring, proposeFigureOccupancy } = await import('@hatiolab/figure-model')
148
149
  let asset = createFigureAsset('Post')
149
150
  asset = addFigureBox(asset, { id: 'post', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
150
151
  asset = addFigurePrimitive(asset, { id: 'drum', kind: 'cylinder', dimensions: { radius: 50, length: 100 }, position: { x: 0, y: 300, z: 0 } })
151
152
  asset = addFigurePrimitive(asset, { id: 'roller', kind: 'cylinder', dimensions: { radius: 30, length: 500 }, position: { x: 0, y: 30, z: 0 }, rotation: { x: 90, y: 0, z: 0 } })
152
- const bottom = (a: typeof asset, part: string) => {
153
+ const centreY = (a: typeof asset, part: string) => {
153
154
  const g = compileFigureAsset(a).evaluate().geometry.find(o => o.id === part)! as unknown as { pose: { t: number[] } }
154
155
  return g.pose.t[1]!
155
156
  }
156
- const grow = (part: string, input: string, value: number) => {
157
- const reach = gizmoReachOf(asset, part)
158
- const changes = [{ kind: 'set-design', input, unit: 'mm', value } as never, ...keepBottomChanges(part, reach, input, value, asset.designInputs)]
159
- return apply(asset, changes)
160
- }
157
+ const set = (input: string, value: number) => ({ kind: 'set-design', input, unit: 'mm', value }) as never
158
+ const grow = (a: typeof asset, part: string, input: string, value: number) => apply(a, figureSizeEditChanges(a, part, [set(input, value)]))
161
159
  const postH = gizmoReachOf(asset, 'post').sizes![1]
162
- const tall = grow('post', postH, 2200)
163
- assert.equal(bottom(tall, 'post') - 2200 / 2, 0, '기둥 아랫면이 바닥에')
160
+ assert.equal(centreY(grow(asset, 'post', postH, 2200), 'post') - 2200 / 2, 0, '바닥 도형(선언 전 기본): 기둥 아랫면이 바닥에')
164
161
  const drumH = gizmoReachOf(asset, 'drum').round!.along[0]!
165
- assert.equal(bottom(grow('drum', drumH, 300), 'drum') - 300 / 2, 300 - 100 / 2, '원기둥도 아랫면이 제자리')
162
+ assert.equal(centreY(grow(asset, 'drum', drumH, 300), 'drum') - 300 / 2, 300 - 100 / 2, '원기둥도 아랫면이 제자리')
166
163
  const rollerLength = gizmoReachOf(asset, 'roller').round!.along[0]!
167
- assert.deepEqual(keepBottomChanges('roller', gizmoReachOf(asset, 'roller'), rollerLength, 800, asset.designInputs), [], '누운 롤러의 길이는 위아래가 아니다')
168
- assert.deepEqual(keepBottomChanges('post', gizmoReachOf(asset, 'post'), gizmoReachOf(asset, 'post').sizes![0], 800, asset.designInputs), [], '폭은 가운데 그대로')
169
- assert.ok(read('../client/modeller/editor.ts').includes('keepBottomChanges('), '숫자 칸이 부른다')
164
+ assert.deepEqual(figureSizeEditChanges(asset, 'roller', [set(rollerLength, 800)]), [set(rollerLength, 800)], '누운 롤러의 길이는 위아래가 아니다')
165
+ assert.deepEqual(figureSizeEditChanges(asset, 'post', [set(gizmoReachOf(asset, 'post').sizes![0], 800)]), [set(gizmoReachOf(asset, 'post').sizes![0], 800)], '폭은 가운데 그대로')
166
+ /* 천장 도형이면 윗면을 지킨다 — 전에는 여기서도 아랫면을 지켜 천장을 뚫고 올라갔다. */
167
+ let hung = addFigureBox(createFigureAsset('Lamp'), { id: 'lamp', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: -50, z: 0 } })
168
+ hung = applyFigureAuthoring(hung, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(hung), placement: 'ceiling' })
169
+ assert.equal(centreY(grow(hung, 'lamp', gizmoReachOf(hung, 'lamp').sizes![1], 300), 'lamp') + 300 / 2, 0, '천장 도형: 윗면이 천장에')
170
+ const editor = read('../client/modeller/editor.ts')
171
+ assert.ok(editor.includes('figureSizeEditChanges(parseFigureAsset(this.session!.source()), part, [change])'), '숫자 칸이 부른다')
172
+ assert.ok(editor.includes('figureSizeEditChanges(asset, ownerOf(id), written)'), '손잡이도 부른다')
173
+ assert.ok(!read('../client/modeller/gizmo.ts').includes('keepBottomChanges'), '화면의 제 산수는 없다')
170
174
  })
171
175
 
172
176
  /* 손잡이는 키를 몰라도 찾는다 — 도구 막대에 옮기기 · 돌리기 · 크기, 제 숫자로 놓인 부품에만 (09-26). */
@@ -84,7 +84,8 @@ test('the editor draws it for the selected part, with the pose in hand; the canv
84
84
  assert.ok(editor.includes('.fan=${this.fanOf(asset as FigureAsset | undefined)}'))
85
85
  assert.ok(editor.includes('return jointFanOf(asset, this.selected, { ...this.currentInputs(), ...this.live.values })'), 'the fan reads the pose the session holds')
86
86
  const canvas = read('../client/modeller/canvas.ts')
87
- assert.ok(canvas.includes('{ ...fan, capturable: false, locked: true }'))
87
+ /* The fan, and what a command would do, are shown and never picked (ADR-0097 §5.5). */
88
+ assert.ok(canvas.includes('.map(c => ({ ...c, capturable: false, locked: true }))'))
88
89
  })
89
90
 
90
91
  /* ── Joints on a chain (user 2026-09-24: 「로봇암 axis와 관절의 각도 표현을 다시 검증해보라」) ───────── */
@@ -37,9 +37,16 @@ test('부품을 고르면 — 도형 전체의 탭(판정 · 이력)은 그 부
37
37
  assert.ok(renderBody.includes(from), from)
38
38
  })
39
39
 
40
- test('부품을 보고 있으면 도형 전체로 돌아가는 칩이 탭마다 있다', () => {
40
+ /* B.1 (designer 2026-09-27): the path -- model name › part -- on every tab; the model name is the one way back, the same move as the list's root. */
41
+ test('모델명 › 부품 경로가 탭마다 있고, 모델명은 목록 루트와 같은 한 동작으로 모델 설정을 연다', () => {
41
42
  assert.ok((renderBody.match(/\$\{this\.renderSubject\(\)\}/g) ?? []).length >= 3)
42
- assert.match(editor.slice(editor.indexOf(' private renderSubject()')), /@click=\$\{\(\) => \(this\.selected = ''\)\}/)
43
+ const subject = editor.slice(editor.indexOf(' private renderSubject()'), editor.indexOf(' private showModelSettings()'))
44
+ assert.ok(subject.includes('@click=${() => this.showModelSettings()}'))
45
+ assert.ok(!subject.includes('whole-figure'), 'no separate "whole figure" link')
46
+ const show = editor.slice(editor.indexOf(' private showModelSettings()'), editor.indexOf(' render() {'))
47
+ assert.ok(show.includes("this.selected = ''") && show.includes("this.tab = 'properties'"), 'the model settings, on the properties tab')
48
+ assert.ok(!/session!?\.(command|apply|edit|undo)/.test(show), 'moving changes no data and no history')
49
+ assert.ok(editor.includes('@show-model-settings=${() => this.showModelSettings()}'), 'the list root, the same move')
43
50
  })
44
51
 
45
52
  test('탭마다 제자리 — 속성엔 부품의 모양 · 재질, 자리와 능력엔 사다리와 자리, 움직임엔 움직임 · 조작값', () => {
@@ -318,9 +318,9 @@ test('붙여도 제자리 — 폼은 지금 서 있는 자리를 가운데서
318
318
  const [before, after] = [where(asset), where(fastened)]
319
319
  assert.deepEqual([after[0], after[2]], [before[0], before[2]], `${JSON.stringify(target)}: 앞뒤·좌우는 그대로`)
320
320
  }
321
- assert.deepEqual(placeKeptOf(asset, 'leaf', { part: 'frame', face: 'top' }), { align: { x: 'mm', z: 'mm' }, alignMm: { x: -50, z: 100 } })
321
+ assert.deepEqual(placeKeptOf(asset, 'leaf', { part: 'frame', face: 'top' }), { align: { x: 'mm', z: 'mm' }, alignMm: { x: -50, z: 100 }, alignInput: {} })
322
322
  const centred = addFigureBox(createFigureAsset('C'), { id: 'a', size: { x: 10, y: 10, z: 10 }, position: { x: 0, y: 5, z: 0 } })
323
- assert.deepEqual(placeKeptOf(centred, 'a', 'plane'), { align: {}, alignMm: {} }, '가운데 있던 것은 가운데로')
323
+ assert.deepEqual(placeKeptOf(centred, 'a', 'plane'), { align: {}, alignMm: {}, alignInput: {} }, '가운데 있던 것은 가운데로')
324
324
 
325
325
  /* The form fills it in whenever the aim changes, and does not offer a part that hangs from this one. */
326
326
  const { hangsFrom } = await import('../client/modeller/links.ts')
@@ -332,7 +332,9 @@ test('붙여도 제자리 — 폼은 지금 서 있는 자리를 가운데서
332
332
  assert.deepEqual([hangsFrom(row, 'roller', 'plate'), hangsFrom(row, 'cap', 'plate'), hangsFrom(row, 'plate', 'roller')], [true, true, false])
333
333
  const form = read('../client/modeller/figure-links.ts')
334
334
  assert.equal(form.match(/this\.aimAt\(asset, \{ \.\.\.f, (toPlane|target|targetFace):/g)?.length, 3, '바닥 · 상대 부품 · 상대 면을 바꿀 때마다')
335
- assert.ok(form.includes('!hangsFrom(asset, one, this.partId)'))
335
+ /* Kept in the list, not choosable, with the reason (ADR-0097 §5.5). */
336
+ assert.ok(form.includes('const loops = (one: string) => hangsFrom(asset, one, this.partId)'))
337
+ assert.ok(form.includes("html`<option value=${one} disabled>${i18next.t('figure.text.target-would-loop'"))
336
338
  })
337
339
 
338
340
  test('따라 움직이는 관절 — 같은 방식으로 움직이고 제 조작값이 있는 관절만 고르고, 기록에는 배율과 더하기만 (집게 두 손가락, 09-25)', async () => {
@@ -609,7 +611,8 @@ test('팔레트의 문틀은 문 크기로 서고, 세 축 모두 크기를 따
609
611
  /* 크기를 따라감 한 번 — 치수 칸의 단추 하나, 크기를 대신 정했으면 한 줄로 말한다 (ADR 판정 Q1, 09-26). */
610
612
  test('치수 칸 — 「도형 크기를 따라감」 단추는 follow-size 한 번, 다 따르면 사라진다; 크기를 대신 정하면 한 줄', () => {
611
613
  const form = read('../client/modeller/figure-links.ts')
612
- const dims = form.slice(form.indexOf(' private renderDimensions('), form.indexOf(' private renderDimensions(') + 1200)
614
+ /* The whole method: the resize-with-placement block above the advanced settings made it longer than a fixed slice. */
615
+ const dims = form.slice(form.indexOf(' private renderDimensions('), form.indexOf(' /** The same source, the same share'))
613
616
  assert.ok(dims.includes("this.command({ kind: 'follow-size', part: this.partId })"))
614
617
  assert.ok(dims.includes("followOf(asset, this.partId, axis).of === 'instance'"), '다 따르면 단추가 없다')
615
618
  const editor = read('../client/modeller/editor.ts')
@@ -0,0 +1,126 @@
1
+ /*
2
+ * What became of an AI proposal (ADR-0097 §5.6 item 5; AI lane, 2026-09-27). A save names the proposals whose steps its
3
+ * text holds -- undone, a proposal is not in it; a new figure made from a proposal is created under the proposal's key.
4
+ * The server side (recorded in the save's transaction, one figure per key) is tested on sqlite in figure-service
5
+ * (figure-proposal-db).
6
+ */
7
+ import { test } from 'node:test'
8
+ import assert from 'node:assert/strict'
9
+ import { readFileSync } from 'node:fs'
10
+ import { fileURLToPath } from 'node:url'
11
+ import { addFigureBox, createFigureAsset, serializeFigureAsset } from '@hatiolab/figure-model'
12
+
13
+ import { createFigureProductSession } from '../client/modeller/product-session.js'
14
+ import { runFigureProposal, takeFigureOffer, type FigureApplyDetail } from '../client/modeller/ai-proposal.js'
15
+
16
+ const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
17
+ const say = (key: string) => key
18
+ const text = () => serializeFigureAsset(addFigureBox(createFigureAsset('A'), { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } }))
19
+ const lid = [{ kind: 'add-part', part: { id: 'lid', kind: 'box', dimensions: { width: 100, height: 10, depth: 100 } } }] as any[]
20
+ const leg = [{ kind: 'add-part', part: { id: 'leg', kind: 'box', dimensions: { width: 10, height: 50, depth: 10 } } }] as any[]
21
+ const K1 = '11111111-1111-4111-8111-111111111111'
22
+ const K2 = '22222222-2222-4222-8222-222222222222'
23
+
24
+ function server() {
25
+ const saves: Array<{ id?: string; creationId?: string; proposalKeys?: string[] }> = []
26
+ let row = text()
27
+ const store = {
28
+ load: async (id: string) => ({ id, source: row }),
29
+ save: async (q: { id?: string; source: string; creationId?: string; proposalKeys?: string[] }) => {
30
+ saves.push({ id: q.id, creationId: q.creationId, proposalKeys: q.proposalKeys })
31
+ row = q.source
32
+ return { id: q.id ?? q.creationId ?? 'created', source: q.source }
33
+ }
34
+ }
35
+ return { store, saves }
36
+ }
37
+
38
+ test('a save names the proposals its text holds: undone, not; redone, again; undone and written past, never', async () => {
39
+ const { store, saves } = server()
40
+ const s = createFigureProductSession(store)
41
+ await s.open('a')
42
+ let v = s.read()
43
+ s.applyProposal(lid, v.revision!, v.context!, K1)
44
+ v = s.read()
45
+ s.applyProposal(leg, v.revision!, v.context!, K2)
46
+ assert.deepEqual(s.proposalsInEffect(), [K1, K2])
47
+ s.undo()
48
+ assert.deepEqual(s.proposalsInEffect(), [K1], 'the leg proposal undone: not in the model')
49
+ s.redo()
50
+ assert.deepEqual(s.proposalsInEffect(), [K1, K2])
51
+ s.undo()
52
+ s.command({ kind: 'rename', name: 'B' } as never)
53
+ assert.deepEqual(s.proposalsInEffect(), [K1], 'undone and written past: gone for good')
54
+ await s.save()
55
+ assert.deepEqual(saves.at(-1)!.proposalKeys, [K1])
56
+ /* A person's own edits carry no key; a refused proposal adds none. */
57
+ v = s.read()
58
+ assert.throws(() => s.applyProposal(leg, v.revision! - 1, v.context!, K2))
59
+ assert.deepEqual(s.proposalsInEffect(), [K1])
60
+ })
61
+
62
+ test('a new figure made from a proposal is created under its key; one being made keeps its own', async () => {
63
+ const { store, saves } = server()
64
+ const s = createFigureProductSession(store)
65
+ s.startNew()
66
+ const detail: FigureApplyDetail = { key: K1, steps: lid, create: true }
67
+ assert.equal(takeFigureOffer(s, detail, { onSavedFigure: false, say }), true)
68
+ await s.save({ type: 'LIDDED' })
69
+ assert.equal(saves.at(-1)!.creationId, K1, 'created under the proposal key: taken twice, it meets the first figure')
70
+ assert.deepEqual(saves.at(-1)!.proposalKeys, [K1])
71
+
72
+ /* A new figure the person has touched is theirs: the proposal is not put on it (NEW_FIGURE_UNSAVED), its id stays. */
73
+ s.startNew({ discard: true })
74
+ s.command({ kind: 'rename', name: 'Mine' } as never)
75
+ assert.throws(() => s.createUnder(K2))
76
+ /* Nor on a saved one. */
77
+ const t = createFigureProductSession(server().store)
78
+ await t.open('a')
79
+ assert.throws(() => t.createUnder(K2))
80
+ })
81
+
82
+ test('the key travels from the card to the modeller, on a revision and on a new figure', async () => {
83
+ const offered: FigureApplyDetail[] = []
84
+ const opened: Array<string | undefined> = []
85
+ await runFigureProposal(
86
+ { key: K1, steps: lid, create: false, context: 'c', revision: 1 },
87
+ { offer: d => (offered.push(d), { ...d, applied: true }), openNew: async () => ({ applied: false }) },
88
+ say
89
+ )
90
+ assert.equal(offered[0]!.key, K1)
91
+ await runFigureProposal(
92
+ { key: K2, steps: lid, create: true },
93
+ { offer: d => d, openNew: async (_steps, key) => (opened.push(key), { applied: true }) },
94
+ say
95
+ )
96
+ assert.deepEqual(opened, [K2])
97
+ const page = read('../client/pages/figure-modeller-page.ts')
98
+ const take = page.slice(page.indexOf(' private takeNewFigureProposal'), page.indexOf(' private askFirstSave'))
99
+ assert.ok(take.includes('if (waiting.key) this.session.createUnder(waiting.key)'))
100
+ assert.ok(take.includes('this.session.applyProposal(waiting.steps, view.revision!, view.context!, waiting.key)'))
101
+ assert.ok(read('../client/figure-assistant.ts').includes('const opened = pendNewFigure(steps, key)'))
102
+ })
103
+
104
+ test('the dock hears the selected part and what the preview tries, with the version -- and selecting starts nothing', () => {
105
+ const page = read('../client/pages/figure-modeller-page.ts')
106
+ const provide = page.slice(page.indexOf(' private provideAssistant = '), page.indexOf(' private withdrawAssistant('))
107
+ assert.ok(provide.includes('...this.assistantFocus()'), 'read at the moment of sending')
108
+ assert.ok(provide.includes('selection: { part: focus.part, context, revision }'))
109
+ assert.ok(provide.includes('trying: { ...focus.trying, context, revision }'))
110
+ /* Not in the key, the label or the anchor: selecting is not a new subject. */
111
+ assert.ok(provide.includes('key: `figure:${id}`'))
112
+ const editor = read('../client/modeller/editor.ts')
113
+ const choose = editor.slice(editor.indexOf(' private choose('), editor.indexOf(' private choose(') + 400)
114
+ assert.ok(!choose.includes('notifyAssistantSurface') && !editor.includes('notifyAssistantSurface'), 'selecting does not tell the dock')
115
+ })
116
+
117
+ test('the dock checks the result from the server: saved is accepted and named, nothing recorded is not known', async () => {
118
+ const { proposalOutcomeFrom } = await import('../client/modeller/ai-proposal.js')
119
+ const words = JSON.parse(read('../translations/ko.json'))
120
+ const sayKo = (key: string, params: Record<string, string> = {}) => words[key].replace(/\{(\w+)\}/g, (_: string, k: string) => params[k] ?? '')
121
+ assert.deepEqual(proposalOutcomeFrom([{ figureId: 'x', type: 'LIDDED', name: '뚜껑 상자' }], sayKo), { verdict: 'accepted', note: '저장된 도형에 들어 있습니다: 뚜껑 상자' })
122
+ assert.equal(proposalOutcomeFrom([], sayKo), undefined, 'not saved is not known, never refused')
123
+ const page = read('../client/pages/figure-modeller-page.ts')
124
+ assert.ok(page.includes('proposalOutcomeFrom(await fetchProposalOutcome(proposalKey)'), 'the surface asks the server')
125
+ for (const lang of ['en', 'ja', 'ko', 'ms', 'zh']) assert.ok(JSON.parse(read(`../translations/${lang}.json`))['figure.text.proposal-saved-into'], lang)
126
+ })
@@ -0,0 +1,28 @@
1
+ /*
2
+ * ADR-0097 §5.6, Q-D2: beside the save, what the saved figure is -- a draft never released, a draft while boards use an
3
+ * earlier release, a release -- and a way to the release that exists, not a second one.
4
+ */
5
+ import { test } from 'node:test'
6
+ import assert from 'node:assert/strict'
7
+ import { readFileSync } from 'node:fs'
8
+ import { fileURLToPath } from 'node:url'
9
+
10
+ const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
11
+
12
+ test('beside the save: the release state, and a way to the release section that exists', () => {
13
+ const editor = read('../client/modeller/editor.ts')
14
+ assert.ok(editor.includes('<span slot="status" release-state>${this.releaseWords(view)}</span>'))
15
+ assert.ok(editor.includes("<button slot=\"commit\" publish ?disabled=${!view.id}"), 'not before the first save')
16
+ const go = editor.slice(editor.indexOf(' private goRelease'), editor.indexOf(' /** The fastening form'))
17
+ assert.ok(go.includes("this.tab = 'history'") && go.includes('showRelease'), 'to the history tab\'s release section')
18
+ const panel = read('../client/modeller/figure-history-panel.ts')
19
+ assert.ok(panel.includes('<section release tabindex="-1">') && panel.includes('async showRelease()'))
20
+ const page = read('../client/pages/figure-modeller-page.ts')
21
+ assert.ok(page.includes('.releaseState=${this.row}'), 'as the server gave it')
22
+ const ko = JSON.parse(read('../translations/ko.json'))
23
+ assert.equal(ko['figure.text.release-state-draft-over'].replace('{version}', '3'), '초안 · 보드는 v3 사용')
24
+ for (const lang of ['en', 'ja', 'ko', 'ms', 'zh']) {
25
+ const t = JSON.parse(read(`../translations/${lang}.json`))
26
+ for (const key of ['figure.text.release-state-released', 'figure.text.release-state-draft-over', 'figure.text.release-state-draft-new', 'figure.button.release']) assert.ok(t[key], `${lang} ${key}`)
27
+ }
28
+ })
@@ -35,7 +35,8 @@ test('새로 만든 도형의 「공간 선언 없음」을 「부품에 맞추
35
35
  const fitted = applyFigureAuthoring(asset, occupancyFromParts(serializeFigureAsset(asset)))
36
36
 
37
37
  assert.deepEqual(codes(fitted), [])
38
- assert.equal(fitted.occupancy?.placement, 'floor', '처음 선언하는 도형은 바닥에 선다')
38
+ assert.equal(fitted.placement ?? 'floor', 'floor', '처음 선언하는 도형은 바닥에 선다')
39
+ assert.equal('placement' in (fitted.occupancy ?? {}), false, '배치는 공간이 아니라 도형 자신의 설정 (ADR-0097)')
39
40
  })
40
41
 
41
42
  test('선언한 공간을 넘은 부품도 같은 한 번으로 풀리고, 배치 기준은 지킨다', () => {
@@ -48,7 +49,7 @@ test('선언한 공간을 넘은 부품도 같은 한 번으로 풀리고, 배
48
49
  const fitted = applyFigureAuthoring(asset, occupancyFromParts(serializeFigureAsset(asset)))
49
50
 
50
51
  assert.deepEqual(codes(fitted), [])
51
- assert.equal(fitted.occupancy?.placement, 'center', '이미 선언된 배치 기준을 바꾸지 않는다')
52
+ assert.equal(fitted.placement, 'center', '이미 선언된 배치 기준을 바꾸지 않는다')
52
53
  })
53
54
 
54
55
  const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')