@things-factory/figure-ui 10.1.146 → 10.1.149

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 (209) hide show
  1. package/client/bootstrap.ts +10 -2
  2. package/client/graphql/index.ts +20 -0
  3. package/client/modeller/canvas.ts +6 -2
  4. package/client/modeller/carrier-declaration.ts +63 -0
  5. package/client/modeller/editor.ts +62 -27
  6. package/client/modeller/figure-carrier.ts +117 -0
  7. package/client/modeller/figure-cavity-drawing.ts +3 -9
  8. package/client/modeller/figure-first-save.ts +4 -7
  9. package/client/modeller/figure-history-panel.ts +2 -3
  10. package/client/modeller/figure-links.ts +46 -60
  11. package/client/modeller/figure-material-panel.ts +4 -14
  12. package/client/modeller/figure-mesh-choice.ts +68 -0
  13. package/client/modeller/figure-parts.ts +23 -1
  14. package/client/modeller/figure-preview.ts +2 -2
  15. package/client/modeller/figure-properties.ts +5 -3
  16. package/client/modeller/figure-section-editor.ts +12 -33
  17. package/client/modeller/figure-section-panel.ts +6 -7
  18. package/client/modeller/figure-slot-layout-panel.ts +208 -0
  19. package/client/modeller/figure-verdict.ts +21 -31
  20. package/client/modeller/first-save.ts +23 -0
  21. package/client/modeller/links.ts +52 -1
  22. package/client/modeller/material-edit.ts +38 -0
  23. package/client/modeller/maturity.ts +28 -0
  24. package/client/modeller/mesh-import.ts +77 -0
  25. package/client/modeller/modeller-route.ts +9 -0
  26. package/client/modeller/mounting-part.ts +15 -0
  27. package/client/modeller/newest-answer.ts +21 -0
  28. package/client/modeller/preview-model.ts +5 -0
  29. package/client/modeller/refusals.ts +168 -82
  30. package/client/modeller/section-controls.ts +25 -1
  31. package/client/modeller/section-drawing.ts +43 -1
  32. package/client/modeller/slot-layout-host.ts +140 -0
  33. package/client/pages/figure-modeller-page.ts +6 -5
  34. package/client/viewparts/detail-route.ts +7 -0
  35. package/client/viewparts/figure-detail-dock.ts +1 -3
  36. package/dist-client/bootstrap.d.ts +1 -1
  37. package/dist-client/bootstrap.js +9 -2
  38. package/dist-client/bootstrap.js.map +1 -1
  39. package/dist-client/graphql/index.d.ts +5 -0
  40. package/dist-client/graphql/index.js +19 -0
  41. package/dist-client/graphql/index.js.map +1 -1
  42. package/dist-client/modeller/canvas.d.ts +2 -0
  43. package/dist-client/modeller/canvas.js +10 -2
  44. package/dist-client/modeller/canvas.js.map +1 -1
  45. package/dist-client/modeller/carrier-declaration.d.ts +31 -0
  46. package/dist-client/modeller/carrier-declaration.js +53 -0
  47. package/dist-client/modeller/carrier-declaration.js.map +1 -0
  48. package/dist-client/modeller/editor.d.ts +13 -0
  49. package/dist-client/modeller/editor.js +62 -27
  50. package/dist-client/modeller/editor.js.map +1 -1
  51. package/dist-client/modeller/figure-carrier.d.ts +26 -0
  52. package/dist-client/modeller/figure-carrier.js +123 -0
  53. package/dist-client/modeller/figure-carrier.js.map +1 -0
  54. package/dist-client/modeller/figure-cavity-drawing.js +3 -11
  55. package/dist-client/modeller/figure-cavity-drawing.js.map +1 -1
  56. package/dist-client/modeller/figure-first-save.d.ts +4 -0
  57. package/dist-client/modeller/figure-first-save.js +5 -8
  58. package/dist-client/modeller/figure-first-save.js.map +1 -1
  59. package/dist-client/modeller/figure-history-panel.js +2 -4
  60. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  61. package/dist-client/modeller/figure-links.d.ts +4 -1
  62. package/dist-client/modeller/figure-links.js +47 -57
  63. package/dist-client/modeller/figure-links.js.map +1 -1
  64. package/dist-client/modeller/figure-material-panel.d.ts +0 -1
  65. package/dist-client/modeller/figure-material-panel.js +4 -14
  66. package/dist-client/modeller/figure-material-panel.js.map +1 -1
  67. package/dist-client/modeller/figure-mesh-choice.d.ts +16 -0
  68. package/dist-client/modeller/figure-mesh-choice.js +75 -0
  69. package/dist-client/modeller/figure-mesh-choice.js.map +1 -0
  70. package/dist-client/modeller/figure-parts.d.ts +1 -1
  71. package/dist-client/modeller/figure-parts.js +22 -0
  72. package/dist-client/modeller/figure-parts.js.map +1 -1
  73. package/dist-client/modeller/figure-preview.js +2 -2
  74. package/dist-client/modeller/figure-preview.js.map +1 -1
  75. package/dist-client/modeller/figure-properties.d.ts +2 -1
  76. package/dist-client/modeller/figure-properties.js +5 -3
  77. package/dist-client/modeller/figure-properties.js.map +1 -1
  78. package/dist-client/modeller/figure-section-editor.d.ts +2 -2
  79. package/dist-client/modeller/figure-section-editor.js +12 -33
  80. package/dist-client/modeller/figure-section-editor.js.map +1 -1
  81. package/dist-client/modeller/figure-section-panel.js +6 -7
  82. package/dist-client/modeller/figure-section-panel.js.map +1 -1
  83. package/dist-client/modeller/figure-slot-layout-panel.d.ts +36 -0
  84. package/dist-client/modeller/figure-slot-layout-panel.js +198 -0
  85. package/dist-client/modeller/figure-slot-layout-panel.js.map +1 -0
  86. package/dist-client/modeller/figure-verdict.d.ts +1 -1
  87. package/dist-client/modeller/figure-verdict.js +19 -37
  88. package/dist-client/modeller/figure-verdict.js.map +1 -1
  89. package/dist-client/modeller/first-save.d.ts +10 -0
  90. package/dist-client/modeller/first-save.js +16 -0
  91. package/dist-client/modeller/first-save.js.map +1 -1
  92. package/dist-client/modeller/links.d.ts +26 -1
  93. package/dist-client/modeller/links.js +43 -0
  94. package/dist-client/modeller/links.js.map +1 -1
  95. package/dist-client/modeller/material-edit.d.ts +11 -0
  96. package/dist-client/modeller/material-edit.js +28 -0
  97. package/dist-client/modeller/material-edit.js.map +1 -0
  98. package/dist-client/modeller/maturity.d.ts +15 -0
  99. package/dist-client/modeller/maturity.js +23 -0
  100. package/dist-client/modeller/maturity.js.map +1 -1
  101. package/dist-client/modeller/mesh-import.d.ts +47 -0
  102. package/dist-client/modeller/mesh-import.js +47 -0
  103. package/dist-client/modeller/mesh-import.js.map +1 -0
  104. package/dist-client/modeller/modeller-route.d.ts +5 -0
  105. package/dist-client/modeller/modeller-route.js +9 -0
  106. package/dist-client/modeller/modeller-route.js.map +1 -1
  107. package/dist-client/modeller/mounting-part.d.ts +14 -0
  108. package/dist-client/modeller/mounting-part.js +10 -0
  109. package/dist-client/modeller/mounting-part.js.map +1 -0
  110. package/dist-client/modeller/newest-answer.d.ts +12 -0
  111. package/dist-client/modeller/newest-answer.js +21 -0
  112. package/dist-client/modeller/newest-answer.js.map +1 -0
  113. package/dist-client/modeller/preview-model.d.ts +2 -0
  114. package/dist-client/modeller/preview-model.js +4 -0
  115. package/dist-client/modeller/preview-model.js.map +1 -1
  116. package/dist-client/modeller/refusals.d.ts +26 -5
  117. package/dist-client/modeller/refusals.js +184 -86
  118. package/dist-client/modeller/refusals.js.map +1 -1
  119. package/dist-client/modeller/section-controls.d.ts +17 -1
  120. package/dist-client/modeller/section-controls.js +18 -0
  121. package/dist-client/modeller/section-controls.js.map +1 -1
  122. package/dist-client/modeller/section-drawing.d.ts +24 -1
  123. package/dist-client/modeller/section-drawing.js +42 -0
  124. package/dist-client/modeller/section-drawing.js.map +1 -1
  125. package/dist-client/modeller/slot-layout-host.d.ts +81 -0
  126. package/dist-client/modeller/slot-layout-host.js +105 -0
  127. package/dist-client/modeller/slot-layout-host.js.map +1 -0
  128. package/dist-client/pages/figure-modeller-page.js +5 -4
  129. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  130. package/dist-client/tsconfig.tsbuildinfo +1 -1
  131. package/dist-client/viewparts/detail-route.d.ts +5 -0
  132. package/dist-client/viewparts/detail-route.js +8 -0
  133. package/dist-client/viewparts/detail-route.js.map +1 -0
  134. package/dist-client/viewparts/figure-detail-dock.d.ts +0 -1
  135. package/dist-client/viewparts/figure-detail-dock.js +1 -3
  136. package/dist-client/viewparts/figure-detail-dock.js.map +1 -1
  137. package/package.json +9 -8
  138. package/test/align.test.ts +0 -9
  139. package/test/browser/assistant-stand-in.js +32 -0
  140. package/test/browser/build.mjs +6 -1
  141. package/test/browser/editing-page.js +224 -0
  142. package/test/browser/editing.check.mjs +550 -0
  143. package/test/browser/links-page.js +113 -0
  144. package/test/browser/links.check.mjs +568 -0
  145. package/test/browser/modeller.check.mjs +21 -0
  146. package/test/browser/parts-page.js +120 -0
  147. package/test/browser/parts.check.mjs +328 -0
  148. package/test/browser/places-mesh-page.js +36 -0
  149. package/test/browser/places-mesh.check.mjs +239 -0
  150. package/test/browser/product-page.check.mjs +682 -0
  151. package/test/browser/product-page.js +276 -0
  152. package/test/browser/screen-page.js +112 -0
  153. package/test/browser/screen-product-page.js +62 -0
  154. package/test/browser/screen.check.mjs +1003 -0
  155. package/test/browser/section-page.js +256 -0
  156. package/test/browser/section.check.mjs +719 -0
  157. package/test/browser/serve.mjs +20 -12
  158. package/test/carrier-declaration.test.ts +69 -0
  159. package/test/cavity-drawing.test.ts +2 -18
  160. package/test/clip-ai-proposal.test.ts +1 -5
  161. package/test/clip-consent.test.ts +0 -5
  162. package/test/clips.test.ts +0 -7
  163. package/test/command-preview.test.ts +0 -68
  164. package/test/controls.test.ts +0 -9
  165. package/test/detail-dock.test.ts +6 -25
  166. package/test/face-mapping.test.ts +1 -20
  167. package/test/fasten-drawing.test.ts +0 -23
  168. package/test/fields.test.ts +6 -33
  169. package/test/figure-assistant-surface.test.ts +32 -38
  170. package/test/first-minute.test.ts +3 -55
  171. package/test/first-save-suggest.test.ts +11 -8
  172. package/test/first-save.test.ts +4 -26
  173. package/test/gizmo.test.ts +0 -25
  174. package/test/joint-fan.test.ts +0 -10
  175. package/test/keys.test.ts +0 -7
  176. package/test/layout.test.ts +4 -73
  177. package/test/links.test.ts +48 -67
  178. package/test/live.test.ts +0 -34
  179. package/test/material.test.ts +17 -26
  180. package/test/maturity.test.ts +13 -16
  181. package/test/mesh-import.test.ts +76 -0
  182. package/test/modeller-route.test.ts +3 -19
  183. package/test/package-download.test.ts +6 -7
  184. package/test/parts-wiring.test.ts +9 -51
  185. package/test/preview.test.ts +5 -46
  186. package/test/properties.test.ts +11 -26
  187. package/test/proposal-record.test.ts +2 -19
  188. package/test/recipes.test.ts +0 -15
  189. package/test/refusals.test.ts +257 -151
  190. package/test/release-entry.test.ts +1 -9
  191. package/test/release-panel.test.ts +1 -22
  192. package/test/resize-with-placement.test.ts +0 -32
  193. package/test/revert-choice.test.ts +1 -22
  194. package/test/revert-keeping.test.ts +1 -12
  195. package/test/save-landed.test.ts +16 -14
  196. package/test/scene-canvas.test.ts +2 -13
  197. package/test/section-editor.test.ts +30 -83
  198. package/test/section-panel.test.ts +22 -17
  199. package/test/slot-layout-host.test.ts +117 -0
  200. package/test/theme.test.ts +5 -9
  201. package/test/verdict.test.ts +44 -41
  202. package/test/view.test.ts +5 -12
  203. package/translations/en.json +61 -1
  204. package/translations/es.json +61 -1
  205. package/translations/ja.json +61 -1
  206. package/translations/ko.json +61 -1
  207. package/translations/ms.json +61 -1
  208. package/translations/zh.json +61 -1
  209. package/test/thumbnail.test.ts +0 -29
