@things-factory/figure-ui 10.1.70 โ†’ 10.1.72

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 (69) hide show
  1. package/client/graphql/index.ts +28 -69
  2. package/client/modeller/figure-v3-parts.ts +16 -136
  3. package/client/modeller/figure-v3-recipe-catalog.ts +366 -0
  4. package/client/modeller/v3-editor.ts +25 -5
  5. package/client/modeller/v3-fields.ts +21 -16
  6. package/client/modeller/v3-gizmo.ts +1 -1
  7. package/client/modeller/v3-links.ts +7 -66
  8. package/client/modeller/v3-palette.ts +3 -0
  9. package/client/modeller/v3-recipe-source.ts +15 -0
  10. package/client/modeller/v3-recipes.ts +50 -27
  11. package/client/pages/figure-v3-modeller-page.ts +16 -13
  12. package/client/route.ts +0 -4
  13. package/dist-client/graphql/index.d.ts +11 -18
  14. package/dist-client/graphql/index.js +18 -59
  15. package/dist-client/graphql/index.js.map +1 -1
  16. package/dist-client/modeller/figure-v3-parts.d.ts +5 -12
  17. package/dist-client/modeller/figure-v3-parts.js +17 -147
  18. package/dist-client/modeller/figure-v3-parts.js.map +1 -1
  19. package/dist-client/modeller/figure-v3-recipe-catalog.d.ts +38 -0
  20. package/dist-client/modeller/figure-v3-recipe-catalog.js +399 -0
  21. package/dist-client/modeller/figure-v3-recipe-catalog.js.map +1 -0
  22. package/dist-client/modeller/v3-clips.d.ts +1 -1
  23. package/dist-client/modeller/v3-editor.d.ts +8 -3
  24. package/dist-client/modeller/v3-editor.js +35 -6
  25. package/dist-client/modeller/v3-editor.js.map +1 -1
  26. package/dist-client/modeller/v3-fields.d.ts +2 -2
  27. package/dist-client/modeller/v3-fields.js +20 -18
  28. package/dist-client/modeller/v3-fields.js.map +1 -1
  29. package/dist-client/modeller/v3-gizmo.js +1 -1
  30. package/dist-client/modeller/v3-gizmo.js.map +1 -1
  31. package/dist-client/modeller/v3-links.d.ts +8 -25
  32. package/dist-client/modeller/v3-links.js +4 -66
  33. package/dist-client/modeller/v3-links.js.map +1 -1
  34. package/dist-client/modeller/v3-palette.js +3 -0
  35. package/dist-client/modeller/v3-palette.js.map +1 -1
  36. package/dist-client/modeller/v3-recipe-source.d.ts +8 -0
  37. package/dist-client/modeller/v3-recipe-source.js +14 -0
  38. package/dist-client/modeller/v3-recipe-source.js.map +1 -0
  39. package/dist-client/modeller/v3-recipes.d.ts +25 -14
  40. package/dist-client/modeller/v3-recipes.js +42 -28
  41. package/dist-client/modeller/v3-recipes.js.map +1 -1
  42. package/dist-client/pages/figure-v3-modeller-page.d.ts +5 -3
  43. package/dist-client/pages/figure-v3-modeller-page.js +16 -13
  44. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  45. package/dist-client/route.d.ts +1 -1
  46. package/dist-client/route.js +0 -3
  47. package/dist-client/route.js.map +1 -1
  48. package/dist-client/tsconfig.fui.tsbuildinfo +1 -1
  49. package/dist-client/tsconfig.tsbuildinfo +1 -1
  50. package/package.json +5 -4
  51. package/scripts/template-thumbnails.mjs +53 -0
  52. package/test/browser/build.mjs +15 -3
  53. package/test/browser/modeller-page.js +26 -3
  54. package/test/browser/modeller.check.mjs +73 -46
  55. package/test/browser/serve.mjs +7 -1
  56. package/test/browser/thumbnail-page.js +14 -0
  57. package/test/v3-fields.test.ts +5 -1
  58. package/test/v3-links.test.ts +12 -0
  59. package/test/v3-recipes.test.ts +47 -25
  60. package/things-factory.config.js +0 -1
  61. package/translations/en.json +38 -23
  62. package/translations/ja.json +38 -23
  63. package/translations/ko.json +38 -23
  64. package/translations/ms.json +38 -23
  65. package/translations/zh.json +38 -23
  66. package/client/pages/figure-recipe-page.ts +0 -338
  67. package/dist-client/pages/figure-recipe-page.d.ts +0 -41
  68. package/dist-client/pages/figure-recipe-page.js +0 -352
  69. package/dist-client/pages/figure-recipe-page.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-ui",
3
- "version": "10.1.70",
3
+ "version": "10.1.72",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -27,21 +27,22 @@
27
27
  "clean": "npm run clean:server && npm run clean:client"
28
28
  },
29
29
  "dependencies": {
30
- "@hatiolab/figure-model": "^0.1.82",
30
+ "@hatiolab/figure-model": "^0.1.87",
31
31
  "@hatiolab/things-scene": "^10.1.56",
32
32
  "@material/web": "^2.0.0",
33
33
  "@operato/data-grist": "^10.0.0",
34
34
  "@operato/graphql": "^10.0.0",
35
35
  "@operato/i18n": "^10.0.0",
36
36
  "@operato/layout": "^10.28.0",
37
+ "@operato/markdown": "^10.23.0",
37
38
  "@operato/p13n": "^10.0.0",
38
39
  "@operato/popup": "^10.23.0",
39
40
  "@operato/shell": "^10.0.0",
40
41
  "@operato/styles": "^10.0.0",
41
42
  "@operato/utils": "^10.0.0",
42
43
  "@things-factory/ai-assistant": "^10.1.58",
43
- "@things-factory/figure-service": "^10.1.70",
44
+ "@things-factory/figure-service": "^10.1.72",
44
45
  "three": "^0.185.1"
45
46
  },
46
- "gitHead": "7c9c614b0cb1d02d401211db76ec0ca0ead344af"
47
+ "gitHead": "fdf6d4faac454dbdca62d8fe7409395dff1ada22"
47
48
  }
