@things-factory/figure-ui 10.1.81 → 10.1.83
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/figure-assistant.ts +2 -2
- package/client/graphql/index.ts +99 -10
- package/client/graphql/reached.ts +25 -0
- package/client/graphql/store.ts +27 -4
- package/client/modeller/ai-proposal.ts +74 -9
- package/client/modeller/align.ts +2 -3
- package/client/modeller/canvas.ts +18 -5
- package/client/modeller/command-preview.ts +113 -0
- package/client/modeller/editor.ts +800 -53
- package/client/modeller/fields.ts +12 -4
- package/client/modeller/figure-facts.ts +2 -2
- package/client/modeller/figure-first-save.ts +36 -14
- package/client/modeller/figure-history-panel.ts +133 -14
- package/client/modeller/figure-links.ts +357 -94
- package/client/modeller/figure-parts.ts +56 -2
- package/client/modeller/figure-preview.ts +19 -1
- package/client/modeller/figure-properties.ts +3 -3
- package/client/modeller/figure-revert-choice.ts +49 -0
- package/client/modeller/first-save.ts +16 -0
- package/client/modeller/gizmo.ts +0 -16
- package/client/modeller/links.ts +142 -15
- package/client/modeller/motion-ends.ts +83 -0
- package/client/modeller/occupancy.ts +28 -2
- package/client/modeller/product-session.ts +154 -18
- package/client/modeller/refusals.ts +15 -0
- package/client/modeller/revert-request.ts +26 -0
- package/client/modeller/side-panel-styles.ts +12 -0
- package/client/modeller/stage-model.ts +25 -3
- package/client/pages/figure-modeller-page.ts +68 -29
- package/dist-client/figure-assistant.js +2 -2
- package/dist-client/figure-assistant.js.map +1 -1
- package/dist-client/graphql/index.d.ts +39 -2
- package/dist-client/graphql/index.js +63 -9
- package/dist-client/graphql/index.js.map +1 -1
- package/dist-client/graphql/reached.d.ts +10 -0
- package/dist-client/graphql/reached.js +28 -0
- package/dist-client/graphql/reached.js.map +1 -0
- package/dist-client/graphql/store.js +27 -4
- package/dist-client/graphql/store.js.map +1 -1
- package/dist-client/modeller/ai-proposal.d.ts +31 -2
- package/dist-client/modeller/ai-proposal.js +65 -6
- package/dist-client/modeller/ai-proposal.js.map +1 -1
- package/dist-client/modeller/align.d.ts +1 -1
- package/dist-client/modeller/align.js +0 -2
- package/dist-client/modeller/align.js.map +1 -1
- package/dist-client/modeller/canvas.d.ts +11 -0
- package/dist-client/modeller/canvas.js +33 -4
- package/dist-client/modeller/canvas.js.map +1 -1
- package/dist-client/modeller/command-preview.d.ts +35 -0
- package/dist-client/modeller/command-preview.js +95 -0
- package/dist-client/modeller/command-preview.js.map +1 -0
- package/dist-client/modeller/editor.d.ts +107 -1
- package/dist-client/modeller/editor.js +823 -49
- package/dist-client/modeller/editor.js.map +1 -1
- package/dist-client/modeller/fields.d.ts +3 -2
- package/dist-client/modeller/fields.js +10 -3
- package/dist-client/modeller/fields.js.map +1 -1
- package/dist-client/modeller/figure-capability-panel.d.ts +1 -1
- package/dist-client/modeller/figure-clips.d.ts +1 -1
- package/dist-client/modeller/figure-controls.d.ts +1 -1
- package/dist-client/modeller/figure-facts.js +2 -2
- package/dist-client/modeller/figure-facts.js.map +1 -1
- package/dist-client/modeller/figure-first-save.d.ts +3 -4
- package/dist-client/modeller/figure-first-save.js +41 -14
- package/dist-client/modeller/figure-first-save.js.map +1 -1
- package/dist-client/modeller/figure-history-panel.d.ts +31 -0
- package/dist-client/modeller/figure-history-panel.js +130 -14
- package/dist-client/modeller/figure-history-panel.js.map +1 -1
- package/dist-client/modeller/figure-links.d.ts +52 -2
- package/dist-client/modeller/figure-links.js +366 -97
- package/dist-client/modeller/figure-links.js.map +1 -1
- package/dist-client/modeller/figure-material-panel.d.ts +1 -1
- package/dist-client/modeller/figure-parts.d.ts +7 -0
- package/dist-client/modeller/figure-parts.js +55 -2
- package/dist-client/modeller/figure-parts.js.map +1 -1
- package/dist-client/modeller/figure-preview.d.ts +6 -0
- package/dist-client/modeller/figure-preview.js +22 -1
- package/dist-client/modeller/figure-preview.js.map +1 -1
- package/dist-client/modeller/figure-properties.d.ts +1 -1
- package/dist-client/modeller/figure-properties.js +3 -3
- package/dist-client/modeller/figure-properties.js.map +1 -1
- package/dist-client/modeller/figure-revert-choice.d.ts +17 -0
- package/dist-client/modeller/figure-revert-choice.js +59 -0
- package/dist-client/modeller/figure-revert-choice.js.map +1 -0
- package/dist-client/modeller/first-save.d.ts +7 -0
- package/dist-client/modeller/first-save.js +17 -0
- package/dist-client/modeller/first-save.js.map +1 -1
- package/dist-client/modeller/gizmo.d.ts +0 -6
- package/dist-client/modeller/gizmo.js +0 -18
- package/dist-client/modeller/gizmo.js.map +1 -1
- package/dist-client/modeller/links.d.ts +49 -4
- package/dist-client/modeller/links.js +141 -15
- package/dist-client/modeller/links.js.map +1 -1
- package/dist-client/modeller/motion-ends.d.ts +12 -0
- package/dist-client/modeller/motion-ends.js +65 -0
- package/dist-client/modeller/motion-ends.js.map +1 -0
- package/dist-client/modeller/occupancy.d.ts +14 -1
- package/dist-client/modeller/occupancy.js +14 -1
- package/dist-client/modeller/occupancy.js.map +1 -1
- package/dist-client/modeller/product-session.d.ts +43 -2
- package/dist-client/modeller/product-session.js +146 -12
- package/dist-client/modeller/product-session.js.map +1 -1
- package/dist-client/modeller/refusals.d.ts +13 -0
- package/dist-client/modeller/refusals.js +16 -0
- package/dist-client/modeller/refusals.js.map +1 -1
- package/dist-client/modeller/revert-request.d.ts +16 -0
- package/dist-client/modeller/revert-request.js +11 -0
- package/dist-client/modeller/revert-request.js.map +1 -0
- package/dist-client/modeller/side-panel-styles.js +12 -0
- package/dist-client/modeller/side-panel-styles.js.map +1 -1
- package/dist-client/modeller/stage-model.d.ts +18 -2
- package/dist-client/modeller/stage-model.js +21 -3
- package/dist-client/modeller/stage-model.js.map +1 -1
- package/dist-client/pages/figure-modeller-page.d.ts +8 -1
- package/dist-client/pages/figure-modeller-page.js +72 -34
- package/dist-client/pages/figure-modeller-page.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/test/ai-proposal.test.ts +2 -2
- package/test/align.test.ts +50 -5
- package/test/browser/acceptance-page.js +187 -0
- package/test/browser/build.mjs +5 -1
- package/test/browser/revert-page.js +47 -0
- package/test/browser/serve-acceptance.mjs +85 -0
- package/test/browser/serve-revert.mjs +119 -0
- package/test/clip-ai-proposal.test.ts +104 -0
- package/test/clip-consent.test.ts +88 -0
- package/test/command-preview.test.ts +288 -0
- package/test/fields.test.ts +4 -3
- package/test/figure-assistant-surface.test.ts +4 -1
- package/test/first-minute.test.ts +96 -0
- package/test/first-save-suggest.test.ts +42 -0
- package/test/gizmo.test.ts +20 -16
- package/test/joint-fan.test.ts +2 -1
- package/test/layout.test.ts +9 -2
- package/test/links.test.ts +9 -5
- package/test/proposal-record.test.ts +126 -0
- package/test/release-entry.test.ts +28 -0
- package/test/release-panel.test.ts +3 -2
- package/test/resize-with-placement.test.ts +226 -0
- package/test/revert-choice.test.ts +39 -0
- package/test/revert-keeping.test.ts +102 -0
- package/test/save-landed.test.ts +198 -0
- package/translations/en.json +229 -11
- package/translations/ja.json +229 -11
- package/translations/ko.json +230 -12
- package/translations/ms.json +228 -10
- package/translations/zh.json +229 -11
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Designer 2026-09-27: an AI proposal that would stop a clip is refused whole -- nothing written, nothing saved -- and
|
|
3
|
+
* the AI can never agree to it for the person. A proposal that stops none keeps every clip.
|
|
4
|
+
*/
|
|
5
|
+
import { test } from 'node:test'
|
|
6
|
+
import assert from 'node:assert/strict'
|
|
7
|
+
import { readFileSync } from 'node:fs'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
import { addFigureBox, applyFigureAuthoring, createFigureAsset, editFigurePart, parseFigureAsset, serializeFigureAsset, type FigureAsset } from '@hatiolab/figure-model'
|
|
10
|
+
|
|
11
|
+
import { createFigureProductSession } from '../client/modeller/product-session.js'
|
|
12
|
+
import { runFigureProposal, takeFigureOffer, type FigureApplyDetail } from '../client/modeller/ai-proposal.js'
|
|
13
|
+
|
|
14
|
+
const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
|
|
15
|
+
const ko = JSON.parse(read('../translations/ko.json'))
|
|
16
|
+
const say = (key: string, params: Record<string, string> = {}) => ko[key].replace(/\{(\w+)\}/g, (_: string, k: string) => params[k] ?? '')
|
|
17
|
+
|
|
18
|
+
function door(): string {
|
|
19
|
+
let a = createFigureAsset('Door')
|
|
20
|
+
a = addFigureBox(a, { id: 'frame', size: { x: 1000, y: 100, z: 200 }, position: { x: 0, y: 50, z: 0 } })
|
|
21
|
+
a = addFigureBox(a, { id: 'leaf', size: { x: 900, y: 1800, z: 40 }, position: { x: 0, y: 1000, z: 0 } })
|
|
22
|
+
a = addFigureBox(a, { id: 'sign', size: { x: 200, y: 100, z: 10 }, position: { x: 0, y: 2200, z: 200 } })
|
|
23
|
+
a = applyFigureAuthoring(a, { kind: 'attach', part: 'leaf', face: 'bottom', to: { part: 'frame', face: 'top' } })
|
|
24
|
+
const j = a.joints!.find(k => k.body1.part === 'leaf')!
|
|
25
|
+
a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { ...j, type: 'revolute', axis: 'Y', state: { id: 'open' } } })
|
|
26
|
+
a = editFigurePart(a, { kind: 'set-driver', state: 'open', clip: 'swing', duration: 2, keys: [{ seconds: 0, value: 0 }, { seconds: 2, value: 80 }] })
|
|
27
|
+
return serializeFigureAsset(a)
|
|
28
|
+
}
|
|
29
|
+
const saved: string[] = []
|
|
30
|
+
const store = { load: async (id: string) => ({ id, source: door() }), save: async (q: { id?: string; source: string }) => (saved.push(q.source), { id: q.id ?? 'x', source: q.source }) }
|
|
31
|
+
|
|
32
|
+
/** The modeller page's own handler (figure-modeller-page.ts calls it): no copy of it here. */
|
|
33
|
+
const pageOf = (s: ReturnType<typeof createFigureProductSession>) => ({
|
|
34
|
+
offer: (detail: FigureApplyDetail) => (takeFigureOffer(s, detail, { onSavedFigure: false, say }), detail),
|
|
35
|
+
openNew: async () => ({ applied: false, code: 'not-opened' })
|
|
36
|
+
})
|
|
37
|
+
const clipsOf = (s: ReturnType<typeof createFigureProductSession>) => (parseFigureAsset(s.source()) as FigureAsset).drivers?.map(d => d.clip) ?? []
|
|
38
|
+
|
|
39
|
+
test('a proposal that would stop a clip is refused whole: nothing written, nothing saved, the clip named', async () => {
|
|
40
|
+
const s = createFigureProductSession(store)
|
|
41
|
+
await s.open('door')
|
|
42
|
+
const { context, revision } = s.read()
|
|
43
|
+
const before = s.source()
|
|
44
|
+
const steps = [{ kind: 'rename', name: 'Renamed by AI' }, { kind: 'remove-part', target: 'leaf' }] as never[]
|
|
45
|
+
const out = await runFigureProposal({ steps, context: context!, revision: revision! }, pageOf(s), say)
|
|
46
|
+
assert.equal(out.verdict, 'refused')
|
|
47
|
+
assert.equal(out.errorCode, 'CLIPS_DROPPED')
|
|
48
|
+
assert.match(out.note!, /swing/)
|
|
49
|
+
assert.equal(s.source(), before, 'not even the rename before it: refused whole')
|
|
50
|
+
assert.equal(s.read().revision, revision)
|
|
51
|
+
assert.equal(s.read().dirty, false, 'nothing to save')
|
|
52
|
+
assert.equal(saved.length, 0, 'and nothing saved')
|
|
53
|
+
assert.deepEqual(clipsOf(s), ['swing'])
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('a proposal that stops no clip keeps every clip', async () => {
|
|
57
|
+
const s = createFigureProductSession(store)
|
|
58
|
+
await s.open('door')
|
|
59
|
+
const { context, revision } = s.read()
|
|
60
|
+
const steps = [{ kind: 'rename', name: 'Door by AI' }, { kind: 'nudge-part', part: 'sign', by: { x: 50 } }, { kind: 'rename-part', target: 'frame', name: 'jamb' }] as never[]
|
|
61
|
+
const out = await runFigureProposal({ steps, context: context!, revision: revision! }, pageOf(s), say)
|
|
62
|
+
assert.equal(out.verdict, 'accepted')
|
|
63
|
+
assert.deepEqual((parseFigureAsset(s.source()) as FigureAsset).drivers, (parseFigureAsset(door()) as FigureAsset).drivers)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('the AI never agrees for the person: no consent on any proposal path, and none carried in a step', async () => {
|
|
67
|
+
for (const file of ['../client/modeller/ai-proposal.ts', '../client/pages/figure-modeller-page.ts'])
|
|
68
|
+
assert.ok(!/withConsent|dropClips/.test(read(file)), `${file} gives no consent`)
|
|
69
|
+
/* Consent written into the steps or beside them by the model is not consent: the proposal is still refused. */
|
|
70
|
+
const s = createFigureProductSession(store)
|
|
71
|
+
await s.open('door')
|
|
72
|
+
const { context, revision } = s.read()
|
|
73
|
+
const steps = [{ kind: 'remove-part', target: 'leaf', dropClips: ['swing/open'] }] as never[]
|
|
74
|
+
const card = { steps, context: context!, revision: revision!, dropClips: ['swing/open'] } as never
|
|
75
|
+
const out = await runFigureProposal(card, pageOf(s), say)
|
|
76
|
+
assert.equal(out.verdict, 'refused')
|
|
77
|
+
assert.equal(out.errorCode, 'CLIPS_DROPPED', 'refused for the clip it would stop: the consent it carries is read as nothing')
|
|
78
|
+
assert.deepEqual(clipsOf(s), ['swing'])
|
|
79
|
+
const page = read('../client/pages/figure-modeller-page.ts')
|
|
80
|
+
assert.ok(page.includes('if (takeFigureOffer(this.session, detail, {'), 'the page takes an offer by the handler tested here')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
/* Designer 2026-09-27: not calling it is not forbidding it -- an AI proposal made inside a person's consent, still refused. */
|
|
84
|
+
test('an AI proposal made while a person\'s consent is held is refused; clips, figure and version stay', async () => {
|
|
85
|
+
const s = createFigureProductSession(store)
|
|
86
|
+
await s.open('door')
|
|
87
|
+
const { context, revision } = s.read()
|
|
88
|
+
const before = s.source()
|
|
89
|
+
const steps = [{ kind: 'remove-part', target: 'leaf' }] as never[]
|
|
90
|
+
assert.throws(
|
|
91
|
+
() => s.withConsent({ dropClips: ['swing/open'], context: context!, revision: revision! }, () => s.applyProposal(steps, revision!, context!)),
|
|
92
|
+
(e: { code?: string }) => e.code === 'CLIPS_DROPPED'
|
|
93
|
+
)
|
|
94
|
+
/* And through the page's handler, with the consent held around it. */
|
|
95
|
+
const detail = { steps, create: false, context: context!, revision: revision! } as FigureApplyDetail
|
|
96
|
+
s.withConsent({ dropClips: ['swing/open'], context: context!, revision: revision! }, () => takeFigureOffer(s, detail, { onSavedFigure: false, say }))
|
|
97
|
+
assert.equal(detail.applied, false)
|
|
98
|
+
assert.equal(detail.code, 'CLIPS_DROPPED')
|
|
99
|
+
assert.equal(s.source(), before)
|
|
100
|
+
assert.equal(s.read().revision, revision)
|
|
101
|
+
assert.equal(s.read().dirty, false)
|
|
102
|
+
assert.deepEqual(clipsOf(s), ['swing'])
|
|
103
|
+
assert.ok(!/origin: 'ai'[^\n]*consented\(\)/.test(read('../client/modeller/product-session.ts')), 'the AI path passes no consent')
|
|
104
|
+
})
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Designer 2026-09-27: an edit that would stop a clip is told to the person before anything is written, with the clips
|
|
3
|
+
* named; agreed, it lets exactly those go in one step, and undo brings them back. Consent never outlives the edit it was
|
|
4
|
+
* given for.
|
|
5
|
+
*/
|
|
6
|
+
import { test } from 'node:test'
|
|
7
|
+
import assert from 'node:assert/strict'
|
|
8
|
+
import { readFileSync } from 'node:fs'
|
|
9
|
+
import { fileURLToPath } from 'node:url'
|
|
10
|
+
import { addFigureBox, applyFigureAuthoring, createFigureAsset, editFigurePart, parseFigureAsset, serializeFigureAsset, type FigureAsset } from '@hatiolab/figure-model'
|
|
11
|
+
|
|
12
|
+
import { createFigureProductSession } from '../client/modeller/product-session.js'
|
|
13
|
+
|
|
14
|
+
const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
|
|
15
|
+
|
|
16
|
+
/** Two doors on a frame, each swinging on a clip of its own. */
|
|
17
|
+
function doors(): string {
|
|
18
|
+
let a = createFigureAsset('Doors')
|
|
19
|
+
a = addFigureBox(a, { id: 'frame', size: { x: 2000, y: 100, z: 200 }, position: { x: 0, y: 50, z: 0 } })
|
|
20
|
+
for (const [id, x] of [['left', -500], ['right', 500]] as const) {
|
|
21
|
+
a = addFigureBox(a, { id, size: { x: 900, y: 1800, z: 40 }, position: { x, y: 1000, z: 0 } })
|
|
22
|
+
a = applyFigureAuthoring(a, { kind: 'attach', part: id, face: 'bottom', to: { part: 'frame', face: 'top' } })
|
|
23
|
+
const j = a.joints!.find(k => k.body1.part === id)!
|
|
24
|
+
a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { ...j, type: 'revolute', axis: 'Y', state: { id: `${id}-open` } } })
|
|
25
|
+
a = editFigurePart(a, { kind: 'set-driver', state: `${id}-open`, clip: `${id}-swing`, duration: 2, keys: [{ seconds: 0, value: 0 }, { seconds: 2, value: 80 }] })
|
|
26
|
+
}
|
|
27
|
+
return serializeFigureAsset(a)
|
|
28
|
+
}
|
|
29
|
+
const store = { load: async (id: string) => ({ id, source: doors() }), save: async (q: { id?: string; source: string }) => ({ id: q.id ?? 'x', source: q.source }) }
|
|
30
|
+
const clipsOf = (s: ReturnType<typeof createFigureProductSession>) => (parseFigureAsset(s.source()) as FigureAsset).drivers?.map(d => d.clip) ?? []
|
|
31
|
+
const fixed = (s: ReturnType<typeof createFigureProductSession>, part: string) => {
|
|
32
|
+
const j = (parseFigureAsset(s.source()) as FigureAsset).joints!.find(k => k.body1.part === part)!
|
|
33
|
+
return { kind: 'set-joint', joint: { id: j.id, type: 'fixed', body0: j.body0, body1: j.body1, origin: j.origin } } as never
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test('refused and named first; agreed, exactly those go in one step; undo brings them back; consent does not outlive it', async () => {
|
|
37
|
+
const s = createFigureProductSession(store)
|
|
38
|
+
await s.open('doors')
|
|
39
|
+
const revision = s.read().revision
|
|
40
|
+
assert.throws(() => s.command(fixed(s, 'left')), (e: { code?: string; clips?: Array<{ id: string; clip: string; why: string }> }) =>
|
|
41
|
+
e.code === 'CLIPS_DROPPED' && e.clips?.map(c => `${c.clip} ${c.why}`).join() === 'left-swing gone')
|
|
42
|
+
assert.equal(s.read().revision, revision, 'nothing written')
|
|
43
|
+
assert.deepEqual(clipsOf(s), ['left-swing', 'right-swing'])
|
|
44
|
+
const ids = ['left-swing/left-open']
|
|
45
|
+
const at = () => ({ context: s.read().context!, revision: s.read().revision! })
|
|
46
|
+
s.withConsent({ dropClips: ids, ...at() }, () => s.command(fixed(s, 'left')))
|
|
47
|
+
assert.deepEqual(clipsOf(s), ['right-swing'], 'only the one agreed to')
|
|
48
|
+
s.undo()
|
|
49
|
+
assert.deepEqual(clipsOf(s), ['left-swing', 'right-swing'], 'one undo, and the clip is back')
|
|
50
|
+
/* The same edit again, not agreed to this time: asked again -- the consent ended with the edit it was given for. */
|
|
51
|
+
assert.throws(() => s.command(fixed(s, 'left')), (e: { code?: string }) => e.code === 'CLIPS_DROPPED', 'consent ended with its edit')
|
|
52
|
+
/* A part taken away with its clip is asked about the same way. */
|
|
53
|
+
assert.throws(() => s.edit({ kind: 'remove-part', target: 'right' }), (e: { code?: string }) => e.code === 'CLIPS_DROPPED')
|
|
54
|
+
assert.deepEqual(clipsOf(s), ['left-swing', 'right-swing'])
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
/* Designer 2026-09-27: a consent is for the edit it was asked about, on the figure and version it was asked on. */
|
|
58
|
+
test('a consent is used by one edit only, on the version and figure it was given for', async () => {
|
|
59
|
+
const s = createFigureProductSession(store)
|
|
60
|
+
await s.open('doors')
|
|
61
|
+
const ids = ['left-swing/left-open']
|
|
62
|
+
const asked = { context: s.read().context!, revision: s.read().revision! }
|
|
63
|
+
/* An edit in between: the version it was given on is gone. Refused; nothing written. */
|
|
64
|
+
s.command({ kind: 'rename', name: 'Doors 2' } as never)
|
|
65
|
+
const revision = s.read().revision
|
|
66
|
+
assert.throws(() => s.withConsent({ dropClips: ids, ...asked }, () => s.command(fixed(s, 'left'))), (e: { code?: string }) => e.code === 'CONSENT_STALE')
|
|
67
|
+
assert.equal(s.read().revision, revision)
|
|
68
|
+
assert.deepEqual(clipsOf(s), ['left-swing', 'right-swing'])
|
|
69
|
+
/* Another figure's consent. */
|
|
70
|
+
assert.throws(() => s.withConsent({ dropClips: ids, context: 'another', revision: s.read().revision! }, () => s.command(fixed(s, 'left'))), (e: { code?: string }) => e.code === 'CONSENT_STALE')
|
|
71
|
+
/* Taken by the first edit made under it: a second edit, the one that would stop the clip, is asked about again. */
|
|
72
|
+
const now = { context: s.read().context!, revision: s.read().revision! }
|
|
73
|
+
assert.throws(() => s.withConsent({ dropClips: ids, ...now }, () => (s.command({ kind: 'rename', name: 'Doors 3' } as never), s.command(fixed(s, 'left')))), (e: { code?: string }) => e.code === 'CLIPS_DROPPED')
|
|
74
|
+
assert.deepEqual(clipsOf(s), ['left-swing', 'right-swing'])
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('the modeller asks on every path an edit can stop a clip, in every language', () => {
|
|
78
|
+
const editor = read('../client/modeller/editor.ts')
|
|
79
|
+
for (const path of ['() => this.run(action)', '() => this.linkCommand(action)', '() => this.removePart(id, refitRoom)'])
|
|
80
|
+
assert.ok(editor.includes(`if (this.askClipLoss(error, ${path})) return`), path)
|
|
81
|
+
assert.ok(editor.includes('if (!now || now.context !== asked.context || now.revision !== asked.revision) {'), 'what was asked about is checked after the answer')
|
|
82
|
+
assert.ok(editor.includes('asked.session.withConsent({ dropClips: clips.map(c => c.id), context: asked.context, revision: asked.revision }, again)'))
|
|
83
|
+
for (const lang of ['en', 'ja', 'ko', 'ms', 'zh']) {
|
|
84
|
+
const t = JSON.parse(read(`../translations/${lang}.json`))
|
|
85
|
+
for (const key of ['figure.text.clips-would-go', 'figure.text.clip-gone', 'figure.text.clip-changed', 'figure.button.let-clips-go', 'figure.text.clips-consent-stale']) assert.ok(t[key], `${lang} ${key}`)
|
|
86
|
+
assert.ok(t['figure.text.clips-would-go'].includes('{clips}') && t['figure.text.clip-gone'].includes('{clip}'), lang)
|
|
87
|
+
}
|
|
88
|
+
})
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ADR-0097 §5.5 (designer 2026-09-27): a fastening is seen before it is done -- the bodies it moves where they would
|
|
3
|
+
* stand, the face they would meet, and in words how far and how each turns. It changes nothing: no revision, no history,
|
|
4
|
+
* no save, no clip. It is shown only for the figure, version and part it was worked out for.
|
|
5
|
+
*/
|
|
6
|
+
import { test } from 'node:test'
|
|
7
|
+
import assert from 'node:assert/strict'
|
|
8
|
+
import { readFileSync } from 'node:fs'
|
|
9
|
+
import { fileURLToPath } from 'node:url'
|
|
10
|
+
import { addFigureBox, applyFigureAuthoring, createFigureAsset, figurePartFaceOf, serializeFigureAsset } from '@hatiolab/figure-model'
|
|
11
|
+
|
|
12
|
+
import { createFigureProductSession } from '../client/modeller/product-session.js'
|
|
13
|
+
import { faceHighlightOf, ghostPartsOf, previewWordsOf, type HeldPreview } from '../client/modeller/command-preview.js'
|
|
14
|
+
|
|
15
|
+
const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
|
|
16
|
+
const ko = JSON.parse(read('../translations/ko.json'))
|
|
17
|
+
const say = (key: string, params: Record<string, string> = {}) => {
|
|
18
|
+
assert.ok(ko[key], `ko has no words for ${key}`)
|
|
19
|
+
return ko[key].replace(/\{(\w+)\}/g, (_: string, k: string) => params[k] ?? '')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function bench(): string {
|
|
23
|
+
let a = createFigureAsset('Bench')
|
|
24
|
+
a = addFigureBox(a, { id: 'leg', size: { x: 60, y: 700, z: 60 }, position: { x: -400, y: 350, z: 0 } })
|
|
25
|
+
a = addFigureBox(a, { id: 'top', size: { x: 1200, y: 40, z: 600 }, position: { x: 0, y: 900, z: 0 } })
|
|
26
|
+
a = addFigureBox(a, { id: 'lamp', size: { x: 80, y: 80, z: 80 }, position: { x: 200, y: 960, z: 100 } })
|
|
27
|
+
a = applyFigureAuthoring(a, { kind: 'attach', part: 'lamp', face: 'bottom', to: { part: 'top', face: 'top' } })
|
|
28
|
+
return serializeFigureAsset(a)
|
|
29
|
+
}
|
|
30
|
+
const store = { load: async (id: string) => ({ id, source: bench() }), save: async (q: { id?: string; source: string }) => ({ id: q.id ?? 'x', source: q.source }) }
|
|
31
|
+
const onLeg = { kind: 'attach', part: 'top', face: 'bottom', to: { part: 'leg', face: 'top' } } as never
|
|
32
|
+
|
|
33
|
+
test('seen, not done: the session\'s preview writes nothing, and names the version it was worked out on', async () => {
|
|
34
|
+
const s = createFigureProductSession(store)
|
|
35
|
+
await s.open('bench')
|
|
36
|
+
const before = s.source()
|
|
37
|
+
const seen = s.previewCommand(onLeg)
|
|
38
|
+
assert.equal(s.source(), before)
|
|
39
|
+
assert.equal(s.read().revision, seen.revision)
|
|
40
|
+
assert.equal(s.read().canUndo, false)
|
|
41
|
+
assert.equal(s.read().dirty, false)
|
|
42
|
+
/* Drawn: only what moves, translucent, never the part as it stands. */
|
|
43
|
+
const held: HeldPreview = { context: seen.context, revision: seen.revision, key: 'k', geometry: seen.geometry, moves: seen.moves }
|
|
44
|
+
const ghost = ghostPartsOf(held)
|
|
45
|
+
assert.deepEqual(ghost.map(g => g.id).sort(), ['__preview:lamp', '__preview:top'], 'the top and the lamp on it')
|
|
46
|
+
assert.ok(ghost.every(g => g.material.transparent && g.material.token === '__preview'))
|
|
47
|
+
/* Said: how far the part goes, and what goes with it. */
|
|
48
|
+
const words = previewWordsOf(held, 'top', say)
|
|
49
|
+
/* Attached with no alignment, it is centred on the leg too: said, not hidden. */
|
|
50
|
+
assert.deepEqual(words, ['도형 기준으로 X −400 · Y −180 mm 옮겨집니다.', '함께 움직이는 부품: lamp'])
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('a turn is said as one axis and one signed angle; a refusal clears the result and says why', () => {
|
|
54
|
+
const turned: HeldPreview = { context: 'c', revision: 0, key: 'k', moves: [{ body: 'box', part: 'box', translate: [0, 0, 0], turn: { axis: 'y', degrees: -30 } }] }
|
|
55
|
+
assert.deepEqual(previewWordsOf(turned, 'box', say), ['도형 기준 Y축 둘레로 −30° 돕니다.'])
|
|
56
|
+
const slanted: HeldPreview = { context: 'c', revision: 0, key: 'k', moves: [{ body: 'box', part: 'box', translate: [0, 0, 0], turn: { axis: [0.577, 0.577, 0.577], degrees: 120 } }] }
|
|
57
|
+
assert.match(previewWordsOf(slanted, 'box', say)[0]!, /기울어진 축\(0\.58, 0\.58, 0\.58\) 둘레로 \+120°/)
|
|
58
|
+
const refused: HeldPreview = { context: 'c', revision: 0, key: 'k', refused: '거꾸로 붙일 수 없습니다', geometry: [{}], moves: turned.moves }
|
|
59
|
+
assert.deepEqual(previewWordsOf(refused, 'box', say), ['이대로는 붙일 수 없습니다: 거꾸로 붙일 수 없습니다'])
|
|
60
|
+
const rows: HeldPreview = { context: 'c', revision: 0, key: 'k', moves: [{ body: 'frame', part: 'frame', translate: [0, 100, 0] }, ...[0, 1, 2].map(i => ({ body: `["roller.repeat",${i},"roller.placed"]`, part: 'roller', translate: [0, 100, 0] as [number, number, number] }))] }
|
|
61
|
+
assert.equal(previewWordsOf(rows, 'frame', say)[1], '함께 움직이는 부품: roller 3개')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('the face it would meet is lit where attaching reads it, just off the face', async () => {
|
|
65
|
+
const s = createFigureProductSession(store)
|
|
66
|
+
await s.open('bench')
|
|
67
|
+
const { parseFigureAsset } = await import('@hatiolab/figure-model')
|
|
68
|
+
const face = figurePartFaceOf(parseFigureAsset(s.source()), 'leg', 'top')!
|
|
69
|
+
const lit = faceHighlightOf(face)!
|
|
70
|
+
assert.equal(lit.id, '__target-face')
|
|
71
|
+
assert.deepEqual(lit.shape, { provider: 'box', dimensions: { width: 60, height: 2, depth: 60 } })
|
|
72
|
+
assert.deepEqual(lit.pose.t.map(v => Math.round(v)), [-400, 702, 0], 'the leg top is at 700; lifted 1 mm, half its thickness above')
|
|
73
|
+
assert.equal(faceHighlightOf(undefined), null)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('the editor shows it only for the figure, version and part it was worked out for, and clears it when the form lets go', () => {
|
|
77
|
+
const editor = read('../client/modeller/editor.ts')
|
|
78
|
+
assert.ok(editor.includes('if (!p || !view || p.context !== view.context || p.revision !== view.revision || p.part !== this.selected || this.middle !== \'edit\' || this.tab !== \'properties\') return undefined'))
|
|
79
|
+
assert.ok(editor.includes('this.preview = { ...held, refused: this.said(error) }'), 'refused: no earlier result stays')
|
|
80
|
+
assert.ok(editor.includes('.ghost=${this.previewShown().ghost}') && editor.includes('.highlight=${this.previewShown().highlight}'))
|
|
81
|
+
const links = read('../client/modeller/figure-links.ts')
|
|
82
|
+
assert.ok(links.includes("this.dispatchEvent(new CustomEvent('preview-command', { detail: this.candidate ?? null, bubbles: true, composed: true }))"))
|
|
83
|
+
assert.ok(links.includes("if (this.told && this.told !== 'null') this.dispatchEvent(new CustomEvent('preview-command', { detail: null, bubbles: true, composed: true }))"), 'leaving the panel clears it')
|
|
84
|
+
assert.ok(links.includes("if (this.section !== 'shape' && !this.told) return"), 'the sections without the form do not clear it')
|
|
85
|
+
assert.ok(links.includes('<button act ?disabled=${this.locked || !action} @click=${() => action && this.command(action)}>'), 'the button applies the command the preview showed')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('the details fold, and what they hold that is not the default stays said', () => {
|
|
89
|
+
const links = read('../client/modeller/figure-links.ts')
|
|
90
|
+
assert.ok(links.includes('<details fasten-detail>'))
|
|
91
|
+
assert.ok(links.includes("detail.length ? detail.join(' · ') : i18next.t('figure.text.fasten-summary-default')"))
|
|
92
|
+
for (const lang of ['en', 'ja', 'ko', 'ms', 'zh']) {
|
|
93
|
+
const t = JSON.parse(read(`../translations/${lang}.json`))
|
|
94
|
+
for (const key of ['preview-refused', 'preview-stays', 'preview-moves', 'preview-turns-named', 'preview-turns-slanted', 'preview-carries', 'preview-copies', 'preview-clips', 'preview-face-unknown', 'target-would-loop', 'fasten-summary-default', 'fasten-summary-gap', 'fasten-summary-twist', 'fasten-summary-off-centre', 'fasten-summary-edge-min', 'fasten-summary-edge-max', 'fasten-summary-follows'])
|
|
95
|
+
assert.ok(t[`figure.text.${key}`], `${lang} ${key}`)
|
|
96
|
+
assert.ok(t['figure.label.fasten-detail'] && t['figure.label.facing'], lang)
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
/* ADR-0097 §5.5 (Q2): the list stays flat; a part says what it is fastened to, beside its name. */
|
|
101
|
+
test('the part list names what each part is fastened to, without reordering or nesting it', () => {
|
|
102
|
+
const editor = read('../client/modeller/editor.ts')
|
|
103
|
+
assert.ok(editor.includes("'plane' in j.body0 ? i18next.t('figure.label.mounting-plane') : `${partLabelOf(j.body0.part)} · ${i18next.t(`figure.label.face-${j.body0.face}`)}`"))
|
|
104
|
+
assert.ok(editor.includes('...(fastened.has(id) ? { fastenedTo: fastened.get(id)! } : {})'))
|
|
105
|
+
const parts = read('../client/modeller/figure-parts.ts')
|
|
106
|
+
assert.ok(parts.includes('<small fastened-to title=${i18next.t(\'figure.label.fastened-to\')}>${part.fastenedTo}</small>'))
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
/* Detaching is seen before it is done: "it stays where it is" only when the copy shows it does. */
|
|
110
|
+
test('detaching says what it would do: stays, what stays fastened to it, the clips it would stop', async () => {
|
|
111
|
+
const s = createFigureProductSession(store)
|
|
112
|
+
await s.open('bench')
|
|
113
|
+
const seen = s.previewCommand({ kind: 'detach', part: 'lamp' } as never)
|
|
114
|
+
assert.equal(seen.moves.length, 0, 'the lamp stays where it stands')
|
|
115
|
+
assert.equal(s.read().revision, 0)
|
|
116
|
+
const editor = read('../client/modeller/editor.ts')
|
|
117
|
+
assert.ok(editor.includes("const own = seen.moves.some(m => m.body === this.selected)"))
|
|
118
|
+
assert.ok(editor.includes(": [say('figure.text.detach-stays', { from })]"), 'said only when it stays')
|
|
119
|
+
assert.equal(say('figure.text.detach-stays', { from: 'top' }), '떼면 지금 자리에 그대로 남고, top과의 붙임만 없어집니다.')
|
|
120
|
+
const links = read('../client/modeller/figure-links.ts')
|
|
121
|
+
assert.equal(links.match(/\$\{this\.renderDetachWords\(\)\}/g)?.length, 2, 'beside both buttons that detach')
|
|
122
|
+
for (const lang of ['en', 'ja', 'ko', 'ms', 'zh']) {
|
|
123
|
+
const t = JSON.parse(read(`../translations/${lang}.json`))
|
|
124
|
+
for (const key of ['detach-stays', 'detach-moves', 'detach-keeps-children', 'detach-clips', 'detach-refused']) assert.ok(t[`figure.text.${key}`], `${lang} ${key}`)
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
/* A panel made with a part already chosen loads that part's fastening: "editing the fastening it has" over an empty target was wrong. */
|
|
129
|
+
test('the fastening form opens on the part\'s own fastening however the panel came to be', () => {
|
|
130
|
+
const links = read('../client/modeller/figure-links.ts')
|
|
131
|
+
assert.ok(links.includes('if (this.asset && this.partId && this.partId !== this.loadedFor) {'))
|
|
132
|
+
assert.ok(!links.includes("changed.get('partId') !== undefined"), 'not only when the part changes after the panel was made')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
/* ADR-0097 §5.5: a row is said as what it makes, read as drawn; where it cannot be made, why. */
|
|
136
|
+
test('a row is seen before it is made, read as figure-model draws it -- and said where it cannot be made', async () => {
|
|
137
|
+
const { rowWordsOf } = await import('../client/modeller/command-preview.js')
|
|
138
|
+
const { rowBlockOf } = await import('../client/modeller/links.js')
|
|
139
|
+
const { figureRowOf, previewFigureCommand } = await import('@hatiolab/figure-model')
|
|
140
|
+
let a = createFigureAsset('Conveyor')
|
|
141
|
+
a = addFigureBox(a, { id: 'frame', size: { x: 2000, y: 100, z: 600 }, position: { x: 0, y: 50, z: 0 } })
|
|
142
|
+
a = addFigureBox(a, { id: 'roller', size: { x: 60, y: 60, z: 560 }, position: { x: 0, y: 130, z: 0 } })
|
|
143
|
+
assert.equal(rowBlockOf(a, 'roller'), 'unfastened')
|
|
144
|
+
a = applyFigureAuthoring(a, { kind: 'attach', part: 'roller', face: 'bottom', to: { part: 'frame', face: 'top' } })
|
|
145
|
+
assert.equal(rowBlockOf(a, 'roller'), undefined)
|
|
146
|
+
const seen = previewFigureCommand(a, { kind: 'make-repeat', part: 'roller', axis: 'x', pitch: 200 } as never)
|
|
147
|
+
const held: HeldPreview = { context: 'c', revision: 0, key: 'k', geometry: seen.geometry, moves: seen.moves, added: seen.added, row: figureRowOf(seen.asset, 'roller') }
|
|
148
|
+
assert.deepEqual(previewWordsOf(held, 'roller', say), ['roller 10개 · X 방향 · pitch 200 mm (중심 간격)', '가운데 정렬 · 양 끝에 70 mm씩 남음'])
|
|
149
|
+
assert.equal(ghostPartsOf(held).length, 10, 'the copies it would draw, translucent')
|
|
150
|
+
/* A set count is spread over the length: said as drawn, 970 apart, nothing left at the ends. */
|
|
151
|
+
const three = figureRowOf(applyFigureAuthoring(a, { kind: 'make-repeat', part: 'roller', axis: 'x', pitch: 1, count: 3 }), 'roller')
|
|
152
|
+
assert.deepEqual(rowWordsOf(three, say), ['roller 3개 · X 방향 · pitch 970 mm (중심 간격)', '가운데 정렬 · 양 끝에 0 mm씩 남음'])
|
|
153
|
+
const links = read('../client/modeller/figure-links.ts')
|
|
154
|
+
assert.ok(links.includes("<p hint row-blocked>${i18next.t(`figure.text.row-blocked-${blocked ?? 'unfastened'}`, { part: this.partId })}</p>"), 'not hidden: why')
|
|
155
|
+
assert.ok(links.includes("this.candidate = this.working === 'row' && this.rowCandidate ? { action: this.rowCandidate } : this.fastenCandidate"), 'the form being worked on is the one seen')
|
|
156
|
+
const editor = read('../client/modeller/editor.ts')
|
|
157
|
+
assert.ok(editor.includes("const row = candidate.action.kind === 'make-repeat' ? figureRowOf(seen.asset, this.selected, this.currentInputs()) : undefined"))
|
|
158
|
+
assert.ok(editor.includes('<ul row-reading>${rowWordsOf(this.rowNow(asset)'), 'a row made says the same, as it stands')
|
|
159
|
+
for (const lang of ['en', 'ja', 'ko', 'ms', 'zh']) {
|
|
160
|
+
const t = JSON.parse(read(`../translations/${lang}.json`))
|
|
161
|
+
for (const key of ['row-count-pitch', 'row-count-uneven', 'row-ends-centred', 'row-ends', 'row-ends-unknown', 'row-companions', 'row-count-spreads', 'row-blocked-unfastened', 'row-blocked-on-plane', 'row-blocked-holds-parts'])
|
|
162
|
+
assert.ok(t[`figure.text.${key}`], `${lang} ${key}`)
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
/* Designer 2026-09-27: a pitch the board may set, typed into its field, is one edit -- undone once, whatever path it takes. */
|
|
167
|
+
test('a published pitch typed into its field is one edit, undone once', async () => {
|
|
168
|
+
let a = createFigureAsset('Conveyor')
|
|
169
|
+
a = addFigureBox(a, { id: 'frame', size: { x: 2000, y: 100, z: 600 }, position: { x: 0, y: 50, z: 0 } })
|
|
170
|
+
a = addFigureBox(a, { id: 'roller', size: { x: 60, y: 60, z: 560 }, position: { x: 0, y: 130, z: 0 } })
|
|
171
|
+
a = applyFigureAuthoring(a, { kind: 'attach', part: 'roller', face: 'bottom', to: { part: 'frame', face: 'top' } })
|
|
172
|
+
a = applyFigureAuthoring(a, { kind: 'make-repeat', part: 'roller', axis: 'x', pitch: 200 })
|
|
173
|
+
const source = serializeFigureAsset(a)
|
|
174
|
+
const s = createFigureProductSession({ load: async (id: string) => ({ id, source }), save: async (q: { id?: string; source: string }) => ({ id: q.id ?? 'x', source: q.source }) })
|
|
175
|
+
await s.open('conveyor')
|
|
176
|
+
/* Published as the modeller does it (editor.renderPublicPitch): one input, the row reading it, published. */
|
|
177
|
+
s.applyRecipe([
|
|
178
|
+
{ kind: 'add-design-input', id: 'pitch', unit: 'mm', min: 100, max: 400, value: 200 },
|
|
179
|
+
{ kind: 'set-repeat', part: 'roller', pitch: { from: { of: 'input', input: 'pitch' } } },
|
|
180
|
+
{ kind: 'publish-input', input: 'pitch', label: 'pitch' }
|
|
181
|
+
] as never)
|
|
182
|
+
const count = () => s.read().geometry.filter(g => g.id.includes('roller')).length
|
|
183
|
+
const before = { revision: s.read().revision, count: count() }
|
|
184
|
+
assert.equal(before.count, 10)
|
|
185
|
+
/* The field's one change, as setPitch sends it. */
|
|
186
|
+
s.apply([{ kind: 'set-design', input: 'pitch', unit: 'mm', value: 300 }])
|
|
187
|
+
assert.equal(count(), 7)
|
|
188
|
+
assert.equal(s.read().revision, before.revision! + 1, 'one step')
|
|
189
|
+
s.undo()
|
|
190
|
+
assert.equal(count(), 10, 'one undo, and it is back')
|
|
191
|
+
assert.equal(s.read().fields.find(f => f.id === 'pitch')?.value, 200)
|
|
192
|
+
const editor = read('../client/modeller/editor.ts')
|
|
193
|
+
assert.ok(editor.includes("this.run(() => this.session!.apply([{ kind: 'set-design', input: from, unit: 'mm', value: pitch }]))"), 'the field sends one change, on change -- not per key')
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
/* ADR-0097 §5.5: a motion is named by its parts, and where it takes the part is drawn at both ends of its range. */
|
|
197
|
+
test('a motion is named by its parts, and drawn at both ends of its range -- only the ends', async () => {
|
|
198
|
+
const { motionEndsOf } = await import('../client/modeller/motion-ends.js')
|
|
199
|
+
let a = createFigureAsset('Doors')
|
|
200
|
+
a = addFigureBox(a, { id: 'frame', size: { x: 2000, y: 100, z: 200 }, position: { x: 0, y: 50, z: 0 } })
|
|
201
|
+
a = addFigureBox(a, { id: 'leaf', size: { x: 900, y: 1800, z: 40 }, position: { x: 0, y: 1000, z: 0 } })
|
|
202
|
+
a = addFigureBox(a, { id: 'gate', size: { x: 400, y: 400, z: 40 }, position: { x: 700, y: 300, z: 0 } })
|
|
203
|
+
a = applyFigureAuthoring(a, { kind: 'attach', part: 'leaf', face: 'bottom', to: { part: 'frame', face: 'top' } })
|
|
204
|
+
a = applyFigureAuthoring(a, { kind: 'attach', part: 'gate', face: 'bottom', to: { part: 'frame', face: 'top' } })
|
|
205
|
+
const j = (part: string) => a.joints!.find(k => k.body1.part === part)!
|
|
206
|
+
assert.equal(motionEndsOf(a, 'leaf'), null, 'not moving: nothing drawn')
|
|
207
|
+
a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { ...j('leaf'), type: 'revolute', axis: 'Y', lowerLimit: 0, upperLimit: 90, state: { id: 'open' } } })
|
|
208
|
+
a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { ...j('gate'), type: 'prismatic', axis: 'X', lowerLimit: 0, upperLimit: 300, state: { id: 'slide' } } })
|
|
209
|
+
/* With the values the screen holds -- design and control alike -- as the editor passes them. */
|
|
210
|
+
const held = { ...a.designInputs, ...a.stateDefaults, 'frame.size.x': 2400 }
|
|
211
|
+
const turn = motionEndsOf(a, 'leaf', held)!
|
|
212
|
+
assert.deepEqual([turn.control, turn.min, turn.max], ['open', 0, 90])
|
|
213
|
+
assert.deepEqual(turn.ghost.map(g => g.id).sort(), ['__preview:max:leaf', '__preview:min:leaf'], 'the leaf at 0 and at 90, nothing else')
|
|
214
|
+
const slide = motionEndsOf(a, 'gate', held)!
|
|
215
|
+
const line = slide.ghost.find(g => g.id === '__target-face:slide')!
|
|
216
|
+
assert.equal(Math.round((line.shape.dimensions as { width: number }).width), 300, 'the line it travels, its whole stroke')
|
|
217
|
+
const editor = read('../client/modeller/editor.ts')
|
|
218
|
+
assert.ok(editor.includes("if (!p && this.tab === 'motion' && this.middle === 'edit') return { ghost: this.motionEnds()?.ghost ?? [], highlight: null }"))
|
|
219
|
+
const links = read('../client/modeller/figure-links.ts')
|
|
220
|
+
assert.ok(links.includes("i18next.t('figure.text.motion-summary', {"))
|
|
221
|
+
assert.ok(links.includes("new CustomEvent('go-fasten', { bubbles: true, composed: true })"), 'a part fastened to nothing is taken to fastening')
|
|
222
|
+
assert.equal(say('figure.text.motion-summary', { part: 'leaf', against: 'frame', kind: '회전', axis: 'Y' }), 'leaf · frame 기준 · Y축 회전')
|
|
223
|
+
assert.match(say('figure.text.motion-ends-shown', { min: '0', max: '90', unit: 'deg' }), /그 사이는 확인하지 않습니다/)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
/* ADR-0097 §5.5, Q3: past the face's edge is said beside the fastening, with the way to change it; nothing moves. */
|
|
227
|
+
test('past the edge of its face is said beside the fastening, with the way to change alignment and gap', () => {
|
|
228
|
+
const editor = read('../client/modeller/editor.ts')
|
|
229
|
+
assert.ok(editor.includes("if (own?.state === 'out') words.push(say('figure.text.overhang-out', { ...where(own), sides: sides(own) }))"))
|
|
230
|
+
assert.ok(editor.includes("else if (own?.state === 'possible') words.push(say('figure.text.overhang-possible', where(own)))"))
|
|
231
|
+
assert.ok(editor.includes("else if (own?.state === 'unknown') words.push(say('figure.text.overhang-unknown', where(own)))"))
|
|
232
|
+
const links = read('../client/modeller/figure-links.ts')
|
|
233
|
+
assert.ok(links.includes("<button act @click=${() => this.openDetail()}><md-icon>tune</md-icon>${i18next.t('figure.button.fix-alignment')}</button>"))
|
|
234
|
+
assert.equal(say('figure.text.overhang-out', { on: 'top', face: '윗면', sides: say('figure.text.overhang-side-high', { axis: 'X', mm: '100' }) }), 'top의 윗면 가장자리 밖으로 나와 있습니다: X 높은 쪽 100 mm. 모델의 점유 공간과는 따로 봅니다.')
|
|
235
|
+
for (const lang of ['en', 'ja', 'ko', 'ms', 'zh']) {
|
|
236
|
+
const t = JSON.parse(read(`../translations/${lang}.json`))
|
|
237
|
+
for (const key of ['figure.text.overhang-out', 'figure.text.overhang-possible', 'figure.text.overhang-unknown', 'figure.text.overhang-held', 'figure.text.overhang-side-low', 'figure.text.overhang-side-high', 'figure.button.fix-alignment']) assert.ok(t[key], `${lang} ${key}`)
|
|
238
|
+
}
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
/* Designer 2026-09-27: a moving part may be a row, and a row may be given a motion -- the modeller's own forms keep the row. */
|
|
242
|
+
test('a moving part is made into a row, and a row given or relieved of a motion stays a row, by the modeller\'s forms', async () => {
|
|
243
|
+
const { rowAxesOf, rowBlockOf, motionJoint, fixedJointOf } = await import('../client/modeller/links.js')
|
|
244
|
+
let a = createFigureAsset('Conveyor')
|
|
245
|
+
a = addFigureBox(a, { id: 'frame', size: { x: 2000, y: 100, z: 600 }, position: { x: 0, y: 50, z: 0 } })
|
|
246
|
+
a = addFigureBox(a, { id: 'roller', size: { x: 60, y: 60, z: 560 }, position: { x: 0, y: 130, z: 0 } })
|
|
247
|
+
a = applyFigureAuthoring(a, { kind: 'attach', part: 'roller', face: 'bottom', to: { part: 'frame', face: 'top' } })
|
|
248
|
+
const source = serializeFigureAsset(a)
|
|
249
|
+
const s = createFigureProductSession({ load: async (id: string) => ({ id, source }), save: async (q: { id?: string; source: string }) => ({ id: q.id ?? 'x', source: q.source }) })
|
|
250
|
+
await s.open('conveyor')
|
|
251
|
+
const now = () => JSON.parse(s.source()) as import('@hatiolab/figure-model').FigureAsset
|
|
252
|
+
const jointOf = () => now().joints!.find(j => j.body1.part === 'roller')!
|
|
253
|
+
const rollers = () => s.read().geometry.filter(g => g.id === 'roller' || g.id.includes('"roller.')).length
|
|
254
|
+
const turn = { kind: 'turn', axis: 'y', min: -180, max: 180, id: 'spin', follow: '', multiplier: 1, offset: 0, travel: '', travelPlus: 0 } as never
|
|
255
|
+
/* Moving first, then a row: offered, not refused. */
|
|
256
|
+
s.command({ kind: 'set-joint', joint: motionJoint(jointOf(), turn, 'roller') })
|
|
257
|
+
assert.equal(rowBlockOf(now(), 'roller'), undefined)
|
|
258
|
+
assert.deepEqual(rowAxesOf(now(), 'roller'), ['x', 'z'])
|
|
259
|
+
s.command({ kind: 'make-repeat', part: 'roller', axis: 'x', pitch: 200 })
|
|
260
|
+
assert.equal(rollers(), 10)
|
|
261
|
+
assert.equal(jointOf().type, 'revolute')
|
|
262
|
+
/* The motion changed by the form: the row stays. */
|
|
263
|
+
s.command({ kind: 'set-joint', joint: motionJoint(jointOf(), { ...(turn as object), kind: 'slide', axis: 'z', min: 0, max: 100 } as never, 'roller') })
|
|
264
|
+
assert.deepEqual([jointOf().type, rollers()], ['prismatic', 10])
|
|
265
|
+
/* The motion taken away by its button: still a row. */
|
|
266
|
+
s.command({ kind: 'set-joint', joint: fixedJointOf(jointOf()) })
|
|
267
|
+
assert.deepEqual([jointOf().type, rollers()], ['fixed', 10])
|
|
268
|
+
/* Each undone once. */
|
|
269
|
+
s.undo()
|
|
270
|
+
assert.deepEqual([jointOf().type, rollers()], ['prismatic', 10])
|
|
271
|
+
s.undo()
|
|
272
|
+
s.undo()
|
|
273
|
+
assert.deepEqual([jointOf().type, rollers()], ['revolute', 1])
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
/* Designer 2026-09-27: wider than its face is not hidden -- a fact in the relation's details, off to one side said. */
|
|
277
|
+
test('a part wider than its face is said in the relation, with its numbers and whether it sits to one side', () => {
|
|
278
|
+
const editor = read('../client/modeller/editor.ts')
|
|
279
|
+
assert.ok(editor.includes("if (own?.state === 'larger') {"))
|
|
280
|
+
assert.ok(editor.includes("if (own.lopsided.length) note.push(say('figure.text.overhang-lopsided'"))
|
|
281
|
+
const links = read('../client/modeller/figure-links.ts')
|
|
282
|
+
assert.ok(links.includes("${this.overhangNote.length ? html`<p hint overhang-note>${this.overhangNote.join(' ')}</p>` : nothing}"), 'in the details, not the warning')
|
|
283
|
+
assert.equal(say('figure.text.overhang-larger', { on: 'leg', face: '윗면', sides: 'X 낮은 쪽 270 mm, X 높은 쪽 870 mm' }), 'leg의 윗면보다 넓어 가장자리 밖으로 나와 있습니다: X 낮은 쪽 270 mm, X 높은 쪽 870 mm.')
|
|
284
|
+
for (const lang of ['en', 'ja', 'ko', 'ms', 'zh']) {
|
|
285
|
+
const t = JSON.parse(read(`../translations/${lang}.json`))
|
|
286
|
+
assert.ok(t['figure.text.overhang-larger'] && t['figure.text.overhang-lopsided'], lang)
|
|
287
|
+
}
|
|
288
|
+
})
|
package/test/fields.test.ts
CHANGED
|
@@ -83,7 +83,7 @@ test('편집기는 저작한 부품 단위로 고르고 보인다 — 되풀이
|
|
|
83
83
|
assert.ok(canvas.includes('.filter(([name]) => ownerOf(name) === this.selected)'), '사용자 2026-09-24: 「why repeatable parts are not selected?」')
|
|
84
84
|
const editor = read('../client/modeller/editor.ts')
|
|
85
85
|
assert.ok(editor.includes('this.selected = id ? ownerOf(id) : id'), '캔버스에서 복사본을 누르면 그 되풀이를 고른다')
|
|
86
|
-
assert.ok(editor.includes('.parts=${this.partRows(view.geometry)}'), '목록은 저작한 부품 한 줄씩')
|
|
86
|
+
assert.ok(editor.includes('.parts=${this.partRows(view.geometry, asset as FigureAsset | undefined)}'), '목록은 저작한 부품 한 줄씩')
|
|
87
87
|
assert.ok(editor.includes("this.session!.command({ kind: 'set-repeat', part: this.selected, pitch })"), 'pitch 는 명령으로 정한다')
|
|
88
88
|
const parts = read('../client/modeller/figure-parts.ts')
|
|
89
89
|
assert.ok(parts.includes('?disabled=${this.locked || full}'), '등급 한도에 이르면 팔레트가 잠긴다')
|
|
@@ -91,14 +91,15 @@ test('편집기는 저작한 부품 단위로 고르고 보인다 — 되풀이
|
|
|
91
91
|
|
|
92
92
|
test('pitch 는 한 곳에서 하나의 값이고, 늘 set-repeat 로 바꿔 최대 길이가 따라온다', () => {
|
|
93
93
|
const editor = readFileSync(fileURLToPath(new URL('../client/modeller/editor.ts', import.meta.url)), 'utf8')
|
|
94
|
-
assert.ok(editor.includes("groupFields(
|
|
94
|
+
assert.ok(editor.includes("groupFields(partFields, this.selected)") && editor.includes("const partFields = own.filter("), '사용자 2026-09-24: 되풀이의 간격과 형상 치수의 repeat · pitch 가 같은 값으로 두 번 떴다')
|
|
95
95
|
assert.ok(editor.includes("!i.id.startsWith(this.selected + '.repeat.')"))
|
|
96
96
|
const pitch = editor.slice(editor.indexOf(' private setPitch('), editor.indexOf(' /** Choose a part,'))
|
|
97
97
|
assert.ok(pitch.includes("this.session!.command({ kind: 'set-repeat', part: this.selected, pitch })"), '늘 명령으로 — 최대 길이가 따라온다')
|
|
98
98
|
assert.ok(!pitch.includes('changeField'))
|
|
99
99
|
for (const lang of ['ko', 'en', 'ja', 'zh', 'ms']) {
|
|
100
100
|
const table = JSON.parse(readFileSync(fileURLToPath(new URL(`../translations/${lang}.json`, import.meta.url)), 'utf8'))
|
|
101
|
-
|
|
101
|
+
/* The term stays pitch; what it measures is said beside it (ADR-0097 §5.5: centre to centre). */
|
|
102
|
+
assert.ok(table['figure.label.repeat-gap'].startsWith('pitch') && table['figure.label.repeat-gap'].length > 'pitch'.length, lang)
|
|
102
103
|
assert.ok(['{length}', '{count}', '{longest}', '{limit}'].every(k => table['figure.text.repeat-what-pitch-gives']?.includes(k)), lang)
|
|
103
104
|
}
|
|
104
105
|
})
|
|
@@ -35,7 +35,10 @@ test('the surface is taken back when the page goes inactive or leaves', () => {
|
|
|
35
35
|
|
|
36
36
|
test('the dock applies a proposal through the page, on the version the model saw', () => {
|
|
37
37
|
assert.match(source, /window\.addEventListener\('figure-apply', this\.applyAI\)/)
|
|
38
|
-
|
|
38
|
+
/* The page takes it by `takeFigureOffer` (ai-proposal.ts), tested there on the session itself. */
|
|
39
|
+
assert.match(source, /takeFigureOffer\(this\.session, detail, \{/)
|
|
40
|
+
const offer = readFileSync(fileURLToPath(new URL('../client/modeller/ai-proposal.ts', import.meta.url)), 'utf8')
|
|
41
|
+
assert.match(offer, /session\.applyProposal\(detail\.steps, detail\.revision!, detail\.context!, detail\.key\)/)
|
|
39
42
|
assert.match(source, /this\.takeNewFigureProposal\(\)/, 'a new figure opened for a proposal takes its steps')
|
|
40
43
|
})
|
|
41
44
|
|