@@ -1,159 +1,135 @@
1
1
  /*
2
- * 크기 범위 거절을 사람의 말로 (사용자 2026-09-24: 「what is this warning message means?」 — 커널의 영어 원문이 떴다).
2
+ * A refused command said in the author's words (user 2026-09-24: 「what is this warning message means?」 -- the kernel's
3
+ * English was on the screen). Every refusal here is a real one from figure-model, and the words are made from its code
4
+ * and details (ADR-0127): the kernel's English is never read, so a sentence the kernel changes cannot silence the screen
5
+ * (2026-10-11: three INSTANCE_SIZE_RANGE sentences showed only 「이 편집은 할 수 없습니다」).
3
6
  */
4
7
  import { test } from 'node:test'
5
8
  import assert from 'node:assert/strict'
6
- import { readFileSync } from 'node:fs'
9
+ import { readdirSync, readFileSync, statSync } from 'node:fs'
10
+ import { join } from 'node:path'
7
11
  import { fileURLToPath } from 'node:url'
8
12
 
9
- import { sizeRangeRefusalOf } from '../client/modeller/refusals.ts'
13
+ import * as fm from '@hatiolab/figure-model'
14
+ import type { FigureAsset } from '@hatiolab/figure-model'
15
+ import {
16
+ editRefusalOf,
17
+ inDeclaredRoom,
18
+ inputRangeRefusalOf,
19
+ narrowingRetryOf,
20
+ partNameRefusalOf,
21
+ partRefusalOf,
22
+ placedRefusalOf,
23
+ placementRefusalOf,
24
+ recipeRefusalOf,
25
+ removalNoticeOf,
26
+ repeatPitchRefusalOf,
27
+ sizeRangeRefusalOf
28
+ } from '../client/modeller/refusals.ts'
10
29
 
11
30
  const ko = JSON.parse(readFileSync(fileURLToPath(new URL('../translations/ko.json', import.meta.url)), 'utf8'))