@@ -0,0 +1,53 @@
1
+ /*
2
+ * Draws each recipe template's thumbnail from its model (ADR-0095 amended: model first, verified, then the recipe).
3
+ *
4
+ * node scripts/template-thumbnails.mjs [name โ€ฆ]
5
+ * FIGURE_MODEL=../../../figure-model node scripts/template-thumbnails.mjs (a figure-model not published yet)
6
+ *
7
+ * Reads figure-service/recipes/<name>/models/default.json (the model at the template's defaults) and writes
8
+ * figure-service/recipes/<name>/thumbnail.png, drawn open when the model has a control, so the card shows the motion.
9
+ */
10
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
11
+ import { dirname, join, resolve } from 'node:path'
12
+ import { fileURLToPath } from 'node:url'
13
+
14
+ import { buildPage } from '../test/browser/build.mjs'
15
+ import { openPage } from '../test/browser/serve.mjs'
16
+
17
+ const HERE = dirname(fileURLToPath(import.meta.url))
18
+ const RECIPES = resolve(HERE, '../../figure-service/recipes')
19
+ const only = process.argv.slice(2)
20
+
21
+ const opened = await openPage(await buildPage('thumbnail-page.js'))
22
+ try {
23
+ for (const name of readdirSync(RECIPES).filter(n => !only.length || only.includes(n)).sort()) {
24
+ const model = join(RECIPES, name, 'models', 'default.json')
25
+ if (!existsSync(model)) continue
26
+ const text = readFileSync(model, 'utf-8')
27
+ const asset = JSON.parse(text)
28
+ /*
29
+ Each control most of the way from where it rests toward its far end: a door partly open (a left hinge opens toward
30
+ -90), so the card shows what moves -- a sliding leaf opened all the way hides behind its jamb.
31
+ */
32
+ const rest = asset.stateDefaults ?? {}
33
+ const state = Object.fromEntries(
34
+ asset.document.model.inputs
35
+ .filter(i => i.role === 'state')
36
+ .map(i => {
37
+ const from = rest[i.id] ?? 0
38
+ const far = Math.abs(i.max - from) >= Math.abs(i.min - from) ? i.max : i.min
39
+ return [i.id, from + (far - from) * 0.6]
40
+ })
41
+ )
42
+ const url = await opened.page.evaluate((text, state) => window.shoot(text, state), text, state)
43
+ if (!url) {
44
+ console.error(`${name}: not drawn`)
45
+ process.exitCode = 1
46
+ continue
47
+ }
48
+ writeFileSync(join(RECIPES, name, 'thumbnail.png'), Buffer.from(url.split(',')[1], 'base64'))
49
+ console.log(`${name}: thumbnail.png`)
50
+ }
51
+ } finally {
52
+ await opened.close()
53
+ }
@@ -19,8 +19,8 @@ const HERE = dirname(fileURLToPath(import.meta.url))
19
19
  const PACKAGE = resolve(HERE, '../..')
20
20
  const ROOT = resolve(PACKAGE, '../..')
21
21
 
