@things-factory/figure-ui 10.1.223 → 10.1.225

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 (80) hide show
  1. package/client/modeller/canvas.ts +2 -2
  2. package/client/modeller/consumer-capabilities.ts +66 -0
  3. package/client/modeller/data-interface.ts +230 -80
  4. package/client/modeller/editor-styles.ts +3 -2
  5. package/client/modeller/editor.ts +17 -8
  6. package/client/modeller/figure-capability-panel.ts +151 -1
  7. package/client/modeller/figure-clips.ts +1 -1
  8. package/client/modeller/figure-data-panel.ts +509 -199
  9. package/client/modeller/figure-links.ts +130 -12
  10. package/client/modeller/figure-preview.ts +65 -8
  11. package/client/modeller/figure-slot-layout-panel.ts +7 -3
  12. package/client/modeller/links.ts +58 -0
  13. package/client/modeller/product-session.ts +3 -2
  14. package/client/modeller/refusals.ts +12 -4
  15. package/client/modeller/slot-layout-host.ts +76 -5
  16. package/client/pages/figure-modeller-page.ts +2 -2
  17. package/dist-client/modeller/canvas.js +2 -2
  18. package/dist-client/modeller/canvas.js.map +1 -1
  19. package/dist-client/modeller/consumer-capabilities.d.ts +29 -0
  20. package/dist-client/modeller/consumer-capabilities.js +60 -0
  21. package/dist-client/modeller/consumer-capabilities.js.map +1 -0
  22. package/dist-client/modeller/data-interface.d.ts +83 -17
  23. package/dist-client/modeller/data-interface.js +194 -82
  24. package/dist-client/modeller/data-interface.js.map +1 -1
  25. package/dist-client/modeller/editor-styles.js +3 -2
  26. package/dist-client/modeller/editor-styles.js.map +1 -1
  27. package/dist-client/modeller/editor.d.ts +5 -2
  28. package/dist-client/modeller/editor.js +21 -8
  29. package/dist-client/modeller/editor.js.map +1 -1
  30. package/dist-client/modeller/figure-capability-panel.d.ts +12 -0
  31. package/dist-client/modeller/figure-capability-panel.js +129 -1
  32. package/dist-client/modeller/figure-capability-panel.js.map +1 -1
  33. package/dist-client/modeller/figure-clips.js +1 -1
  34. package/dist-client/modeller/figure-clips.js.map +1 -1
  35. package/dist-client/modeller/figure-data-panel.d.ts +57 -13
  36. package/dist-client/modeller/figure-data-panel.js +476 -191
  37. package/dist-client/modeller/figure-data-panel.js.map +1 -1
  38. package/dist-client/modeller/figure-links.d.ts +11 -0
  39. package/dist-client/modeller/figure-links.js +137 -16
  40. package/dist-client/modeller/figure-links.js.map +1 -1
  41. package/dist-client/modeller/figure-preview.d.ts +4 -0
  42. package/dist-client/modeller/figure-preview.js +65 -7
  43. package/dist-client/modeller/figure-preview.js.map +1 -1
  44. package/dist-client/modeller/figure-slot-layout-panel.js +6 -4
  45. package/dist-client/modeller/figure-slot-layout-panel.js.map +1 -1
  46. package/dist-client/modeller/links.d.ts +22 -0
  47. package/dist-client/modeller/links.js +48 -0
  48. package/dist-client/modeller/links.js.map +1 -1
  49. package/dist-client/modeller/product-session.d.ts +4 -2
  50. package/dist-client/modeller/product-session.js +3 -2
  51. package/dist-client/modeller/product-session.js.map +1 -1
  52. package/dist-client/modeller/refusals.js +14 -6
  53. package/dist-client/modeller/refusals.js.map +1 -1
  54. package/dist-client/modeller/slot-layout-host.d.ts +28 -3
  55. package/dist-client/modeller/slot-layout-host.js +68 -6
  56. package/dist-client/modeller/slot-layout-host.js.map +1 -1
  57. package/dist-client/pages/figure-modeller-page.js +2 -2
  58. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  59. package/dist-client/tsconfig.tsbuildinfo +1 -1
  60. package/package.json +8 -8
  61. package/test/ai-proposal.test.ts +2 -2
  62. package/test/browser/data-table-page.js +9 -0
  63. package/test/browser/links.check.mjs +25 -3
  64. package/test/browser/product-page.check.mjs +25 -0
  65. package/test/browser/side-panel-sweep.check.mjs +182 -0
  66. package/test/consumer-capabilities.test.ts +116 -0
  67. package/test/data-motion.test.ts +94 -27
  68. package/test/figure-assistant-surface.test.ts +1 -1
  69. package/test/first-save.test.ts +2 -2
  70. package/test/links.test.ts +15 -0
  71. package/test/product-session.test.ts +5 -5
  72. package/test/proposal-record.test.ts +3 -3
  73. package/test/revert-keeping.test.ts +1 -1
  74. package/test/slot-layout-host.test.ts +63 -5
  75. package/translations/en.json +82 -23
  76. package/translations/es.json +82 -23
  77. package/translations/ja.json +82 -23
  78. package/translations/ko.json +82 -23
  79. package/translations/ms.json +82 -23
  80. package/translations/zh.json +82 -23
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-ui",
3
- "version": "10.1.223",
3
+ "version": "10.1.225",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -18,7 +18,7 @@
18
18
  "scripts": {
19
19
  "build": "npm run build:server && npm run build:client",
20
20
  "test": "FIGURE_FREEZE_COMPILED=1 node --import ./test/resolve-ts-siblings.mjs --test test/*.test.ts",
21
- "test:browser": "node --test test/browser/build.check.mjs test/browser/modeller.check.mjs test/browser/modeller-revision.check.mjs test/browser/editing.check.mjs test/browser/links.check.mjs test/browser/section.check.mjs test/browser/screen.check.mjs test/browser/product-page.check.mjs test/browser/parts.check.mjs test/browser/places-mesh.check.mjs test/browser/part-ids.check.mjs test/browser/fix-alignment.check.mjs test/browser/mesh-select.check.mjs test/browser/row-preview.check.mjs test/browser/detail-tabs.check.mjs test/browser/props.check.mjs test/browser/retire.check.mjs test/browser/namespace.check.mjs test/browser/public.check.mjs test/browser/category.check.mjs test/browser/property-section.check.mjs test/browser/list.check.mjs test/browser/item.check.mjs",
21
+ "test:browser": "node --test test/browser/build.check.mjs test/browser/modeller.check.mjs test/browser/modeller-revision.check.mjs test/browser/editing.check.mjs test/browser/links.check.mjs test/browser/section.check.mjs test/browser/screen.check.mjs test/browser/product-page.check.mjs test/browser/parts.check.mjs test/browser/places-mesh.check.mjs test/browser/part-ids.check.mjs test/browser/fix-alignment.check.mjs test/browser/mesh-select.check.mjs test/browser/row-preview.check.mjs test/browser/detail-tabs.check.mjs test/browser/props.check.mjs test/browser/retire.check.mjs test/browser/namespace.check.mjs test/browser/public.check.mjs test/browser/category.check.mjs test/browser/property-section.check.mjs test/browser/list.check.mjs test/browser/item.check.mjs test/browser/side-panel-sweep.check.mjs",
22
22
  "copy:files": "copyfiles -e \"./client/**/*.{ts,js,json}\" -u 1 \"./client/**/*\" dist-client",
23
23
  "build:client": "npm run copy:files && npx tsc --p ./client/tsconfig.json",
24
24
  "build:server": "npx tsc --p ./server/tsconfig.json",
@@ -27,8 +27,8 @@
27
27
  "clean": "npm run clean:server && npm run clean:client"
28
28
  },