12
- const say = (key: string, params: Record<string, string>) => ko[key].replace(/\{(\w+)\}/g, (_: string, k: string) => params[k] ?? '')
13
- const refusal = (message: string) => Object.assign(new Error(message), { code: 'INSTANCE_SIZE_RANGE' })
31
+ const say = (key: string, params: Record<string, string>) => {
32
+ assert.ok(key in ko, `no words for ${key}`)
33
+ return ko[key].replace(/\{(\w+)\}/g, (_: string, k: string) => params[k] ?? '')
34
+ }
35
+ /** The key and its values, to see which words were chosen. */
36
+ const keyed = (key: string, p: Record<string, string>) => `${key} ${JSON.stringify(p)}`
14
37
 
15
- test('화성 트레이의 거절 — 축 · 숫자 · 부품 · 고치는 곳', () => {
16
- const said = sizeRangeRefusalOf(refusal('INSTANCE_SIZE_RANGE: size.x: below 8.82353 mm a length goes to zero (cell); declare the smallest size at 8.82353 mm or more'), say)!
17
- assert.ok(said.includes('X 축을') && said.includes('8.8 mm') && said.includes('cell 부품') && said.includes('다시 재기'), said)
18
- assert.ok(!/declare|below/.test(said))
19
- })
20
-
21
- test('위쪽 끝도, 부품 이름이 없어도', () => {
22
- const said = sizeRangeRefusalOf(refusal('INSTANCE_SIZE_RANGE: size.y: above 15000 mm the figure cannot be drawn; declare the largest size at 15000 mm or less'), say)!
23
- assert.ok(said.includes('Y 축을') && said.includes('15,000 mm'), said)
24
- })
25
-
26
- /*
27
- * 범위가 지금 크기를 담지 않는 거절도 숫자와 함께 (Figure 레인 인계, 2026-10-11: 400 mm 상자에 가장 작은 크기 500 을
28
- * 넣으면 「이 편집은 할 수 없습니다. (INSTANCE_SIZE_RANGE)」만 떴다 — 이 문장은 below/above 꼴이 아니라 읽히지 않았다).
29
- */
30
- test('범위가 지금 크기를 담지 않으면 축 · 지금 크기 · 범위를 말한다', () => {
31
- const said = sizeRangeRefusalOf(refusal('INSTANCE_SIZE_RANGE: size.x: the authored size 400 mm must lie within 500…4000 mm'), say)!
32
- assert.ok(said.includes('X') && said.includes('400 mm') && said.includes('500') && said.includes('4,000'), said)
33
- assert.ok(!/authored|lie within/.test(said), said)
34
- })
35
-
36
- test('가장 작은 크기가 0 이하이면 그렇게 말한다', () => {
37
- const said = sizeRangeRefusalOf(refusal('INSTANCE_SIZE_RANGE: size.z: the smallest size must be above zero; got 0'), say)!
38
- assert.ok(said.includes('Z') && said.includes('0 보다'), said)
39
- assert.ok(!/smallest|above zero/.test(said), said)
40
- })
41
-
42
- test('지금 크기에서 이미 길이가 없는 부품이 있으면 그렇게 말한다', () => {
43
- const said = sizeRangeRefusalOf(refusal('INSTANCE_SIZE_RANGE: size.y: the figure is authored at 120 mm, where a length is already gone; change the link or the size'), say)!
44
- assert.ok(said.includes('Y') && said.includes('120 mm'), said)
45
- assert.ok(!/authored|already gone/.test(said), said)
46
- })
47
-
48
- test('INSTANCE_SIZE_RANGE 의 문장 넷이 모두 사람의 말이 된다 — 커널이 쓰는 꼴 그대로', async () => {
49
- const { addFigureBox, applyFigureAuthoring, createFigureAsset } = await import('@hatiolab/figure-model')
50
- const box = addFigureBox(createFigureAsset('Box'), { id: 'box', size: { x: 400, y: 300, z: 200 }, position: { x: 0, y: 150, z: 0 } })
51
- const refused = (action: unknown) => {
52
- try {
53
- applyFigureAuthoring(box, action as never)
54
- } catch (e) {
55
- return e
56
- }
57
- assert.fail('the kernel took it')
58
- }
59
- const range = (x: { min: number; max: number }) => ({ kind: 'declare-instance-size', range: { x, y: { min: 100, max: 3000 }, z: { min: 100, max: 3000 } } })
60
- for (const error of [refused(range({ min: 500, max: 4000 })), refused(range({ min: 0, max: 4000 }))]) {
61
- assert.equal((error as { code?: string }).code, 'INSTANCE_SIZE_RANGE', String(error))
62
- assert.ok(sizeRangeRefusalOf(error, say), `not read: ${(error as Error).message}`)
38
+ const act = (asset: FigureAsset, action: object) => fm.applyFigureAuthoring(asset, action as never)
39
+ function refusalOf(run: () => unknown): unknown {
40
+ try {
41
+ run()
42
+ } catch (e) {
43
+ assert.ok((e as { code?: string }).code, String(e))
44
+ return e
63
45
  }
64
- })
46
+ assert.fail('the kernel took it')
47
+ }
65
48
 
66
- test('다른 거절은 건드리지 않는다', () => {
67
- assert.equal(sizeRangeRefusalOf(Object.assign(new Error('x'), { code: 'EDIT_TARGET' }), say), null)
49
+ const box = () => fm.addFigureBox(fm.createFigureAsset('Box'), { id: 'box', size: { x: 400, y: 300, z: 200 }, position: { x: 0, y: 150, z: 0 } })
50
+ function panel(): FigureAsset {
51
+ const a = fm.addFigureBox(fm.createFigureAsset('Panel'), { id: 'panel', size: { x: 400, y: 400, z: 400 }, position: { x: 0, y: 0, z: 0 } })
52
+ const range = { min: 1, max: 4000 }
53
+ return act(a, { kind: 'declare-instance-size', range: { x: range, y: range, z: range }, at: { x: 400, y: 400, z: 400 } })
54
+ }
55
+ const followsWidth = (plus: number, extra: object = {}) => ({ kind: 'link-dimension' as const, part: 'panel', axis: 'x' as const, source: { of: 'instance' as const, axis: 'x' as const }, plus, ...extra })
56
+ const declaring = (x: { min: number; max: number }) => ({ kind: 'declare-instance-size', range: { x, y: { min: 100, max: 3000 }, z: { min: 100, max: 3000 } } })
57
+ function conveyor(): FigureAsset {
58
+ let a = fm.createFigureAsset('Conveyor')
59
+ a = fm.addFigureBox(a, { id: 'frame', size: { x: 2000, y: 100, z: 600 }, position: { x: 0, y: 0, z: 0 } })
60
+ a = fm.addFigureBox(a, { id: 'roller', size: { x: 60, y: 60, z: 560 }, position: { x: 0, y: 0, z: 0 } })
61
+ a = act(a, { kind: 'attach', part: 'frame', face: 'bottom', to: { plane: 'mounting-plane' } })
62
+ a = act(a, { kind: 'attach', part: 'roller', face: 'bottom', to: { part: 'frame', face: 'top' } })
63
+ return act(a, { kind: 'make-repeat', part: 'roller', axis: 'x', pitch: 200 })
64
+ }
65
+
66
+ test('크기 범위의 거절 다섯 — 축 · 숫자 · 부품을 커널이 넘긴 값으로 말한다', () => {
67
+ const small = sizeRangeRefusalOf(refusalOf(() => act(panel(), followsWidth(-50))), say)!
68
+ assert.ok(small.includes('X 축을') && small.includes('50 mm') && small.includes('panel 부품') && small.includes('다시 재기'), small)
69
+ const large = sizeRangeRefusalOf(refusalOf(() => act(panel(), followsWidth(1000, { times: -1 }))), say)!
70
+ assert.ok(large.includes('X 축을') && large.includes('1,000 mm') && large.includes('그릴 수 없는데'), large)
71
+ const outside = sizeRangeRefusalOf(refusalOf(() => act(box(), declaring({ min: 500, max: 4000 }))), say)!
72
+ assert.ok(outside.includes('X') && outside.includes('400 mm') && outside.includes('500') && outside.includes('4,000'), outside)
73
+ const zero = sizeRangeRefusalOf(refusalOf(() => act(box(), declaring({ min: 0, max: 4000 }))), say)!
74
+ assert.ok(zero.includes('X') && zero.includes('0 보다'), zero)
75
+ const gone = sizeRangeRefusalOf(refusalOf(() => act(panel(), followsWidth(-399.9999995, { narrowRange: true }))), say)!
76
+ assert.ok(gone.includes('X') && gone.includes('400 mm') && gone.includes('이미 0'), gone)
77
+ for (const said of [small, large, outside, zero, gone]) assert.doesNotMatch(said, /below|above|declare|authored|INSTANCE_SIZE_RANGE/)
78
+ assert.equal(sizeRangeRefusalOf(refusalOf(() => act(conveyor(), { kind: 'set-repeat', part: 'frame', pitch: 10 })), say), null, '다른 거절은 건드리지 않는다')
68
79
  })
69
80
 
70
- test('범위가 받을 수 없는 연결은 같은 연결을 범위를 좁혀 다시 권한다 — 다시 재기로는 풀리지 않는다 (사이징, 2026-09-25)', async () => {
71
- const { narrowingRetryOf } = await import('../client/modeller/refusals.ts')
72
- const { addFigureBox, applyFigureAuthoring, createFigureAsset } = await import('@hatiolab/figure-model')
73
- let asset = addFigureBox(createFigureAsset('Panel'), { id: 'panel', size: { x: 400, y: 400, z: 400 }, position: { x: 0, y: 0, z: 0 } })
74
- asset = applyFigureAuthoring(asset, { kind: 'declare-instance-size', range: { x: { min: 1, max: 4000 }, y: { min: 1, max: 4000 }, z: { min: 1, max: 4000 } }, at: { x: 400, y: 400, z: 400 } })
75
- const link = { kind: 'link-dimension' as const, part: 'panel', axis: 'x' as const, source: { of: 'instance' as const, axis: 'x' as const }, plus: -50 }
76
- let error: unknown
77
- try {
78
- applyFigureAuthoring(asset, link)
79
- } catch (e) {
80
- error = e
81
- }
82
- const say = (key: string, p: Record<string, string>) => `${key} ${JSON.stringify(p)}`
83
- const offer = narrowingRetryOf(link, error, say)!
81
+ test('범위가 받을 수 없는 연결은 같은 연결을 범위를 좁혀 다시 권한다 — 다시 재기로는 풀리지 않는다 (사이징, 2026-09-25)', () => {
82
+ const asset = panel()
83
+ const link = followsWidth(-50)
84
+ const error = refusalOf(() => act(asset, link))
85
+ const offer = narrowingRetryOf(link, error, keyed)!
84
86
  assert.ok(offer, '권한다')
85
87
  assert.match(offer.label, /figure.button.narrow-range-from .*"axis":"X"/)
88
+ assert.match(offer.message, /link-needs-smaller-range .*"part":"panel"/)
86
89
  assert.deepEqual(offer.retry, { ...link, narrowRange: true })
87
- assert.doesNotThrow(() => applyFigureAuthoring(asset, offer.retry as never), '권한 것은 받아들여진다')
88
- assert.equal(narrowingRetryOf({ kind: 'attach' }, error, say), null, '연결이 아닌 명령에는 권하지 않는다')
90
+ assert.doesNotThrow(() => act(asset, offer.retry), '권한 것은 받아들여진다')
91
+ assert.equal(narrowingRetryOf({ kind: 'attach' }, error, keyed), null, '연결이 아닌 명령에는 권하지 않는다')
89
92
  })
90
93
 
91
- test('지우기는 매단 것 · 따라 움직이는 것을 풀어 남기고 그 이름을 한 줄로 말한다; 남는 거절은 공간 선언과 줄 (아키텍트 판정 2026-09-27)', async () => {
92
- const { partRefusalOf, removalNoticeOf, editRefusalOf } = await import('../client/modeller/refusals.ts')
93
- const fm = await import('@hatiolab/figure-model')
94
- const say = (key: string, p: Record<string, string>) => `${key} ${JSON.stringify(p)}`
94
+ test('지우기는 매단 것 · 따라 움직이는 것을 풀어 남기고 그 이름을 한 줄로 말한다; 남는 거절은 공간 선언과 줄 (아키텍트 판정 2026-09-27)', () => {
95
95
  let a = fm.createFigureAsset('Bench')
96
96
  a = fm.addFigureBox(a, { id: 'leg', size: { x: 60, y: 700, z: 60 }, position: { x: 0, y: 0, z: 0 } })
97
97
  a = fm.addFigureBox(a, { id: 'top', size: { x: 1200, y: 40, z: 600 }, position: { x: 0, y: 0, z: 0 } })
98
98
  a = fm.addFigureBox(a, { id: 'lamp', size: { x: 80, y: 80, z: 80 }, position: { x: 0, y: 0, z: 0 } })
99
99
  a = fm.addFigureBox(a, { id: 'shade', size: { x: 40, y: 40, z: 40 }, position: { x: 0, y: 0, z: 0 } })
100
- a = fm.applyFigureAuthoring(a, { kind: 'attach', part: 'leg', face: 'bottom', to: { plane: 'mounting-plane' } })
101
- a = fm.applyFigureAuthoring(a, { kind: 'attach', part: 'top', face: 'bottom', to: { part: 'leg', face: 'top' } })
102
- a = fm.applyFigureAuthoring(a, { kind: 'attach', part: 'lamp', face: 'bottom', to: { part: 'top', face: 'top' }, align: { x: { mm: -300 } } })
103
- a = fm.applyFigureAuthoring(a, { kind: 'attach', part: 'shade', face: 'bottom', to: { part: 'top', face: 'top' }, align: { x: { mm: 300 } } })
104
- const turn = (asset: typeof a, part: string, extra: object) => fm.applyFigureAuthoring(asset, { kind: 'set-joint', joint: { ...asset.joints!.find(j => j.body1.part === part)!, type: 'revolute', axis: 'Y', ...extra } as never })
100
+ a = act(a, { kind: 'attach', part: 'leg', face: 'bottom', to: { plane: 'mounting-plane' } })
101
+ a = act(a, { kind: 'attach', part: 'top', face: 'bottom', to: { part: 'leg', face: 'top' } })
102
+ a = act(a, { kind: 'attach', part: 'lamp', face: 'bottom', to: { part: 'top', face: 'top' }, align: { x: { mm: -300 } } })
103
+ a = act(a, { kind: 'attach', part: 'shade', face: 'bottom', to: { part: 'top', face: 'top' }, align: { x: { mm: 300 } } })
104
+ const turn = (asset: typeof a, part: string, extra: object) => act(asset, { kind: 'set-joint', joint: { ...asset.joints!.find(j => j.body1.part === part)!, type: 'revolute', axis: 'Y', ...extra } })
105
105
  a = turn(a, 'lamp', { state: { id: 'lamp-turn' } })
106
106
  a = turn(a, 'shade', { mimic: { joint: 'lamp', multiplier: 1 } })
107
107
 
108
108
  const top = fm.removePart(a, { kind: 'remove-part', target: 'top' })
109
- const told = removalNoticeOf(top.notice, say)!
109
+ const told = removalNoticeOf(top.notice, keyed)!
110
110
  assert.match(told, /removal-released-motion .*"part":"lamp".*"control":"lamp-turn"/, '움직이던 것은 조작값 이름까지')
111
111
  assert.match(told, /removal-released .*"parts":"shade"/)
112
- const lamp = removalNoticeOf(fm.removePart(a, { kind: 'remove-part', target: 'lamp' }).notice, say)!
112
+ const lamp = removalNoticeOf(fm.removePart(a, { kind: 'remove-part', target: 'lamp' }).notice, keyed)!
113
113
  assert.match(lamp, /removal-own-control .*"part":"shade".*"control":"shade".*"min":"-180".*"max":"180"/, '따르던 것은 제 조작값을 받는다')
114
- assert.equal(removalNoticeOf(fm.removePart(a, { kind: 'remove-part', target: 'shade' }).notice, say), null, '아무것도 풀지 않았으면 말하지 않는다')
115
-
116
- const declared = fm.applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: fm.proposeFigureOccupancy(a), placement: 'floor' })
117
- const why = (asset: typeof a, part: string) => {
118
- try {
119
- fm.editFigurePart(asset, { kind: 'remove-part', target: part })
120
- return 'removed'
121
- } catch (e) {
122
- return partRefusalOf(e, part, say)
123
- }
124
- }
125
- assert.match(why(declared, 'shade')!, /part-in-declared-space .*"part":"shade"/, '잎인데 공간 선언에 들어 있다')
114
+ assert.equal(removalNoticeOf(fm.removePart(a, { kind: 'remove-part', target: 'shade' }).notice, keyed), null, '아무것도 풀지 않았으면 말하지 않는다')
126
115
 
127
- let row = fm.createFigureAsset('Conveyor')
128
- row = fm.addFigureBox(row, { id: 'frame', size: { x: 2000, y: 100, z: 600 }, position: { x: 0, y: 0, z: 0 } })
129
- row = fm.addFigureBox(row, { id: 'roller', size: { x: 60, y: 60, z: 560 }, position: { x: 0, y: 0, z: 0 } })
130
- row = fm.applyFigureAuthoring(row, { kind: 'attach', part: 'frame', face: 'bottom', to: { plane: 'mounting-plane' } })
131
- row = fm.applyFigureAuthoring(row, { kind: 'attach', part: 'roller', face: 'bottom', to: { part: 'frame', face: 'top' } })
132
- row = fm.applyFigureAuthoring(row, { kind: 'make-repeat', part: 'roller', axis: 'x', pitch: 200 })
133
- assert.match(why(row, 'frame')!, /part-row-along .*"part":"frame".*"rows":"roller"/, '줄이 따라 놓인 부품')
134
-
135
- /* A refusal no sentence knows is still said as a refusal, with its code -- never the kernel's English. */
136
- const unknown = editRefusalOf(Object.assign(new Error('EDIT_TARGET: x: something in English'), { code: 'EDIT_TARGET' }), say)
137
- assert.match(unknown, /^figure\.text\.edit-refused .*"code":"EDIT_TARGET"/)
138
- assert.doesNotMatch(unknown, /something in English/)
116
+ const declared = act(a, { kind: 'declare-occupancy', proposal: fm.proposeFigureOccupancy(a), placement: 'floor' })
117
+ const why = (asset: FigureAsset, part: string) => partRefusalOf(refusalOf(() => fm.editFigurePart(asset, { kind: 'remove-part', target: part })), part, keyed)
118
+ assert.match(why(declared, 'shade')!, /part-in-declared-space .*"part":"shade"/, '잎인데 공간 선언에 들어 있다')
119
+ assert.match(why(conveyor(), 'frame')!, /part-row-along .*"part":"frame".*"rows":"roller"/, '줄이 따라 놓인 부품')
120
+ let hung = fm.addFigureBox(conveyor(), { id: 'cap', size: { x: 20, y: 20, z: 20 }, position: { x: 0, y: 0, z: 0 } })
121
+ hung = act(hung, { kind: 'attach', part: 'cap', face: 'bottom', to: { part: 'roller', face: 'top' } })
122
+ assert.match(why(hung, 'roller')!, /part-row-carries .*"part":"roller".*"others":"cap"/, '줄에 매달린 것')
139
123
  })
140
124
 
141
- test('공간 선언에 든 부품 — 한 번 눌러 지우고, 공간은 남은 부품에 다시 맞추며 그렇다고 한 줄 말한다(묻지 않는다, 2026-09-27)', async () => {
142
- const { inDeclaredRoom } = await import('../client/modeller/refusals.ts')
143
- const fm = await import('@hatiolab/figure-model')
125
+ test('공간 선언에 든 부품 — 한 번 눌러 지우고, 공간은 남은 부품에 다시 맞추며 그렇다고 한 줄 말한다(묻지 않는다, 2026-09-27)', () => {
144
126
  let a = fm.addFigureBox(fm.createFigureAsset('Gate'), { id: 'housing', size: { x: 300, y: 1000, z: 300 }, position: { x: 0, y: 0, z: 0 } })
145
127
  a = fm.addFigureBox(a, { id: 'stray', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
146
- a = fm.applyFigureAuthoring(a, { kind: 'attach', part: 'housing', face: 'bottom', to: { plane: 'mounting-plane' } })
147
- a = fm.applyFigureAuthoring(a, { kind: 'attach', part: 'stray', face: 'bottom', to: { plane: 'mounting-plane' } })
148
- a = fm.applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: fm.proposeFigureOccupancy(a), placement: 'floor' })
149
- let refused: unknown
150
- try {
151
- fm.editFigurePart(a, { kind: 'remove-part', target: 'stray' })
152
- } catch (e) {
153
- refused = e
154
- }
155
- assert.equal(inDeclaredRoom(refused), true)
156
- assert.equal(inDeclaredRoom(Object.assign(new Error('PART_REFERENCED: top: top holds leg; take it off first'), { code: 'PART_REFERENCED' })), false, '매단 것은 이 길이 아니다')
128
+ a = act(a, { kind: 'attach', part: 'housing', face: 'bottom', to: { plane: 'mounting-plane' } })
129
+ a = act(a, { kind: 'attach', part: 'stray', face: 'bottom', to: { plane: 'mounting-plane' } })
130
+ a = act(a, { kind: 'declare-occupancy', proposal: fm.proposeFigureOccupancy(a), placement: 'floor' })
131
+ assert.equal(inDeclaredRoom(refusalOf(() => fm.editFigurePart(a, { kind: 'remove-part', target: 'stray' }))), true)
132
+ assert.equal(inDeclaredRoom(refusalOf(() => fm.editFigurePart(conveyor(), { kind: 'remove-part', target: 'frame' }))), false, '줄은 이 길이 아니다')
157
133
  const after = fm.editFigurePart(a, { kind: 'remove-part', target: 'stray', refitRoom: true })
158
134
  assert.equal(after.document.model.nodes.some(n => n.id === 'stray'), false)
159
135
  assert.ok(after.occupancy?.bounds, '공간은 남은 부품으로 다시 선언된다')
@@ -165,45 +141,175 @@ test('공간 선언에 든 부품 — 한 번 눌러 지우고, 공간은 남은
165
141
  assert.ok(ko['figure.text.removal-space-refitted'])
166
142
  })
167
143
 
168
- test('배치를 바꿀 수 없는 까닭 — 평면에 붙은 부품과 새 평면이 받는 면을 말한다(벽 배치, 09-25)', async () => {
169
- const { placementRefusalOf } = await import('../client/modeller/refusals.ts')
170
- const fm = await import('@hatiolab/figure-model')
171
- let a = fm.addFigureBox(fm.createFigureAsset('Post'), { id: 'post', size: { x: 100, y: 1000, z: 100 }, position: { x: 0, y: 0, z: 0 } })
172
- a = fm.applyFigureAuthoring(a, { kind: 'attach', part: 'post', face: 'bottom', to: { plane: 'mounting-plane' } })
173
- let refused: unknown
174
- try {
175
- fm.applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: fm.proposeFigureOccupancy(a), placement: 'wall' as never })
176
- } catch (e) {
177
- refused = e
178
- }
144
+ test('배치를 바꿀 수 없는 까닭 — 평면에 붙은 부품과 새 평면이 받는 면을 말한다(벽 배치, 09-25)', () => {
145
+ const a = act(fm.addFigureBox(fm.createFigureAsset('Post'), { id: 'post', size: { x: 100, y: 1000, z: 100 }, position: { x: 0, y: 0, z: 0 } }), {
146
+ kind: 'attach',
147
+ part: 'post',
148
+ face: 'bottom',
149
+ to: { plane: 'mounting-plane' }
150
+ })
151
+ const refused = refusalOf(() => act(a, { kind: 'declare-occupancy', proposal: fm.proposeFigureOccupancy(a), placement: 'wall' }))
179
152
  const said = placementRefusalOf(refused, say)!
180
153
  assert.ok(said.includes('post (bottom)') && said.includes('벽 기반') && said.includes('back'), said)
181
- assert.equal(placementRefusalOf(new Error('x'), say), null)
154
+ assert.equal(editRefusalOf(refused, say), said, '편집 거절의 말도 같다')
155
+ assert.equal(placementRefusalOf(refusalOf(() => act(box(), declaring({ min: 0, max: 4000 }))), say), null)
182
156
  /* The placement list sends its command through the page, not the editor: the page says it in words too, and the list goes back. */
183
157
  const page = readFileSync(fileURLToPath(new URL('../client/pages/figure-modeller-page.ts', import.meta.url)), 'utf8')
184
- assert.ok(page.includes('placementRefusalOf(error, (key, params) => i18next.t(key, params))'), '페이지 경로도 사람의 말로')
158
+ assert.ok(page.includes('editRefusalOf(error, (key, params) => i18next.t(key, params))'), '페이지 경로도 사람의 말로')
185
159
  const props = readFileSync(fileURLToPath(new URL('../client/modeller/figure-properties.ts', import.meta.url)), 'utf8')
186
160
  assert.ok(props.includes('select.value = current'), '거절되면 목록이 원래 배치로 돌아온다')
187
161
  })
188
162
 
189
- test('범위 밖 값은 받을 수 있는 범위를 숫자로 말한다 — 커널의 영어가 아니다 (Figure 레인 :3300, 2026-09-26)', async () => {
190
- const { inputRangeRefusalOf } = await import('../client/modeller/refusals.ts')
191
- const range = (message: string) => Object.assign(new Error(message), { code: 'INPUT_RANGE' })
192
- const size = inputRangeRefusalOf(range('INPUT_RANGE: size.x: 6021100 outside [300,1200]'), say)!
193
- assert.ok(size.includes('X') && size.includes('300') && size.includes('1,200') && size.includes('6,021,100'), size)
194
- assert.ok(!/outside|INPUT_RANGE/.test(size))
195
- const other = inputRangeRefusalOf(range('INPUT_RANGE: lift.stroke: -5 outside [0,1]'), say)!
163
+ test('범위 밖 값은 받을 수 있는 범위를 숫자로 말한다 — 커널의 영어가 아니다 (Figure 레인 :3300, 2026-09-26)', () => {
164
+ const sized = box()
165
+ const size = inputRangeRefusalOf(refusalOf(() => fm.compileFigureAsset({ ...sized, designInputs: { ...sized.designInputs, 'size.x': 6021100 } }).evaluate()), say)
166
+ const lift = act(box(), { kind: 'add-design-input', id: 'lift', unit: 'mm', min: 0, max: 1, value: 0 })
167
+ const other = inputRangeRefusalOf(refusalOf(() => fm.compileFigureAsset({ ...lift, designInputs: { ...lift.designInputs, lift: -5 } }).evaluate()), say)!
196
168
  assert.ok(other.includes('0') && other.includes('1') && other.includes('-5'), other)
197
- assert.equal(inputRangeRefusalOf(range('INPUT_RANGE: steps: at the length it is authored (100 mm) a pitch under 1 mm makes more than 50 copies'), say), null)
198
- assert.equal(inputRangeRefusalOf(Object.assign(new Error('x'), { code: 'EDIT_TARGET' }), say), null)
169
+ assert.doesNotMatch(other, /outside|INPUT_RANGE/)
170
+ /* The figure's size is said by its axis -- when the figure declares one; a box made here declares none. */
171
+ if (size) assert.ok(size.includes('X') && size.includes('6,021,100'), size)
172
+ const sizing = act(box(), declaring({ min: 300, max: 1200 }))
173
+ const axis = inputRangeRefusalOf(refusalOf(() => fm.compileFigureAsset({ ...sizing, designInputs: { ...sizing.designInputs, 'size.x': 6021100 } }).evaluate()), say)!
174
+ assert.ok(axis.includes('X') && axis.includes('300') && axis.includes('1,200') && axis.includes('6,021,100'), axis)
175
+ const added = editRefusalOf(refusalOf(() => act(box(), { kind: 'add-design-input', id: 'lift', unit: 'mm', min: 0, max: 10, value: 20 })), say)
176
+ assert.ok(added.includes('20') && added.includes('10') && !added.includes('INPUT_RANGE'), `입력을 더할 때의 범위 밖 값도 말한다 (전에는 코드만): ${added}`)
177
+ assert.equal(inputRangeRefusalOf(refusalOf(() => act(box(), declaring({ min: 0, max: 4000 }))), say), null)
178
+ })
179
+
180
+ test('줄 간격이 너무 좁으면 저작한 길이 · 가장 작은 간격 · 개수 한도를 말한다', () => {
181
+ /* A row made before rows lived in their fastenings: its pitch is the graph's (a figure saved that way). */
182
+ const legacy = structuredClone(conveyor())
183
+ for (const j of legacy.joints!) delete (j as { repeat?: unknown }).repeat
184
+ const refused = refusalOf(() => act(legacy, { kind: 'set-repeat', part: 'roller', pitch: 1 }))
185
+ const said = repeatPitchRefusalOf(refused, say)!
186
+ assert.ok(said.includes('20 mm') && said.includes('2,000 mm') && said.includes('100'), said)
187
+ assert.equal(editRefusalOf(refused, say), said, '편집 거절의 말도 같다')
188
+ assert.equal(inputRangeRefusalOf(refused, say), null, '값 범위의 말이 아니다')
189
+ })
190
+
191
+ test('줄이 한도를 넘으면 몇 개가 되는지와 한도를 말한다 — 코드만 보이지 않는다 (ADR-0127, RESOURCE_LIMIT · 줄 개수)', () => {
192
+ /* A row made in the modeller, its pitch too fine: 195 copies where a row takes 100. */
193
+ const fine = editRefusalOf(refusalOf(() => act(conveyor(), { kind: 'set-repeat', part: 'roller', pitch: 10 })), say)
194
+ assert.ok(fine.includes('195') && fine.includes('100') && !fine.includes('RESOURCE_LIMIT'), fine)
195
+ /* Copies asked for past what a row has. */
196
+ let a = fm.createFigureAsset('Conveyor')
197
+ a = fm.addFigureBox(a, { id: 'frame', size: { x: 2000, y: 100, z: 600 }, position: { x: 0, y: 0, z: 0 } })
198
+ a = fm.addFigureBox(a, { id: 'roller', size: { x: 10, y: 60, z: 560 }, position: { x: 0, y: 0, z: 0 } })
199
+ a = act(a, { kind: 'attach', part: 'frame', face: 'bottom', to: { plane: 'mounting-plane' } })
200
+ a = act(a, { kind: 'attach', part: 'roller', face: 'bottom', to: { part: 'frame', face: 'top' } })
201
+ const many = editRefusalOf(refusalOf(() => act(a, { kind: 'make-repeat', part: 'roller', axis: 'x', pitch: 12, count: 150 })), say)
202
+ assert.ok(many.includes('150') && many.includes('1~100') && !many.includes('GEOMETRY_DOMAIN'), many)
203
+ /* A saved figure whose row would expand past what a figure draws: ten thousand copies, each a roller and its cap. */
204
+ let capped = fm.addFigureBox(conveyor(), { id: 'cap', size: { x: 20, y: 20, z: 20 }, position: { x: 0, y: 0, z: 0 } })
205
+ capped = structuredClone(act(capped, { kind: 'attach', part: 'cap', face: 'bottom', to: { part: 'roller', face: 'top' } }))
206
+ const m = capped.document.model as unknown as { constants: Array<{ id: string; value: number }> }
207
+ m.constants.find(c => c.id === 'limit.count.100')!.value = 10000
208
+ m.constants.find(c => c.id === 'd.mm.200')!.value = 0.2
209
+ const bodies = editRefusalOf(refusalOf(() => fm.compileFigureAsset(capped)), say)
210
+ assert.ok(bodies.includes('19,402') && bodies.includes('10,000'), bodies)
211
+ })
212
+
213
+ test('기즈모로 옮길 수 없는 부품 — 관절이 자리를 정하는지, 붙은 면이 정하는지', () => {
214
+ assert.match(placedRefusalOf(refusalOf(() => act(conveyor(), { kind: 'nudge-part', part: 'roller', by: { x: 10 } })), '롤러', keyed)!, /gizmo-joint-places .*"part":"롤러"/)
215
+ assert.match(placedRefusalOf(refusalOf(() => act(conveyor(), { kind: 'scale-position', part: 'roller', axis: 'x' })), '롤러', keyed)!, /gizmo-fastening-places .*"part":"롤러"/)
216
+ assert.equal(placedRefusalOf(refusalOf(() => act(conveyor(), { kind: 'set-repeat', part: 'frame', pitch: 10 })), '프레임', keyed), null, '다른 EDIT_TARGET 은 이 말이 아니다')
217
+ })
218
+
219
+ test('부품 이름 — 이름 규칙은 커널 한 벌이고, 화면은 거절의 값으로 말한다 (ADR-0127)', () => {
220
+ const rename = (asset: FigureAsset, target: string, name: string) => refusalOf(() => fm.editFigurePart(asset, { kind: 'rename-part', target, name } as never))
221
+ assert.match(partNameRefusalOf(rename(conveyor(), 'frame', '1x'), keyed)!, /part-name-invalid .*"name":"1x"/)
222
+ assert.match(partNameRefusalOf(rename(conveyor(), 'frame', 'roller'), keyed)!, /part-name-taken .*"name":"roller"/)
223
+ /* An imported figure may name a part under another (`box.lid`): the third refusal is said as itself, not as one of the other two. */
224
+ const nested = JSON.parse(JSON.stringify(fm.addFigureBox(box(), { id: 'lid', size: { x: 400, y: 10, z: 200 }, position: { x: 0, y: 0, z: 0 } })).replaceAll('"lid', '"box.lid'))
225
+ const said = partNameRefusalOf(rename(nested, 'box', 'crate'), say)!
226
+ assert.ok(said.includes('box.lid'), said)
227
+ const editor = readFileSync(fileURLToPath(new URL('../client/modeller/editor.ts', import.meta.url)), 'utf8')
228
+ assert.doesNotMatch(editor, /a-zA-Z0-9_-/, '이름 규칙을 화면에 다시 쓰지 않는다')
229
+ })
230
+
231
+ test('단면 · 속 공간 — 모서리, 벽, 막힌 끝, 붙은 면을 거절의 값으로 말한다', () => {
232
+ assert.match(editRefusalOf(refusalOf(() => act(box(), { kind: 'set-section-corner', part: 'box', corner: 500 })), keyed), /^figure\.text\.section-corner-too-large/)
233
+ assert.match(editRefusalOf(refusalOf(() => act(box(), { kind: 'set-cavity', part: 'box', cavity: { wall: 150, start: { closed: 10 }, end: 'open' } })), keyed), /^figure\.text\.section-wall-too-thick/)
234
+ assert.match(editRefusalOf(refusalOf(() => act(box(), { kind: 'set-cavity', part: 'box', cavity: { wall: 10, start: { closed: 200 }, end: { closed: 200 } } })), keyed), /^figure\.text\.section-ends-too-thick/)
235
+ let tray = act(box(), { kind: 'set-cavity', part: 'box', cavity: { wall: 10, start: { closed: 15 }, end: 'open' } })
236
+ tray = fm.addFigureBox(tray, { id: 'divider', size: { x: 10, y: 100, z: 150 }, position: { x: 0, y: 0, z: 0 } })
237
+ tray = act(tray, { kind: 'attach', part: 'divider', face: 'bottom', to: { part: 'box', face: 'inside-bottom' } })
238
+ const faces = editRefusalOf(refusalOf(() => act(tray, { kind: 'set-cavity', part: 'box', cavity: { wall: 10, start: 'open', end: 'open' } })), say)
239
+ assert.ok(faces.includes(ko['figure.label.face-inside-bottom']), faces)
240
+ /* A section finding the screen has no words for is said with its code. */
241
+ const wedge = fm.addFigurePrimitive(fm.createFigureAsset('Drawn'), {
242
+ id: 'wedge',
243
+ kind: 'polygon',
244
+ dimensions: { height: 100 },
245
+ points: [
246
+ { x: -50, z: -40 },
247
+ { x: 50, z: -40 },
248
+ { x: 0, z: 40 }
249
+ ],
250
+ position: { x: 0, y: 50, z: 0 }
251
+ })
252
+ const crossed = [
253
+ { id: 'p1', x: -50, z: -40 },
254
+ { id: 'p2', x: 50, z: 40 },
255
+ { id: 'p3', x: 50, z: -40 },
256
+ { id: 'p4', x: -50, z: 40 }
257
+ ]
258
+ assert.match(editRefusalOf(refusalOf(() => act(wedge, { kind: 'set-section', part: 'wedge', points: crossed })), keyed), /^figure\.text\.edit-refused .*"code":"SECTION_INVALID"/)
259
+ })
260
+
261
+ test('도형의 한도 — 노드 수, 저장 크기, 중첩 깊이를 숫자로 말한다 (「노드 한도」로 잘못 말하던 결함)', () => {
262
+ const doc = structuredClone(box().document) as { model: { nodes: unknown[] } }
263
+ for (let i = 0; i <= fm.FIGURE_DOCUMENT_NODE_LIMIT; i++) doc.model.nodes.push({ id: `k${i}`, op: 'const@1', args: [], outputs: {} })
264
+ const nodes = editRefusalOf(refusalOf(() => fm.compileFigureDocument(doc as never)), say)
265
+ assert.ok(nodes.includes(String(fm.FIGURE_DOCUMENT_NODE_LIMIT)), nodes)
266
+ const large = editRefusalOf(refusalOf(() => fm.compileFigureAsset({ ...box(), name: 'x'.repeat(fm.FIGURE_JSON_MAX_BYTES) })), say)
267
+ assert.ok(large.includes('저장 한도 1,024 KiB') && /1,02[5-9] KiB 로/.test(large) && !large.includes('노드'), large)
268
+ const unmeasured = editRefusalOf(refusalOf(() => fm.parseFigureJson(`"${'a'.repeat(fm.FIGURE_JSON_MAX_BYTES)}"`)), say)
269
+ assert.ok(unmeasured.includes('1,024 KiB') && !unmeasured.includes('노드'), unmeasured)
270
+ const deep = editRefusalOf(refusalOf(() => fm.parseFigureJson('['.repeat(70) + ']'.repeat(70))), say)
271
+ assert.ok(deep.includes('64') && !deep.includes('노드'), deep)
272
+ })
273
+
274
+ test('레시피가 막히면 몇 번째 단계에서 왜 막혔는지 사람의 말로 — 단계 안의 거절도 값을 잃지 않는다 (2026-09-27)', () => {
275
+ const refused = refusalOf(() => fm.applyFigureProposal(box(), [{ kind: 'attach', part: 'nowhere', face: 'bottom', to: { plane: 'mounting-plane' } }, declaring({ min: 500, max: 4000 })] as never))
276
+ assert.match(recipeRefusalOf(refused, keyed)!, /^figure\.text\.recipe-step-refused .*"step":"1"/, '첫째 단계, 코드와 함께')
277
+ const sized = refusalOf(() => fm.applyFigureProposal(box(), [declaring({ min: 500, max: 4000 })] as never))
278
+ const told = recipeRefusalOf(sized, say)!
279
+ assert.ok(told.includes('1 번째') && told.includes('400 mm') && told.includes('4,000'), told)
280
+ assert.equal(recipeRefusalOf(refusalOf(() => act(box(), declaring({ min: 0, max: 4000 }))), keyed), null, '단계가 아닌 거절은 다른 말이 맡는다')
199
281
  })
200
282
 
201
- test('레시피가 막히면 몇 번째 단계에서 왜 막혔는지 사람의 말로 — 커널의 영어가 아니라 (2026-09-27)', async () => {
202
- const { recipeRefusalOf } = await import('../client/modeller/refusals.ts')
203
- const say = (key: string, p: Record<string, string>) => `${key} ${JSON.stringify(p)}`
204
- const refusal = Object.assign(new Error('EDIT_TARGET: steps[2]: attach: door is wider than the opening'), { code: 'EDIT_TARGET', path: 'steps[2]' })
205
- const told = recipeRefusalOf(refusal, say)!
206
- assert.match(told, /^figure\.text\.recipe-step-refused .*"step":"3".*"code":"EDIT_TARGET"/, '셋째 단계, 코드와 함께')
207
- assert.doesNotMatch(told, /wider than/)
208
- assert.equal(recipeRefusalOf(Object.assign(new Error('x'), { code: 'SCHEMA', path: 'steps' }), say), null, '단계가 아닌 거절은 다른 말이 맡는다')
283
+ test('말이 없는 거절은 코드로 — 커널의 영어가 아니다', () => {
284
+ const refused = refusalOf(() => act(conveyor(), { kind: 'set-repeat', part: 'frame', pitch: 10 }))
285
+ const said = editRefusalOf(refused, keyed)
286
+ assert.match(said, /^figure\.text\.edit-refused .*"code":"EDIT_TARGET"/)
287
+ assert.doesNotMatch(said, /repeated part/)
288
+ })
289
+
290
+ /*
291
+ * ADR-0127 decision 4: the screen says a refusal from its code and details, never from the kernel's sentence. A
292
+ * regular expression handed an error's message anywhere in the client brings back what went quiet on 2026-10-11.
293
+ */
294
+ test('화면은 커널의 거절 문장에 정규식을 대지 않는다 (ADR-0127)', () => {
295
+ const root = fileURLToPath(new URL('../client', import.meta.url))
296
+ const files: string[] = []
297
+ const walk = (dir: string) => {
298
+ for (const name of readdirSync(dir)) {
299
+ const path = join(dir, name)
300
+ if (statSync(path).isDirectory()) walk(path)
301
+ else if (name.endsWith('.ts')) files.push(path)
302
+ }
303
+ }
304
+ walk(root)
305
+ const reading = /\.(?:test|exec|match|matchAll|replace|search)\(\s*(?:message|msg|(?:error|err|e)\.message|String\((?:error|err|e)\))\b|\b(?:message|(?:error|err|e)\.message)\.(?:match|matchAll|replace|search)\(/
306
+ const found = files.flatMap(file =>
307
+ readFileSync(file, 'utf8')
308
+ .split('\n')
309
+ .map((line, i) => ({ file, line: i + 1, text: line }))
310
+ .filter(l => reading.test(l.text))
311
+ )
312
+ assert.deepEqual(found.map(f => `${f.file.slice(root.length)}:${f.line}: ${f.text.trim()}`), [])
313
+ const refusals = readFileSync(join(root, 'modeller/refusals.ts'), 'utf8')
314
+ assert.doesNotMatch(refusals, /\.message\b/, 'refusals.ts 는 문장을 읽지 않는다')
209
315
  })
@@ -10,15 +10,7 @@ import { fileURLToPath } from 'node:url'
10
10
  const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
11
11
 
12
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')
13
+ /* The state beside the save and the way to the release section are checked on the page: test/browser/product-page.check.mjs. */
22
14
  const ko = JSON.parse(read('../translations/ko.json'))
23
15
  assert.equal(ko['figure.text.release-state-draft-over'].replace('{version}', '3'), '초안 · 보드는 v3 사용')
24
16
  for (const lang of ['en', 'ja', 'ko', 'ms', 'zh', 'es']) {
@@ -11,8 +11,6 @@
11
11
  */
12
12
  import { test } from 'node:test'
13
13
  import assert from 'node:assert/strict'
14
- import { readFileSync } from 'node:fs'
15
- import { fileURLToPath } from 'node:url'
16
14
 
17
15
  import {
18
16
  addFigureBox,
@@ -52,23 +50,4 @@ test('선언한 공간을 넘은 부품도 같은 한 번으로 풀리고, 배
52
50
  assert.equal(fitted.placement, 'center', '이미 선언된 배치 기준을 바꾸지 않는다')
53
51
  })
54
52
 
55
- const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
56
-
57
- test('모델러가 발행 패널을 걸고, 패널이 내는 셋을 받는다', () => {
58
- const page = read('../client/pages/figure-modeller-page.ts')
59
-
60
- assert.ok(/<figure-history-panel/.test(page), '패널을 건다')
61
- for (const event of ['fit-base-to-parts', 'figure-released', 'figure-reverted']) {
62
- assert.ok(page.includes(`@${event}=`), `패널이 내는 ${event} 를 받는 자리가 없다 — 눌러도 아무 일이 없다`)
63
- }
64
- assert.ok(/occupancyFromParts\(shown\.asset\)/.test(page), '「부품에 맞추기」가 공간 선언으로 이어진다')
65
- /* 없으면 셸이 페이지를 내용 폭(편집기 660 + 패널 320)으로 세워 패널이 오른쪽 밖으로 나간다 — 945 px 에서 봤다. */
66
- assert.ok(/:host \{[^}]*overflow: hidden/.test(page), '페이지가 주어진 폭 안에 든다')
67
- })
68
-
69
- test('패널은 받는 화면이 있는 조치만 그린다 — 「바닥에 앉히기」는 없다', () => {
70
- const panel = read('../client/modeller/figure-history-panel.ts')
71
-
72
- assert.ok(!/settle-parts-on-floor|apply-sizing-fix|restore-released-footprint/.test(panel), '받는 화면이 없는 조치를 내지 않는다')
73
- assert.ok(/code === 'part-outside-occupancy' \|\| code === 'occupancy-undeclared'/.test(panel), '두 판정에 맞추기를 낸다')
74
- })
53
+ /* The page hangs the panel and takes what it sends -- fit, released, reverted -- checked in test/browser/product-page.check.mjs. */