22
- export async function buildPage() {
23
- const out = join(tmpdir(), 'figure-ui-browser-check')
22
+ export async function buildPage(page = 'modeller-page.js') {
23
+ const out = join(tmpdir(), 'figure-ui-browser-' + page.replace(/\W+/g, '-'))
24
24
  const emitted = join(out, 'client')
25
25
  rmSync(out, { recursive: true, force: true })
26
26
  mkdirSync(out, { recursive: true })
@@ -34,7 +34,7 @@ export async function buildPage() {
34
34
  if (figureModel && !existsSync(figureModel)) throw Error(`FIGURE_MODEL has no build: ${figureModel}`)
35
35
 
36
36
  const bundle = await rollup({
37
- input: join(HERE, 'modeller-page.js'),
37
+ input: join(HERE, page),
38
38
  onwarn(warning, warn) {
39
39
  if (warning.code === 'CIRCULAR_DEPENDENCY' || warning.code === 'THIS_IS_UNDEFINED' || warning.code === 'EVAL') return
40
40
  warn(warning)
@@ -56,6 +56,18 @@ export async function buildPage() {
56
56
  const file = join(out, 'page.js')
57
57
  await bundle.write({ file, format: 'esm', inlineDynamicImports: true })
58
58
  await bundle.close()
59
+
60
+ /*
61
+ ox-markdown renders in a worker it loads from beside its module (\`../../workers/markdown-webworker.js\`); from
62
+ /page.js that is /workers/. Bundled here and served there, the guides render as in the app -- without it the worker
63
+ never answers and a guide is drawn empty.
64
+ */
65
+ const worker = await rollup({
66
+ input: join(ROOT, 'node_modules/@operato/markdown/workers/markdown-webworker.js'),
67
+ plugins: [nodeResolve({ browser: true, modulePaths: [join(PACKAGE, 'node_modules'), join(ROOT, 'node_modules')] }), commonjs()]
68
+ })
69
+ await worker.write({ file: join(out, 'workers/markdown-webworker.js'), format: 'esm', inlineDynamicImports: true })
70
+ await worker.close()
59
71
  return file
60
72
  }
61
73
 
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import 'figure-ui-client/modeller/v3-editor.js'
7
7
  import { createV3ProductSession } from 'figure-ui-client/modeller/v3-product-session.js'
8
- import { addV3Box, createV3Asset, robotArmV3, serializeV3Asset } from '@hatiolab/figure-model'
8
+ import { addV3Box, addV3Primitive, applyV3Authoring, createV3Asset, robotArmV3, serializeV3Asset } from '@hatiolab/figure-model'
9
9
 
10
10
  const PALETTE = {
11
11
  'palette.dark': '#2b2b2b',
@@ -36,6 +36,20 @@ function sourceOf(kind, text) {
36
36
  asset.palette = { ...asset.palette, ...PALETTE }
37
37
  return serializeV3Asset(addV3Box(asset, { id: 'room', size: { x: 4000, y: 3000, z: 6000 }, position: { x: 0, y: 1500, z: 0 } }))
38
38
  }
39
+ if (kind === 'rollers') {
40
+ /* A roller row whose rollers spin in place, each carrying a mark (ADR-0093, 2026-09-26). */
41
+ let asset = createV3Asset('Rollers')
42
+ asset.palette = { ...asset.palette, ...PALETTE }
43
+ asset = addV3Box(asset, { id: 'bed', size: { x: 2000, y: 400, z: 600 }, position: { x: 0, y: 200, z: 0 } })
44
+ asset = applyV3Authoring(asset, { kind: 'attach', part: 'bed', face: 'bottom', to: { plane: 'mounting-plane' } })
45
+ asset = addV3Primitive(asset, { id: 'roller', kind: 'cylinder', dimensions: { radius: 40, length: 560 }, position: { x: 0, y: 0, z: 0 }, rotation: { x: 90, y: 0, z: 0 } })
46
+ asset = applyV3Authoring(asset, { kind: 'attach', part: 'roller', face: 'centre', to: { part: 'bed', face: 'top' }, gap: 40 })
47
+ asset = addV3Box(asset, { id: 'mark', size: { x: 30, y: 30, z: 560 }, position: { x: 0, y: 0, z: 0 } })
48
+ asset = applyV3Authoring(asset, { kind: 'attach', part: 'mark', face: 'left', to: { part: 'roller', face: 'right' } })
49
+ const j = asset.joints.find(o => o.body1.part === 'roller')
50
+ asset = applyV3Authoring(asset, { kind: 'set-joint', joint: { ...j, type: 'revolute', axis: 'Z', lowerLimit: -360, upperLimit: 360, state: { id: 'spin' } } })
51
+ return serializeV3Asset(applyV3Authoring(asset, { kind: 'make-repeat', part: 'roller', axis: 'x', pitch: 200 }))
52
+ }
39
53
  throw Error(`no figure for ${kind}`)
40
54
  }
41
55
 
@@ -139,6 +153,14 @@ window.probe = {
139
153
  const l = Math.hypot(b.x - a.x, b.y - a.y) || 1
140
154
  return { x: (b.x - a.x) / l, y: (b.y - a.y) / l }
141
155
  },
156
+ /** The ids of every body drawn. */
157
+ ids() {
158
+ return editor().session.read().geometry.map(g => g.id)
159
+ },
160
+ /** Whether an input with this label is on the page. */
161
+ hasInput(label) {
162
+ return deep('input').some(i => i.getAttribute('aria-label') === label)
163
+ },
142
164
  /** The page point of a part's row in the parts list. */
143
165
  row(id) {
144
166
  const label = deep('span[label]').find(s => s.getAttribute('title') === id)
@@ -191,7 +213,7 @@ window.probe = {
191
213
  }
192
214
 
193
215
  window.harness = {
194
- async mount(kind, text, recipes = []) {
216
+ async mount(kind, text, templates = [], guides = {}) {
195
217
  const source = sourceOf(kind, text)
196
218
  const session = createV3ProductSession({
197
219
  load: async id => ({ id, source }),
@@ -200,7 +222,8 @@ window.harness = {
200
222
  await session.open('probe')
201
223
  const editor = document.createElement('figure-v3-editor')
202
224
  editor.session = session
203
- editor.recipes = recipes
225
+ editor.templates = templates
226
+ editor.guideOf = async name => guides[name] ?? ''
204
227
  editor.revision = 0
205
228
  /* The modeller page counts a change the same way: the editor re-reads the session on a new revision. */
206
229
  editor.addEventListener('v3-changed', () => (editor.revision = editor.revision + 1))
@@ -13,9 +13,8 @@
13
13
  */
14
14
  import { test, describe, before, after } from 'node:test'
15
15
  import assert from 'node:assert/strict'
16
- import { readdirSync, readFileSync } from 'node:fs'
17
- import { createRequire } from 'node:module'
18
- import { dirname, join, resolve } from 'node:path'
16
+ import { readFileSync } from 'node:fs'
17
+ import { dirname, resolve } from 'node:path'
19
18
  import { fileURLToPath } from 'node:url'
20
19
 
21
20
  import { buildPage } from './build.mjs'
@@ -674,13 +673,13 @@ describe('a part follows the figure size in one step (follow-size, 2026-09-26)',
674
673
  })
675
674
  })
676
675
 
677
- describe('a recipe put onto a part: chosen, filled in, one step (ADR-0095, 2026-09-26)', () => {
678
- /* The recipes figure-model ships: the build named by FIGURE_MODEL, else the installed one. */
679
- const recipeDir = () => {
680
- const base = process.env.FIGURE_MODEL ? resolve(process.env.FIGURE_MODEL) : dirname(createRequire(import.meta.url).resolve('@hatiolab/figure-model/package.json'))
681
- return join(base, 'recipes')
682
- }
683
- const recipes = () => readdirSync(recipeDir()).filter(f => f.endsWith('.json')).sort().map(f => JSON.parse(readFileSync(join(recipeDir(), f), 'utf-8')))
676
+ describe('the recipe catalog: a template chosen, filled in, put onto the chosen part in one step (ADR-0095)', () => {
677
+ /* The product's templates, as the server reads them, as catalog rows. */
678
+ const templates = () =>
679
+ ['sliding-door', 'hinged-door', 'roller-row'].map(name => {
680
+ const recipe = JSON.parse(readFileSync(resolve(HERE, `../../../figure-service/recipes/${name}/recipe.json`), 'utf-8'))
681
+ return { name, category: recipe.category, tags: recipe.tags, target: recipe.target, title: recipe.title.en, description: recipe.description.en, hasThumbnail: false, recipe }
682
+ })
684
683
  const at = selector =>
685
684
  page().evaluate(selector => {
686
685
  const deep = (root = document) => [...root.querySelectorAll(selector)].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
@@ -688,58 +687,86 @@ describe('a recipe put onto a part: chosen, filled in, one step (ADR-0095, 2026-
688
687
  if (!e) return null
689
688
  e.scrollIntoView({ block: 'center' })
690
689
  const r = e.getBoundingClientRect()
691
- return { x: r.left + r.width / 2, y: r.top + r.height / 2, disabled: !!e.disabled }
690
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2, disabled: !!e.disabled, text: e.textContent.trim() }
692
691
  }, selector)
693
692
  const parts = () => read(() => document.querySelector('figure-v3-editor').session.read().geometry.map(g => g.id))
693
+ const type = async (selector, text) => {
694
+ const field = await page().evaluateHandle(selector => {
695
+ const deep = (root = document) => [...root.querySelectorAll(selector)].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
696
+ return deep()[0] ?? null
697
+ }, selector)
698
+ await field.evaluate(i => i.scrollIntoView({ block: 'center' }))
699
+ await field.click({ clickCount: 3 })
700
+ await page().keyboard.type(text)
701
+ await page().keyboard.press('Tab')
702
+ }
694
703
 
695
- test('with no part chosen the door waits; on the room it opens its form, and put in makes the frame, the leaves and the lamp', async () => {
696
- await read((text, list) => harness.mount('room', text, list), null, recipes())
704
+ test('the room chosen, the catalog opened: the door told in full, a share typed, put in -- the frame, the leaves and the lamp, and the catalog closes', async () => {
705
+ const guides = { 'sliding-door': readFileSync(resolve(HERE, '../../../figure-service/recipes/sliding-door/guide.en.md'), 'utf-8') }
706
+ await read((list, guides) => harness.mount('room', null, list, guides), templates(), guides)
697
707
  await sleep(2500)
698
- const idle = await at('button[recipe="sliding-door"]')
699
- assert.ok(idle, 'the sliding door is offered')
700
- assert.ok(idle.disabled, 'it waits for a part to be chosen')
701
708
  await click(await read(() => probe.row('room')))
702
- await click(await at('button[recipe="sliding-door"]'))
703
- const share = await page().evaluateHandle(() => {
704
- const deep = (root = document) => [...root.querySelectorAll('input[param="share"]')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
705
- return deep()[0] ?? null
709
+ await click(await at('button[recipe-catalog]'))
710
+ assert.ok(await at('figure-v3-recipe-catalog'), 'the catalog is open')
711
+ await click(await at('button[card="hinged-door"]'))
712
+ /* A search that takes the chosen card away tells the first card it leaves (:3300, 2026-09-26). */
713
+ await type('input[search]', 'sliding')
714
+ assert.equal(await read(() => {
715
+ const deep = (root = document) => [...root.querySelectorAll('button[card][on]')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
716
+ return deep().map(b => b.getAttribute('card')).join()
717
+ }), 'sliding-door', 'the card told is one the search shows')
718
+ assert.ok(await at('li[parts="2โ€“4"]'), 'the parts it adds, worked out from its choices: 2 to 4')
719
+ /* The guide is drawn as its text, not as the page its words would name (seen on :3300, 2026-09-26). */
720
+ await sleep(1500)
721
+ const guide = await read(() => {
722
+ const deep = (root = document) => [...root.querySelectorAll('ox-markdown')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
723
+ const shown = deep()[0]
724
+ return shown ? { headings: [...shown.shadowRoot.querySelectorAll('h2')].map(h => h.textContent.trim()), app: !!shown.shadowRoot.querySelector('ox-app, things-app, body, html') } : null
706
725
  })
707
- assert.equal(await share.evaluate(i => i.value), '35', 'a share is typed as a percentage')
708
- await share.evaluate(i => i.scrollIntoView({ block: 'center' }))
709
- await share.click({ clickCount: 3 })
710
- await page().keyboard.type('50')
711
- await page().keyboard.press('Tab')
726
+ assert.ok(guide, 'the guide is shown')
727
+ assert.ok(guide.headings.includes('When to use it'), `the guide's own headings: ${JSON.stringify(guide.headings)}`)
728
+ assert.equal(guide.app, false, 'no page drawn inside it')
729
+ await type('input[param="share"]', '50')
712
730
  const start = await state()
713
- await click(await at('div[recipe-actions] button[insert]'))
714
- const after = await state()
715
- assert.equal(after.error, '', 'not refused')
716
- assert.equal(await read(() => document.querySelector('figure-v3-editor').recipeError), '', 'the recipe went in')
717
- assert.equal(after.revision, start.revision + 1, 'one step')
731
+ await click(await at('button[insert]'))
732
+ assert.equal(await read(() => document.querySelector('figure-v3-editor').recipeError), '', 'not refused')
733
+ assert.equal((await state()).revision, start.revision + 1, 'one step')
718
734
  const ids = await parts()
719
735
  for (const part of ['frame', 'left', 'right', 'lamp']) assert.ok(ids.includes(`sliding-door-1-${part}`), `${part} is in`)
720
736
  const frame = await body('sliding-door-1-frame')
721
- assert.equal(Math.round(frame.dimensions.right - frame.dimensions.left), 2000, 'half the room\'s 4000 mm')
737
+ assert.equal(Math.round(frame.dimensions.right - frame.dimensions.left), 2000, "half the room's 4000 mm")
738
+ assert.equal(await at('figure-v3-recipe-catalog'), null, 'the catalog closes')
722
739
  })
723
740
 
724
- test('a door the room cannot take is refused in the form, in the kernel\'s words, and nothing is added', async () => {
741
+ test('a door the room cannot take is refused in the catalog, in the kernel\'s words, and nothing is added', async () => {
742
+ await click(await read(() => probe.row('room')))
743
+ await click(await at('button[recipe-catalog]'))
744
+ await click(await at('button[card="sliding-door"]'))
725
745
  const before = await parts()
726
- const share = await page().evaluateHandle(() => {
727
- const deep = (root = document) => [...root.querySelectorAll('input[param="share"]')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
728
- return deep()[0] ?? null
729
- })
730
- await share.evaluate(i => i.scrollIntoView({ block: 'center' }))
731
- await share.click({ clickCount: 3 })
732
- await page().keyboard.type('5')
733
- await page().keyboard.press('Tab')
746
+ await type('input[param="share"]', '5')
734
747
  const start = await state()
735
- await click(await at('div[recipe-actions] button[insert]'))
748
+ await click(await at('button[insert]'))
736
749
  assert.equal((await state()).revision, start.revision, 'no step')
737
750
  assert.deepEqual(await parts(), before, 'nothing added')
738
- const said = await page().evaluate(() => {
739
- const deep = (root = document) => [...root.querySelectorAll('div[recipe-form] p[error]')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
740
- return deep()[0]?.textContent ?? ''
741
- })
742
- assert.match(said, /steps\[\d+\]/, `the kernel's sentence, with the step: ${said}`)
751
+ const said = await at('section[detail] p[role="alert"]')
752
+ assert.ok(said && /steps\[\d+\]/.test(said.text), `the kernel's sentence, with the step: ${said?.text}`)
753
+ await click(await at('button[close]'))
754
+ })
755
+ })
756
+
757
+ describe('a row that carries a part (ADR-0093, 2026-09-26)', () => {
758
+ test('the list has the roller and its mark, each once; a click on a mark on the canvas picks the mark, and only the roller offers the row', async () => {
759
+ await mount('rollers')
760
+ assert.ok(await read(() => probe.row('roller')), 'the roller, once')
761
+ assert.ok(await read(() => probe.row('mark')), 'the mark it carries, once')
762
+ const copy = (await read(() => probe.ids())).find(id => id.includes('mark.') && id.includes(',3,'))
763
+ assert.ok(copy, 'a mark on the fourth roller is drawn')
764
+ await click(await read(id => probe.partOnPage(id), copy))
765
+ assert.equal((await state()).selected, 'mark', 'picked by the part it copies')
766
+ assert.equal(await read(() => probe.hasInput('mark.repeat.pitch')), false, 'the mark has no row of its own to set')
767
+ await click(await read(() => probe.row('roller')))
768
+ assert.equal((await state()).selected, 'roller')
769
+ assert.ok(await read(() => probe.hasInput('roller.repeat.pitch')), 'the roller offers its row')
743
770
  })
744
771
  })
745
772
 
@@ -2,7 +2,8 @@
2
2
  * Opens the built page in a headless browser: a tiny server for page.js, and puppeteer on it at a desktop size.
3
3
  */
4
4
  import { createServer } from 'node:http'
5
- import { readFileSync } from 'node:fs'
5
+ import { existsSync, readFileSync } from 'node:fs'
6
+ import { dirname, join, normalize } from 'node:path'
6
7
  import puppeteer from 'puppeteer'
7
8
 
8
9
  const HTML = `<!DOCTYPE html>
@@ -16,9 +17,14 @@ const HTML = `<!DOCTYPE html>
16
17
  export async function openPage(bundle, { width = 1400, height = 900 } = {}) {
17
18
  const js = readFileSync(bundle)
18
19
  const server = createServer((req, res) => {
20
+ /* Scripts built beside the page (the markdown worker) are served as they are. */
21
+ const beside = join(dirname(bundle), normalize(req.url.split('?')[0]).replace(/^([/\\])+/, ''))
19
22
  if (req.url === '/page.js') {
20
23
  res.writeHead(200, { 'Content-Type': 'application/javascript' })
21
24
  res.end(js)
25
+ } else if (req.url.endsWith('.js') && existsSync(beside)) {
26
+ res.writeHead(200, { 'Content-Type': 'application/javascript' })
27
+ res.end(readFileSync(beside))
22
28
  } else {
23
29
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
24
30
  res.end(HTML)
@@ -0,0 +1,14 @@
1
+ /*
2
+ * The page that draws recipe template thumbnails (ADR-0095 amended): a model -- the host and the template's parts -- drawn
3
+ * the way the modeller draws a figure's card, by the same code (`captureV3Thumbnail`).
4
+ */
5
+ import { captureV3Thumbnail } from 'figure-ui-client/modeller/v3-thumbnail.js'
6
+ import { drawableV3Parts } from 'figure-ui-client/modeller/v3-renderer.js'
7
+ import { compileV3Asset } from '@hatiolab/figure-model'
8
+
9
+ window.harness = { ready: true }
10
+ window.shoot = async (text, state = {}) => {
11
+ const asset = JSON.parse(text)
12
+ const evaluation = compileV3Asset(asset).evaluate({ ...asset.stateDefaults, ...state })
13
+ return await captureV3Thumbnail(drawableV3Parts(evaluation.geometry), evaluation.palette ?? asset.palette ?? {})
14
+ }
@@ -53,7 +53,7 @@ test('๊ธด ์„ค๋ช…์€ ์ œ๋ชฉ ์˜† ใ€Œ?ใ€ ๋กœ ์—ฐ๋‹ค โ€” ๋Š˜ ํŽผ์ณ ๋‘์ง€ ์•Š๋Š”
53
53
 
54
54
  test('๋˜ํ’€์ดํ•œ ๋ถ€ํ’ˆ์€ ๋˜ํ’€์ด์˜ ์ด๋ฆ„๊ณผ ๋ช‡ ๋ฒˆ์งธ์ธ์ง€๋กœ ์ฝ๋Š”๋‹ค โ€” ๋‚  id ๋ฅผ ๋ณด์ด์ง€ ์•Š๋Š”๋‹ค', () => {
55
55
  assert.equal(partLabelOf('["roller.repeat",0,"roller.value"]'), 'roller 1')
56
- assert.equal(partLabelOf('["rack.repeat",1,"shelf.repeat",2,"shelf.value"]'), 'rack 2ยท3')
56
+ assert.equal(partLabelOf('["roller.repeat",2,"mark.placed"]'), 'mark 3', '์ค„์˜ ๋ถ€ํ’ˆ์— ๋ถ™์€ ๊ฒƒ์€ ์ œ ์ด๋ฆ„์œผ๋กœ (ADR-0093, 2026-09-26)')
57
57
  assert.equal(partLabelOf('deck'), 'deck', '๋ณดํ†ต ๋ถ€ํ’ˆ์€ ์ œ ์ด๋ฆ„')
58
58
  assert.equal(partLabelOf('[not json'), '[not json')
59
59
  const parts = readFileSync(fileURLToPath(new URL('../client/modeller/figure-v3-parts.ts', import.meta.url)), 'utf8')
@@ -64,6 +64,9 @@ test('๋˜ํ’€์ดํ•œ ๋ถ€ํ’ˆ์˜ ์†์„ฑ ์ด๋ฆ„ ์นธ๋„ ์‚ฌ๋žŒ์˜ ์ด๋ฆ„์ด๊ณ , ๋ช‡
64
64
  const geometry = Array.from({ length: 16 }, (_, i) => ({ id: JSON.stringify(['roller.repeat', i, 'roller.value']) })).concat([{ id: 'deck' }])
65
65
  assert.deepEqual(repeatOf('["roller.repeat",14,"roller.value"]', geometry), { name: 'roller', count: 16, index: 15 })
66
66
  assert.equal(repeatOf('deck', geometry), null)
67
+ const carried = geometry.concat(Array.from({ length: 16 }, (_, i) => ({ id: JSON.stringify(['roller.repeat', i, 'mark.placed']) })))
68
+ assert.deepEqual(repeatOf('["roller.repeat",14,"roller.value"]', carried), { name: 'roller', count: 16, index: 15 }, '๋กค๋Ÿฌ์— ๋ถ™์€ ๊ฒƒ์€ ๋กค๋Ÿฌ ์ˆ˜์— ์•ˆ ๋“ ๋‹ค')
69
+ assert.deepEqual(repeatOf('["roller.repeat",2,"mark.placed"]', carried), { name: 'mark', count: 16, index: 3 })
67
70
  const editor = readFileSync(fileURLToPath(new URL('../client/modeller/v3-editor.ts', import.meta.url)), 'utf8')
68
71
  assert.ok(editor.includes('.value=${partLabelOf(this.selected)}'), '์‚ฌ์šฉ์ž 2026-09-24: ใ€Œ๋ถ€ํ’ˆ ์ด๋ฆ„ ???ใ€')
69
72
  assert.ok(editor.includes('?disabled=${unavailable || !!repeatOf(this.selected, view.geometry)}'), '๋ณต์‚ฌ๋ณธ์—๋Š” ๋ฐ”๊ฟ€ ์ œ ์ด๋ฆ„์ด ์—†๋‹ค')
@@ -72,6 +75,7 @@ test('๋˜ํ’€์ดํ•œ ๋ถ€ํ’ˆ์˜ ์†์„ฑ ์ด๋ฆ„ ์นธ๋„ ์‚ฌ๋žŒ์˜ ์ด๋ฆ„์ด๊ณ , ๋ช‡
72
75
  test('ํŽธ์ง‘๊ธฐ๋Š” ์ €์ž‘ํ•œ ๋ถ€ํ’ˆ ๋‹จ์œ„๋กœ ๊ณ ๋ฅด๊ณ  ๋ณด์ธ๋‹ค โ€” ๋˜ํ’€์ด๋Š” ํ•œ ๋ถ€ํ’ˆ, ์บ”๋ฒ„์Šค์—์„œ๋Š” ๋ณต์‚ฌ๋ณธ ๋ชจ๋‘๊ฐ€ ๊ณจ๋ผ์ง„๋‹ค', () => {
73
76
  assert.equal(ownerOf('["roller.repeat",14,"roller.value"]'), 'roller')
74
77
  assert.equal(ownerOf('deck'), 'deck')
78
+ assert.equal(ownerOf('["roller.repeat",2,"mark.placed"]'), 'mark', '๋กค๋Ÿฌ์— ๋ถ™์€ ํ‘œ์‹œ์˜ ๋ณต์‚ฌ๋ณธ์„ ๋ˆ„๋ฅด๋ฉด ๊ทธ ํ‘œ์‹œ๋ฅผ ๊ณ ๋ฅธ๋‹ค')
75
79
  assert.deepEqual(repeatOf('roller', [{ id: '["roller.repeat",0,"roller.value"]' }, { id: '["roller.repeat",1,"roller.value"]' }, { id: 'deck' }]), { name: 'roller', count: 2, index: 0 })
76
80
  const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
77
81
  const canvas = read('../client/modeller/v3-canvas.ts')
@@ -618,3 +618,15 @@ test('์น˜์ˆ˜ ์นธ โ€” ใ€Œ๋„ํ˜• ํฌ๊ธฐ๋ฅผ ๋”ฐ๋ผ๊ฐใ€ ๋‹จ์ถ”๋Š” follow-size ํ•œ
618
618
  const ko = JSON.parse(read('../translations/ko.json'))
619
619
  assert.match(ko['figure.text.size-declared-to-follow'], /\{x\} ร— \{y\} ร— \{z\} mm/)
620
620
  })
621
+
622
+ /* ํ†ต โ€” ์† ๋นˆ ์ƒ์ž๋ฅผ ํŒ”๋ ˆํŠธ์—์„œ, ์„ธ ์ถ• ๋ชจ๋‘ ํฌ๊ธฐ๋ฅผ ๋”ฐ๋ฅด๊ณ , ์†์žก์ด๋กœ ํ‚ค์šฐ๊ณ , ์•ˆ์ชฝ ๋ฉด์— ๋ถ™์ธ๋‹ค (G4, 09-26). */
623
+ test('ํŒ”๋ ˆํŠธ์˜ ํ†ต์€ ํ†ต์˜ ํฌ๊ธฐ๋กœ ์„œ๊ณ , ์„ธ ์ถ• ๋ชจ๋‘ ํฌ๊ธฐ๋ฅผ ๋”ฐ๋ฅผ ์ˆ˜ ์žˆ๊ณ , ์†์žก์ด๋กœ ํ‚ค์šธ ์ˆ˜ ์žˆ๋‹ค', async () => {
624
+ const { paletteRecipe } = await import('../client/modeller/v3-palette.ts')
625
+ const { gizmoReachOf } = await import('../client/modeller/v3-gizmo.ts')
626
+ const { addV3Primitive: add } = await import('@hatiolab/figure-model')
627
+ const asset = add(createV3Asset('Tote'), paletteRecipe('hollow-box' as never, 'tote'))
628
+ assert.deepEqual(followableAxes(asset, 'tote'), ['x', 'y', 'z'])
629
+ assert.deepEqual(gizmoReachOf(asset as never, 'tote').sizes, ['tote.dimension.width', 'tote.dimension.height', 'tote.dimension.depth'])
630
+ const ko = JSON.parse(read('../translations/ko.json'))
631
+ assert.equal(ko['figure.label.face-inside-bottom'], '์•ˆ์ชฝ ๋ฐ”๋‹ฅ')
632
+ })
@@ -1,14 +1,17 @@
1
1
  /*
2
- * **์กฐ๊ฐ ์นธ** (ADR-0095, 2026-09-26). ๋ฌธ ยท ๋กค๋Ÿฌ ์ค„์„ ํ•œ ๋‹จ๊ณ„๋กœ ๋„ฃ๋Š”๋‹ค โ€” ๋ถ€ํ’ˆ์„ ๊ณ ๋ฅธ ๋’ค์—, ๋ช‡ ๊ฐœ์˜ ์ˆ˜์™€ ์„ ํƒ์œผ๋กœ.
2
+ * **์กฐ๊ฐ ์นดํƒˆ๋กœ๊ทธ** (ADR-0095, 2026-09-26). ํ…œํ”Œ๋ฆฟ์€ ์„œ๋ฒ„์˜ ์ •์  ํŒŒ์ผ์ด๊ณ , ๋ชจ๋ธ๋Ÿฌ๋Š” ๋ถ„๋ฅ˜ ยท ์ฐพ๊ธฐ ยท ์„ค๋ช… ยท ๋„ฃ๊ธฐ๋ฅผ ํ•œ ์ฐฝ์—์„œ ํ•œ๋‹ค.
3
+ * ๋ถ€ํ’ˆ ์ˆ˜์™€ ์ž‘๋™ ๋ฐฉ์‹์€ ๋ ˆ์‹œํ”ผ์—์„œ ์…ˆํ•œ๋‹ค โ€” ๊ธ€๋กœ ์ ์ง€ ์•Š๋Š”๋‹ค.
3
4
  */
4
5
  import { test } from 'node:test'
5
6
  import assert from 'node:assert/strict'
6
7
  import { readFileSync } from 'node:fs'
7
8
  import { fileURLToPath } from 'node:url'
8
9
 
9
- import { recipeFieldOf, recipeReady, recipesOf, recipeValueOf, recipeWord } from '../client/modeller/v3-recipes.ts'
10
+ import { recipeFieldOf, recipeReady, recipesMatching, recipeValueOf, recipeWord, recipeWorkings, templatesOf } from '../client/modeller/v3-recipes.ts'
10
11
 
11
12
  const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
13
+ /* The product's templates, as the server reads them. */
14
+ const template = (name: string) => read(`../../figure-service/recipes/${name}/recipe.json`)
12
15
  const share = { name: 'share', kind: 'ratio' as const, default: 0.35, min: 0.05, max: 0.95 }
13
16
 
14
17
  test('๋น„์œจ์€ ๋ฐฑ๋ถ„์œจ๋กœ ๋ณด์ด๊ณ  ๋ฐ›๋Š”๋‹ค; ๋นˆ ์นธ์€ ๊ธฐ๋ณธ๊ฐ’; ์„ ํƒ์€ ์„ ํƒ์ง€ ์ค‘์—์„œ๋งŒ', () => {
@@ -27,34 +30,53 @@ test('๋ง์€ ํ™”๋ฉด์˜ ์–ธ์–ด๋กœ, ์—†์œผ๋ฉด ์˜์–ด๋กœ; ๋ถ€ํ’ˆ์šฉ ์กฐ๊ฐ์€ ๋ถ€
27
30
  assert.equal(recipeWord({ ko: '๋ฏธ๋‹ซ์ด ๋ฌธ', en: 'sliding door' }, 'ja'), 'sliding door')
28
31
  assert.equal(recipeReady({ target: 'part' }, ''), false)
29
32
  assert.equal(recipeReady({ target: 'part' }, 'room'), true)
30
- assert.equal(recipeReady({ target: 'figure' }, ''), true)
31
- assert.deepEqual(recipesOf([{ source: '{"name":"a","steps":[]}' }, { source: 'not json' }, { source: '{"x":1}' }]).map(r => r.name), ['a'])
33
+ const row = { category: 'door', tags: [], target: 'part' as const, title: 't', description: 'd', hasThumbnail: false }
34
+ assert.deepEqual(templatesOf([{ ...row, name: 'a', source: template('sliding-door') }, { ...row, name: 'b', source: 'not json' }]).map(t => t.name), ['a'])
32
35
  })
33
36
 
34
- test('ํŽธ์ง‘๊ธฐ๋Š” ์กฐ๊ฐ์„ ๋„ํ˜•์ด ์„  ๊ทธ๋Œ€๋กœ ํ’€์–ด ํ•œ ๋‹จ๊ณ„๋กœ ๋„ฃ๊ณ , ๊ฑฐ์ ˆ์€ ํผ์— ์ปค๋„์˜ ๋ง๋กœ', () => {
37
+ test('๋ถ„๋ฅ˜์™€ ๋‚ฑ๋ง๋กœ ์ฐพ๋Š”๋‹ค โ€” ์ œ๋ชฉ ยท ์„ค๋ช… ยท ์ด๋ฆ„ ยท ํƒœ๊ทธ ์–ด๋””์— ์žˆ๋“ , ๋‚ฑ๋ง์ด ๋ชจ๋‘ ์žˆ์–ด์•ผ', () => {
38
+ const list = [
39
+ { name: 'sliding-door', category: 'door', title: '๋ฏธ๋‹ซ์ด ๋ฌธ', description: '์•ž๋ฉด์— ๋ฌธํ‹€๊ณผ ๋ฌธ์ง', tags: ['door', 'lamp'] },
40
+ { name: 'roller-row', category: 'conveyor', title: '๋กค๋Ÿฌ ์ค„', description: '์œ—๋ฉด์— ๋กค๋Ÿฌ', tags: ['roller'] }
41
+ ]
42
+ assert.deepEqual(recipesMatching(list, '', '').map(t => t.name), ['sliding-door', 'roller-row'])
43
+ assert.deepEqual(recipesMatching(list, 'conveyor', '').map(t => t.name), ['roller-row'])
44
+ assert.deepEqual(recipesMatching(list, '', 'lamp ๋ฌธ').map(t => t.name), ['sliding-door'])
45
+ assert.deepEqual(recipesMatching(list, 'door', 'roller'), [])
46
+ })
47
+
48
+ test('์ž‘๋™ ๋ฐฉ์‹์€ ๋ ˆ์‹œํ”ผ์—์„œ ์…ˆํ•œ๋‹ค โ€” ๋ฏธ๋‹ซ์ด ๋ฌธ: ๋‘ ๋ฌธ์ง์ด ๋ฏธ๋„๋Ÿฌ์ง€๊ณ  ํ•˜๋‚˜๋Š” ๋”ฐ๋ผ๊ฐ€๋ฉฐ, ์กฐ์ž‘๊ฐ’ ํ•˜๋‚˜, ๋žจํ”„๋Š” ์กฐ์ž‘๊ฐ’์— ๋”ฐ๋ผ ๋ณด์ž„', () => {
49
+ const door = JSON.parse(template('sliding-door'))
50
+ assert.deepEqual(recipeWorkings(door), [
51
+ { key: 'figure.text.recipe-slides', words: { part: 'left' } },
52
+ { key: 'figure.text.recipe-moves-with', words: { part: 'right', leader: 'left' } },
53
+ { key: 'figure.text.recipe-controls', words: { count: 1 } },
54
+ { key: 'figure.text.recipe-shown-by-control', words: { parts: 'lamp' } }
55
+ ])
56
+ /* The rollers spin on one control, every copy (ADR-0093, 2026-09-26). */
57
+ assert.deepEqual(recipeWorkings(JSON.parse(template('roller-row'))), [
58
+ { key: 'figure.text.recipe-turns', words: { part: 'roller' } },
59
+ { key: 'figure.text.recipe-controls', words: { count: 1 } },
60
+ { key: 'figure.text.recipe-row', words: { part: 'roller' } }
61
+ ])
62
+ })
63
+
64
+ test('๋ชจ๋ธ๋Ÿฌ โ€” ๋ถ€ํ’ˆ ์นธ์˜ ๋‹จ์ถ”๊ฐ€ ์นดํƒˆ๋กœ๊ทธ๋ฅผ ์—ด๊ณ , ๋„ฃ์œผ๋ฉด ํ•œ ๋‹จ๊ณ„๋กœ ๋“ค์–ด๊ฐ€๊ณ  ์ฐฝ์ด ๋‹ซํžŒ๋‹ค; ๋ชฉ๋ก์€ ํŽ˜์ด์ง€๊ฐ€ ์„œ๋ฒ„์—์„œ ํ•œ ๋ฒˆ ์ฝ๋Š”๋‹ค', () => {
65
+ const parts = read('../client/modeller/figure-v3-parts.ts')
66
+ assert.ok(parts.includes("this.ask('open-recipe-catalog', {})"))
35
67
  const editor = read('../client/modeller/v3-editor.ts')
36
68
  const insert = editor.slice(editor.indexOf(' private insertRecipe('), editor.indexOf(' /** The design values of the asset being edited. */'))
37
- assert.ok(insert.includes('v3RecipeIdFor(asset, checked)') && insert.includes('resolveV3Recipe(checked'), '๋‹ค์Œ ๋นˆ ์ด๋ฆ„์œผ๋กœ ํ‘ผ๋‹ค')
38
- assert.ok(insert.includes('this.session!.applyRecipe(steps)'), 'ํ•œ ๋‹จ๊ณ„')
39
- assert.ok(insert.includes('this.recipeError = error instanceof Error ? error.message : String(error)'), '๊ฑฐ์ ˆ์€ ๊ทธ๋Œ€๋กœ')
40
- const session = read('../client/modeller/v3-product-session.ts')
41
- assert.ok(session.includes("workspace.applyProposal({ origin: 'ui', context: at.context, expectedRevision: at.expectedRevision, steps })"), '์‚ฌ๋žŒ์ด ๋„ฃ์€ ๊ฒƒ')
69
+ assert.ok(insert.includes('v3RecipeIdFor(asset, checked)') && insert.includes('this.session!.applyRecipe(steps)') && insert.includes('this.catalogOpen = false'))
70
+ assert.ok(insert.includes('this.recipeError = error instanceof Error ? error.message : String(error)'), '๊ฑฐ์ ˆ์€ ์ปค๋„์˜ ๋ง๋กœ')
42
71
  const page = read('../client/pages/figure-v3-modeller-page.ts')
43
- assert.ok(page.includes('.recipes=${this.recipes}') && page.includes('fetchFigureRecipes()'), 'ํŽ˜์ด์ง€๊ฐ€ ๋ชฉ๋ก์„ ์ฝ์–ด ๊ฑด๋„จ๋‹ค')
72
+ assert.ok(page.includes("availableRecipes(i18next.language || 'en')") && page.includes('.templates=${this.templates}'))
73
+ const catalog = read('../client/modeller/figure-v3-recipe-catalog.ts')
74
+ assert.ok(catalog.includes('describeV3Recipe(t.recipe)'), '๋ถ€ํ’ˆ ์ˆ˜๋Š” ์…ˆํ•œ๋‹ค')
75
+ assert.ok(catalog.includes("${this.guide ? html`<ox-markdown"), '์„ค๋ช…์ด ์—†์œผ๋ฉด ์•„๋ฌด๊ฒƒ๋„ ๋‘์ง€ ์•Š๋Š”๋‹ค โ€” ใ€Œ์ค€๋น„ ์ค‘ใ€ ์—†์Œ')
44
76
  })
45
77
 
46
- test('๊ด€๋ฆฌ ํ™”๋ฉด โ€” ์ฃผ์†Œ์—์„œ ๋„๋ฉ”์ธ์„ ์ฝ๊ณ , ์ œ ์กฐ๊ฐ๊ณผ ์ƒ์œ„ ๋„๋ฉ”์ธ ์กฐ๊ฐ์„ ๊ฐ€๋ฅด๊ณ , ๊ฐ’์ด ๋ฐ›๋Š” ๋ฒ”์œ„๋ฅผ ์ ๋Š”๋‹ค', async () => {
47
- const { subdomainOf, recipeIsOwn, paramRangeOf } = await import('../client/modeller/v3-recipes.ts')
48
- assert.equal(subdomainOf('/domain/plant-1/figure-recipes'), 'plant-1')
49
- assert.equal(subdomainOf('/figure-recipes'), '')
50
- assert.equal(recipeIsOwn({ domain: { subdomain: 'plant-1' } }, 'plant-1'), true)
51
- assert.equal(recipeIsOwn({ domain: { subdomain: 'system' } }, 'plant-1'), false)
52
- assert.equal(recipeIsOwn({ domain: { subdomain: 'system' } }, ''), false, '์ฃผ์†Œ๊ฐ€ ๋„๋ฉ”์ธ์„ ๋งํ•˜์ง€ ์•Š์œผ๋ฉด ์ œ ๊ฒƒ์ด ์•„๋‹ˆ๋‹ค')
53
- assert.equal(paramRangeOf(share), '5 โ€“ 95 %')
54
- assert.equal(paramRangeOf({ name: 'leaves', kind: 'choice', default: 2, options: [1, 2] }), '1 / 2')
55
- const page = read('../client/pages/figure-recipe-page.ts')
56
- assert.ok(page.includes('applyV3Recipe(room.asset, parseV3Recipe(recipe)'), '๋ฏธ๋ฆฌ๋ณด๊ธฐ๋Š” ๊ฒ€์‚ฌํ•˜๋Š” ๋ฐฉ์— ๋„ฃ์€ ๋ชจ์Šต')
57
- assert.ok(page.includes("saveFigureRecipe(row.source, 'draft')"), '์ƒ์œ„ ๋„๋ฉ”์ธ ๊ฒƒ์€ ์ดˆ์•ˆ์œผ๋กœ ๋ณต์‚ฌํ•ด ๊ณ ์นœ๋‹ค')
58
- assert.ok(page.includes('OxPrompt.open('), '์ง€์šฐ๊ธฐ ์ „์— ์•ฑ์˜ ํ™•์ธ ์ฐฝ์œผ๋กœ ๋ฌป๋Š”๋‹ค')
59
- assert.ok(read('../things-factory.config.js').includes("{ tagname: 'figure-recipe-page', page: 'figure-recipes' }"))
78
+ test('๋„ฃ๊ธฐ๋Š” ์นดํƒˆ๋กœ๊ทธ์—์„œ ์˜จ๋‹ค โ€” ํŽธ์ง‘๊ธฐ๋Š” ์นดํƒˆ๋กœ๊ทธ ์š”์†Œ์—์„œ insert-recipe ๋ฅผ ๋“ฃ๋Š”๋‹ค (์ž˜๋ชป ์˜ฎ๊ฒจ์ง„ ์—ฐ๊ฒฐ, 09-26)', () => {
79
+ const editor = read('../client/modeller/v3-editor.ts')
80
+ const catalog = editor.slice(editor.indexOf('<figure-v3-recipe-catalog'), editor.indexOf('</figure-v3-recipe-catalog>'))
81
+ assert.ok(catalog.includes('@insert-recipe=${(e: CustomEvent) => this.insertRecipe(e.detail.recipe, e.detail.values)}'))
60
82
  })
@@ -5,7 +5,6 @@ export default {
5
5
  route,
6
6
  routes: [
7
7
  { tagname: 'figure-list-page', page: 'figure-list' },
8
- { tagname: 'figure-recipe-page', page: 'figure-recipes' },
9
8
  /* One modeller since ADR-0087 step 5; the old V3 address is an alias. The shell builds the DOM from this table. */
10
9
  { tagname: 'figure-v3-modeller-page', page: 'figure-modeller' },
11
10
  { tagname: 'figure-v3-modeller-page', page: 'figure-v3-modeller' }