29
29
  "dependencies": {
30
- "@hatiolab/figure-model": "^0.11.1",
31
- "@hatiolab/things-scene": "^10.1.101",
30
+ "@hatiolab/figure-model": "^0.12.0",
31
+ "@hatiolab/things-scene": "^10.1.102",
32
32
  "@material/web": "^2.0.0",
33
33
  "@operato/data-grist": "^10.29.43",
34
34
  "@operato/graphql": "^10.29.43",
@@ -39,14 +39,14 @@
39
39
  "@operato/p13n": "^10.29.43",
40
40
  "@operato/popup": "^10.29.43",
41
41
  "@operato/shell": "^10.29.43",
42
- "@operato/slot-layout-editor": "^10.29.43",
42
+ "@operato/slot-layout-editor": "^10.29.57",
43
43
  "@operato/styles": "^10.0.0",
44
44
  "@operato/utils": "^10.29.43",
45
45
  "@things-factory/ai-assistant": "^10.1.223",
46
- "@things-factory/figure-service": "^10.1.223",
47
- "@things-factory/figure-viewer": "^10.1.222",
46
+ "@things-factory/figure-service": "^10.1.224",
47
+ "@things-factory/figure-viewer": "^10.1.224",
48
48
  "@things-factory/setting-base": "^10.1.223",
49
49
  "three": "^0.185.1"
50
50
  },
51
- "gitHead": "868f2026b8373371ad04f42b0a097964d0d0ed0f"
51
+ "gitHead": "9929ca76f614ff84bea1f61dabe0e1b7f00cd18a"
52
52
  }
@@ -157,7 +157,7 @@ test('the same new figure\'s card in another tab before the first save makes a s
157
157
  })
158
158
  const made = [{ kind: 'add-part', part: { id: 'lid', kind: 'box', dimensions: { width: 100, height: 10, depth: 100 } } }] as any[]
159
159
  const first = tab()
160
- first.startNew()
160
+ first.startNew({ name: 'New' })
161
161
  const detail: FigureApplyDetail = { key: K, steps: made, create: true }
162
162
  const here = { ...taker({}), offer: (d: FigureApplyDetail) => (takeFigureOffer(first, d, { onSavedFigure: false, say }), d) }
163
163
  assert.equal((await runFigureProposal(detail, here, say)).verdict, 'accepted')
@@ -166,7 +166,7 @@ test('the same new figure\'s card in another tab before the first save makes a s
166
166
  const there = {
167
167
  ...taker({}),
168
168
  async openNew(s: FigureProposalStep[], key?: string) {
169
- second.startNew()
169
+ second.startNew({ name: 'New' })
170
170
  second.createUnder(key!)
171
171
  second.applyProposal(s, second.read().revision!, second.read().context!, key)
172
172
  return { applied: true }
@@ -0,0 +1,9 @@
1
+ /* Manual check of the data tab's tables (ADR-0166): the real editor on the robot dog, in Korean, over a store in memory. */
2
+ import './modeller-page.js'
3
+ import { i18next } from '@operato/i18n'
4
+ import { robotDogFigure, serializeFigureAsset } from '@hatiolab/figure-model'
5
+
6
+ const ko = await fetch('/ko.json').then(r => r.json())
7
+ i18next.addResourceBundle('ko', 'translations', ko, true, true)
8
+ await i18next.changeLanguage('ko')
9
+ await window.harness.mount('dog', serializeFigureAsset(robotDogFigure().asset))
@@ -350,13 +350,14 @@ describe('a row\'s pitch', () => {
350
350
  })
351
351
 
352
352
  describe('holding places on a part', () => {
353
- test('a place needs kinds it takes; a figure that holds nothing says so, and declaring it holds things is one step', async () => {
353
+ test('a place takes any item until kinds are named; a figure that holds nothing says so, and declaring it holds things is one step', async () => {
354
354
  await mount('box')
355
355
  await pick('box')
356
356
  /* What the figure does is a tab again (user 2026-10-17). */
357
357
  await tab('capability')
358
- assert.equal((await at('button[act]', 'figure.button.add-place'))?.disabled, true, 'no kinds: no place')
359
- await type(await handle('input[placeholder="BOX, TOTE"]'), 'BOX')
358
+ /* No kinds named: it takes any item (ADR-0051 §4.1 decision 5, 2026-10-27). */
359
+ assert.equal((await at('button[act]', 'figure.button.add-place'))?.disabled, false, 'no kinds: a place for any item')
360
+ await type(await handle('input[placeholder="figure.text.place-accepts-any"]'), 'BOX')
360
361
  assert.equal((await at('button[act]', 'figure.button.add-place'))?.disabled, false, 'a kind: a place may be made')
361
362
  assert.equal((await all('p[hint]', 'figure.text.place-needs-holding')).length, 1, 'said: nothing is passed without holding')
362
363
  const start = await state()
@@ -365,6 +366,27 @@ describe('holding places on a part', () => {
365
366
  assert.ok((await source()).document.capabilities.names.includes('Capacity'))
366
367
  assert.deepEqual(await all('p[hint]', 'figure.text.place-needs-holding'), [], 'not said again')
367
368
  })
369
+
370
+ test('a place is changed under its own id: its types emptied, it takes any item, in one step', async () => {
371
+ let a = box(createFigureAsset('Gripper'), 'box', [200, 100, 200], [0, 50, 0])
372
+ a = applyFigureAuthoring(a, { kind: 'add-binding', id: 'box-grip-old', label: 'Grip', role: 'grip', part: 'box', face: 'top', accepts: ['BOX', 'WORKPIECE'], capacity: 1 })
373
+ await mount('box', a)
374
+ await pick('box')
375
+ await tab('capability')
376
+ await click(await at('button[edit-place]'), 'change the place')
377
+ const field = await handle('input[placeholder="figure.text.place-accepts-any"]')
378
+ assert.equal(await field.evaluate(i => i.value), 'BOX, WORKPIECE', 'its types are in the form')
379
+ await field.click({ clickCount: 3 })
380
+ await page().keyboard.press('Backspace')
381
+ await page().keyboard.press('Tab')
382
+ await settled()
383
+ const start = await state()
384
+ await click(await at('button[act]', 'figure.button.save-place'), 'save the place')
385
+ await oneStep(start)
386
+ const places = (await source()).document.capabilities.bindings
387
+ assert.deepEqual(places.map(b => [b.id, b.label, b.role, Object.hasOwn(b, 'accepts')]), [['box-grip-old', 'Grip', 'grip', false]])
388
+ assert.ok(await at('button[act]', 'figure.button.add-place'), 'the form adds again')
389
+ })
368
390
  })
369
391
 
370
392
  describe('a position worked out from the figure size', () => {
@@ -173,6 +173,11 @@ describe('the first save of a new figure', () => {
173
173
  return { first: popup.shadowRoot.querySelector('input')?.id, type: popup.shadowRoot.querySelector('#type').value, focused: active?.id }
174
174
  })
175
175
 
176
+ test('a new figure is named in the screen\'s words, the tab too (sweep 2026-10-27 U8)', async () => {
177
+ assert.equal(await read(() => product.page().session.read().name), 'figure.text.new-figure-name')
178
+ assert.equal(await read(() => product.page().context.title), 'figure.text.new-figure-name')
179
+ })
180
+
176
181
  test('before it is saved: the type can be written, held by the page, and the release waits', async () => {
177
182
  const field = await read(() => { const input = product.page().shadowRoot.querySelector('label[type-field] input'); return input && { disabled: input.disabled } })
178
183
  assert.deepEqual(field, { disabled: false })
@@ -242,6 +247,22 @@ describe('opening another figure', () => {
242
247
  })
243
248
  })
244
249
 
250
+ test('nothing open on the page: its properties say nothing of an empty figure (sweep 2026-10-27 U3)', async () => {
251
+ await open('a')
252
+ const empty = await read(async () => {
253
+ const page = product.page()
254
+ /* The state a page is in before it is told which figure, and after it is left: the session holds none. */
255
+ page.session.close()
256
+ const editor = product.deep('figure-editor')[0]
257
+ editor.requestUpdate()
258
+ await editor.updateComplete
259
+ return editor.shadowRoot.querySelectorAll('section[empty-figure]').length
260
+ })
261
+ /* Told another figure, the page opens it again: the checks after this one start from an open page. */
262
+ await open('b')
263
+ assert.equal(empty, 0)
264
+ })
265
+
245
266
  test('the row is emptied first: the type of the one before does not stand while the next one loads', async () => {
246
267
  const typeField = () => read(() => product.page().shadowRoot.querySelector('label[type-field] input')?.value)
247
268
  await open('a')
@@ -258,6 +279,10 @@ describe('opening another figure', () => {
258
279
  await until(() => !!window.letB, 'b asked for')
259
280
  await read(() => product.page().updateComplete)
260
281
  assert.equal(await typeField(), '')
282
+ /* Still loading, the figure is not an empty one: no 「add a part」 (sweep 2026-10-27 U3). */
283
+ await read(() => product.deep('figure-editor')[0].updateComplete)
284
+ assert.equal(await read(() => product.deep('figure-editor >>> section[empty-figure]').length), 0)
285
+ assert.equal(await read(() => product.page().context.title), 'figure.label.figure')
261
286
  await read(() => window.letB())
262
287
  await until(() => product.page().shadowRoot.querySelector('label[type-field] input')?.value === 'type-b', 'b\'s type')
263
288
  })
@@ -0,0 +1,182 @@
1
+ /*
2
+ * What the modeller sweep of 2026-10-27 found on the side panel and the preview, in a real browser
3
+ * (operato-figure docs/audits/modeller-ux-sweep-2026-10-27.md, U1 U2 U4 U5 U6 U7): each as a person would meet it -- a
4
+ * box that reads as a box, a heading nothing is drawn over, a delete that looks like one, the empty animation said in
5
+ * its own words, a field added by Enter, the preview's values starting in one column and its help in the bar's look.
6
+ * The page loads no translations, so the words read are i18n keys.
7
+ *
8
+ * Run: node --test test/browser/side-panel-sweep.check.mjs
9
+ */
10
+ import { test, describe, before, after } from 'node:test'
11
+ import assert from 'node:assert/strict'
12
+
13
+ import { addFigureBox, createFigureAsset, serializeFigureAsset } from '@hatiolab/figure-model'
14
+
15
+ import { buildPage } from './build.mjs'
16
+ import { openPage } from './serve.mjs'
17
+
18
+ const sleep = ms => new Promise(done => setTimeout(done, ms))
19
+
20
+ let opened
21
+ before(async () => {
22
+ opened = await openPage(await buildPage('links-page.js'))
23
+ })
24
+ after(async () => {
25
+ await opened?.close()
26
+ })
27
+
28
+ const page = () => opened.page
29
+ const read = (fn, ...args) => page().evaluate(fn, ...args)
30
+ const source = () => read(() => probe.source())
31
+ const settled = () => read(() => document.querySelector('figure-editor').updateComplete)
32
+
33
+ async function until(fn, args, what, ms = 8000) {
34
+ const deadline = Date.now() + ms
35
+ for (;;) {
36
+ const got = await read(fn, ...args)
37
+ if (got) return got
38
+ if (Date.now() > deadline) assert.fail(`waited for ${what}`)
39
+ await sleep(50)
40
+ }
41
+ }
42
+ async function mount(kind, text = null) {
43
+ await read((kind, text) => harness.mount(kind, text), kind, text)
44
+ await until(
45
+ () => {
46
+ try {
47
+ return probe.ids().some(id => !!probe.partOnPage(id))
48
+ } catch {
49
+ return false
50
+ }
51
+ },
52
+ [],
53
+ 'the figure drawn in 3D',
54
+ 15000
55
+ )
56
+ }
57
+ async function click(point, what) {
58
+ assert.ok(point, `${what} is on the page to click`)
59
+ await page().mouse.click(point.x, point.y)
60
+ await settled()
61
+ }
62
+ const tab = name => read(l => probe.tab(l), `figure.label.tab-short-${name}`)
63
+
64
+ /** Every element matching the selector through every shadow root, shown on the page. */
65
+ const DEEP = `(selector => {
66
+ const deep = (root = document) => [...root.querySelectorAll(selector)].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
67
+ return deep().filter(e => e.getBoundingClientRect().height > 0)
68
+ })`
69
+
70
+ describe('the side panel', () => {
71
+ before(() => mount('box'))
72
+
73
+ test('U1 the capability tab: the item box keeps its size beside its long note', async () => {
74
+ await click(await tab('capability'), 'the capability tab')
75
+ /* The note as Korean reads it: words that wrap (the key alone is one word that never does). */
76
+ const width = await read(DEEP => {
77
+ eval(DEEP)('label[item] em')[0].textContent = '홀더가 자리에 앉혀 나르는 물건의 모양입니다. 릴 · 트레이 · 매거진. 아이템 타입 표에서 이 피규어를 고를 수 있습니다.'
78
+ return eval(DEEP)('label[item] > input')[0]?.getBoundingClientRect().width
79
+ }, DEEP)
80
+ assert.ok(width >= 12, `the item box is ${width}px wide`)
81
+ })
82
+
83
+ test('U2 scrolled under the heading, the places chips are drawn below it, not over it', async () => {
84
+ /* A short window, so the panel scrolls. */
85
+ await page().setViewport({ width: 1400, height: 520 })
86
+ await click(await read(id => probe.row(id), 'box'), 'the box in the parts list')
87
+ await click(await tab('capability'), 'the capability tab')
88
+ const hit = await read(DEEP => {
89
+ const chips = eval(DEEP)('ox-input-select-buttons[places-by]')[0]
90
+ const subject = eval(DEEP)('div[subject]')[0]
91
+ if (!chips || !subject) return 'no chips or no heading'
92
+ let scroller = chips.parentNode
93
+ for (;;) {
94
+ if (!scroller) return 'nothing scrolls'
95
+ if (scroller instanceof ShadowRoot) scroller = scroller.host
96
+ else if (scroller instanceof Element && /auto|scroll/.test(getComputedStyle(scroller).overflowY) && scroller.scrollHeight > scroller.clientHeight) break
97
+ else scroller = scroller.parentNode
98
+ }
99
+ const head = subject.getBoundingClientRect()
100
+ const before = chips.getBoundingClientRect()
101
+ scroller.scrollTop += before.top - (head.top + head.height / 2) + 4
102
+ const at = chips.getBoundingClientRect()
103
+ const x = at.left + Math.min(40, at.width / 2),
104
+ y = at.top + 6
105
+ if (y > head.bottom || y < head.top) return `the chips stand at ${Math.round(y)}, the heading ${Math.round(head.top)}..${Math.round(head.bottom)}`
106
+ let el = document.elementFromPoint(x, y)
107
+ while (el?.shadowRoot) {
108
+ const inner = el.shadowRoot.elementFromPoint(x, y)
109
+ if (!inner || inner === el) break
110
+ el = inner
111
+ }
112
+ for (let n = el; n; n = n.parentNode instanceof ShadowRoot ? n.parentNode.host : n.parentNode) if (n === subject) return 'heading'
113
+ return el?.tagName.toLowerCase()
114
+ }, DEEP)
115
+ await page().setViewport({ width: 1400, height: 900 })
116
+ assert.equal(hit, 'heading')
117
+ })
118
+
119
+ test('U5 the motion tab: no animation yet is said in its own words, the controls\' words said once', async () => {
120
+ await click(await tab('motion'), 'the motion tab')
121
+ const hints = await read(DEEP => eval(DEEP)('p[hint]').map(p => p.textContent.trim()), DEEP)
122
+ assert.ok(hints.includes('figure.text.no-clips'), hints.join(' | '))
123
+ assert.equal(hints.filter(h => h === 'figure.text.no-controls').length, 1)
124
+ })
125
+
126
+ test('U6 the data tab: a field name and Enter adds the field', async () => {
127
+ await click(await tab('data'), 'the data tab')
128
+ await click(await read(() => look.at('input[aria-label="figure.label.data-name"]')), 'the field name')
129
+ await page().keyboard.type('speed')
130
+ await page().keyboard.press('Enter')
131
+ await settled()
132
+ await until(() => probe.source().dataInterface?.schema?.some(f => f.name === 'speed'), [], 'the field added')
133
+ assert.equal((await read(DEEP => eval(DEEP)('input[aria-label="figure.label.data-name"]')[0].value, DEEP)), '')
134
+ })
135
+
136
+ test('U4 a field opened: its delete is the bin, not a cross that reads as close', async () => {
137
+ await click(await read(() => look.at('button', 'speed')), 'the speed field')
138
+ const icon = await until(DEEP => eval(DEEP)('button[title="figure.button.remove-data-field"] md-icon')[0]?.textContent.trim(), [DEEP], 'the field opened')
139
+ assert.equal(icon, 'delete')
140
+ assert.ok((await source()).dataInterface.schema.some(f => f.name === 'speed'))
141
+ })
142
+ })
143
+
144
+ describe('the preview', () => {
145
+ /* A number and a text, of names far apart in length, each read by a table: the preview gives each a field. */
146
+ const asset = addFigureBox(createFigureAsset('Probe'), { id: 'box', size: { x: 400, y: 400, z: 400 }, position: { x: 0, y: 200, z: 0 } })
147
+ asset.dataInterface = {
148
+ version: 'figure-data-interface-2',
149
+ schema: [
150
+ { name: 'A', kind: 'number' },
151
+ { name: 'CARRIERSTATUS', kind: 'text' }
152
+ ],
153
+ bindings: [
154
+ { id: 'a', accessor: 'A', rule: 'table', lookup: 'ranges', targets: { parts: ['box'] }, table: { '~10': { color: '#ff0000' }, default: { color: '#0000ff' } } },
155
+ { id: 's', accessor: 'CARRIERSTATUS', rule: 'table', lookup: 'code', targets: { parts: ['box'] }, table: { X: { color: '#00ff00' } } }
156
+ ]
157
+ }
158
+ before(async () => {
159
+ await mount('box', serializeFigureAsset(asset))
160
+ await click(await read(() => probe.modeButton('preview')), 'the preview')
161
+ await until(DEEP => eval(DEEP)('figure-preview')[0]?.shadowRoot?.querySelectorAll('div[clips] input').length === 2, [DEEP], 'the two values in the preview')
162
+ })
163
+
164
+ test('U7 a number\'s field and a text\'s start in one column, whatever the names\' lengths', async () => {
165
+ const lefts = await read(DEEP => [...eval(DEEP)('figure-preview')[0].shadowRoot.querySelectorAll('div[clips] input')].map(i => Math.round(i.getBoundingClientRect().left)), DEEP)
166
+ assert.equal(lefts[0], lefts[1], `the fields start at ${lefts.join(' and ')}`)
167
+ })
168
+
169
+ test('U7 the size help\'s choice and button are in the bar\'s look, not the browser\'s grey', async () => {
170
+ const looks = await read(DEEP => {
171
+ const root = eval(DEEP)('figure-preview')[0].shadowRoot
172
+ return ['div[setup] select', 'div[setup] button', 'div[clips] input'].map(s => {
173
+ const c = getComputedStyle(root.querySelector(s))
174
+ return { radius: c.borderTopLeftRadius, background: c.backgroundColor }
175
+ })
176
+ }, DEEP)
177
+ assert.deepEqual(looks[0], looks[1])
178
+ assert.deepEqual(looks[0], looks[2])
179
+ assert.equal(looks[0].radius, '5px')
180
+ assert.notEqual(looks[0].background, 'rgb(239, 239, 239)')
181
+ })
182
+ })
@@ -0,0 +1,116 @@
1
+ /*
2
+ * A product's capabilities in the modeller (ADR-0167 3, 9, 12): the registered descriptors, what each still needs, its
3
+ * settings as the descriptor draws them, and the kernel taking what the panel writes.
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
+ import { addFigureBox, addFigurePrimitive, createFigureAsset, editFigurePart } from '@hatiolab/figure-model'
11
+ import { figureBindingsTakenOver, registerFigureCapability } from '@hatiolab/things-scene'
12
+ import {
13
+ consumerDescriptors,
14
+ inputTableAt,
15
+ namesWith,
16
+ needsMissing,
17
+ partsAt,
18
+ settingsIssues,
19
+ settingsOf,
20
+ unknownConsumerNames,
21
+ withInputTable,
22
+ withParts
23
+ } from '../client/modeller/consumer-capabilities.ts'
24
+
25
+ const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
26
+ const FILL = { key: 'fill', kind: 'input-table', label: '적재량', keys: ['FULL', 'EMPTY', 'EMPTYEMPTY'], start: { FULL: 100, EMPTY: 0, EMPTYEMPTY: 0 } } as const
27
+ const CARRIER = {
28
+ name: 'acme.Carrier',
29
+ label: 'Carrier',
30
+ note: '캐리어',
31
+ version: '1',
32
+ needs: ['item'],
33
+ settings: [{ key: 'loadParts', kind: 'parts', label: '적재물', required: true }, FILL],
34
+ data: [{ name: 'EMPTYTYPE' }, { name: 'CARRIERSTATUS' }],
35
+ check: (settings: Record<string, unknown> | undefined) => (Array.isArray(settings?.loadParts) && settings.loadParts.length ? [] : ['적재물 부품을 고릅니다.'])
36
+ } as never
37
+
38
+ function reel() {
39
+ let asset = addFigurePrimitive(createFigureAsset('Reel'), { id: 'wound', kind: 'cylinder', dimensions: { radius: 400, length: 300 }, position: { x: 0, y: 400, z: 0 } })
40
+ asset.document.model.inputs.push({ id: 'fill', unit: 'percent', min: 0, max: 100, role: 'state' })
41
+ asset.stateDefaults['fill'] = 100
42
+ return asset
43
+ }
44
+
45
+ test('the registered products\' descriptors; one the figure wears that this modeller does not know is said', () => {
46
+ const off = registerFigureCapability(CARRIER)
47
+ try {
48
+ assert.deepEqual(consumerDescriptors().map(d => d.name), ['acme.Carrier'], 'the shared four are the ladder, not here')
49
+ const asset = editFigurePart(reel(), { kind: 'set-capabilities', names: ['acme.Carrier', 'other.Gauge'] })
50
+ assert.deepEqual(unknownConsumerNames(asset), ['other.Gauge'])
51
+ } finally {
52
+ off()
53
+ }
54
+ })
55
+
56
+ test('what a capability still needs: the item mark, a name; put on and off keeps the other names', () => {
57
+ const asset = reel()
58
+ assert.deepEqual(needsMissing(CARRIER, asset), ['item'])
59
+ assert.deepEqual(needsMissing(CARRIER, { ...asset, item: true }), [])
60
+ assert.deepEqual(needsMissing({ ...(CARRIER as object), needs: ['Holdable', ['Capacity', 'FlowNode']] } as never, asset), ['Holdable', 'Capacity | FlowNode'])
61
+ const held = editFigurePart(asset, { kind: 'set-capabilities', names: ['Capacity', 'Holdable'] })
62
+ assert.deepEqual(namesWith(held, 'acme.Carrier', true), ['Capacity', 'Holdable', 'acme.Carrier'])
63
+ assert.deepEqual(namesWith(editFigurePart(held, { kind: 'set-capabilities', names: namesWith(held, 'acme.Carrier', true) }), 'acme.Carrier', false), ['Capacity', 'Holdable'])
64
+ })
65
+
66
+ test('settings as the descriptor draws them: parts picked, an input with a value per fixed key; the kernel takes them', () => {
67
+ let asset = editFigurePart({ ...reel(), item: true }, { kind: 'set-capabilities', names: ['acme.Carrier'] })
68
+ assert.deepEqual(settingsIssues(CARRIER, asset), ['적재물 부품을 고릅니다.'])
69
+ let settings = withParts(settingsOf(asset, 'acme.Carrier'), 'loadParts', ['wound'])
70
+ assert.deepEqual(partsAt(settings, 'loadParts'), ['wound'])
71
+ /* No input yet: the fixed keys start at the descriptor's values. */
72
+ assert.deepEqual(inputTableAt(settings, FILL), { values: { FULL: 100, EMPTY: 0, EMPTYEMPTY: 0 } })
73
+ settings = withInputTable(settings, FILL, 'fill', { ...inputTableAt(settings, FILL).values, EMPTY: 5 })
74
+ assert.deepEqual(settings, { loadParts: ['wound'], fill: { input: 'fill', values: { FULL: 100, EMPTY: 5, EMPTYEMPTY: 0 } } })
75
+ asset = editFigurePart(asset, { kind: 'set-capability-settings', name: 'acme.Carrier', settings })
76
+ assert.deepEqual(settingsIssues(CARRIER, asset), [])
77
+ assert.deepEqual(inputTableAt(settingsOf(asset, 'acme.Carrier'), FILL), { input: 'fill', values: { FULL: 100, EMPTY: 5, EMPTYEMPTY: 0 } })
78
+ /* No input: the table goes; no parts: the key goes. */
79
+ assert.deepEqual(withInputTable(settings, FILL, undefined, {}), { loadParts: ['wound'] })
80
+ assert.deepEqual(withParts(settings, 'loadParts', []), { fill: settings.fill })
81
+ })
82
+
83
+ test('the panel writes through the kernel\'s commands, the ladder keeps a product\'s names, and every word is there', () => {
84
+ const panel = read('../client/modeller/figure-capability-panel.ts')
85
+ assert.match(panel, /kind: 'set-capability-settings', name: d\.name/)
86
+ assert.match(panel, /\[\.\.\.RUNGS\[which\], \.\.\.consumers\]/)
87
+ const keys = [read('../client/modeller/figure-capability-panel.ts'), read('../client/modeller/figure-data-panel.ts')].flatMap(src => [...src.matchAll(/'(figure\.[a-z.-]*[a-z])'/g)].map(m => m[1]!)).filter(k => /product-capabilit|data-capability|data-add-selected-part/.test(k))
88
+ for (const lang of ['ko', 'en', 'ja', 'zh', 'ms', 'es']) {
89
+ const table = JSON.parse(read(`../translations/${lang}.json`))
90
+ for (const key of keys) assert.ok(table[key], `${lang}: ${key}`)
91
+ }
92
+ })
93
+
94
+ test('a binding a product capability takes over is marked where it rests, whole or for the targets named (ADR-0167 17)', () => {
95
+ const off = registerFigureCapability({ name: 'acme.Lamp', label: 'Lamp', note: '', version: '1', needs: [], takes: settings => ({ parts: (settings?.parts as string[]) ?? [] }) })
96
+ try {
97
+ let asset = addFigureBox(addFigureBox(createFigureAsset('Lamp post'), { id: 'head', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 450, z: 0 } }), { id: 'pole', size: { x: 20, y: 400, z: 20 }, position: { x: 0, y: 200, z: 0 } })
98
+ asset = editFigurePart(asset, { kind: 'set-capabilities', names: ['acme.Lamp'] })
99
+ asset = editFigurePart(asset, { kind: 'set-capability-settings', name: 'acme.Lamp', settings: { parts: ['head'] } })
100
+ asset = editFigurePart(asset, {
101
+ kind: 'set-data-binding',
102
+ schema: [{ name: 'mode', kind: 'text' }],
103
+ binding: { id: 'look', accessor: 'mode', rule: 'table', lookup: 'code', targets: { parts: ['head', 'pole'] }, table: { default: { color: '#111111' } } }
104
+ })
105
+ assert.deepEqual(figureBindingsTakenOver(asset), [{ id: 'look', capability: 'acme.Lamp', whole: false, parts: ['head'], inputs: [] }])
106
+ } finally {
107
+ off()
108
+ }
109
+ const panel = read('../client/modeller/figure-data-panel.ts')
110
+ assert.match(panel, /const resting = figureBindingsTakenOver\(asset\)/)
111
+ assert.match(panel, /resting\.filter\(r => r\.id === b\.id\)\.map\(r => this\.renderResting\(r\)\)/)
112
+ for (const lang of ['ko', 'en', 'ja', 'zh', 'ms', 'es']) {
113
+ const table = JSON.parse(read(`../translations/${lang}.json`))
114
+ for (const key of ['figure.text.data-binding-rests', 'figure.text.data-binding-rests-targets']) assert.match(table[key], /\{where\}.*\{name\}/, `${lang}: ${key}`)
115
+ }
116
+ })