@things-factory/figure-service 10.1.38 → 10.1.39
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/dist-server/service/figure/figure-format.d.ts +5 -1
- package/dist-server/service/figure/figure-format.js +37 -8
- package/dist-server/service/figure/figure-format.js.map +1 -1
- package/dist-server/service/figure/figure-mutation.js +9 -2
- package/dist-server/service/figure/figure-mutation.js.map +1 -1
- package/dist-server/service/figure/figure-query.d.ts +9 -0
- package/dist-server/service/figure/figure-query.js +43 -2
- package/dist-server/service/figure/figure-query.js.map +1 -1
- package/dist-server/service/figure/figure-rename.js +8 -0
- package/dist-server/service/figure/figure-rename.js.map +1 -1
- package/dist-server/service/figure/figure-tools.js +5 -5
- package/dist-server/service/figure/figure-tools.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/server/service/figure/figure-format.test.ts +26 -1
- package/server/service/figure/figure-format.ts +39 -7
- package/server/service/figure/figure-inspect-db.test.ts +130 -0
- package/server/service/figure/figure-mutation.ts +9 -2
- package/server/service/figure/figure-query.ts +41 -2
- package/server/service/figure/figure-rename.test.ts +27 -0
- package/server/service/figure/figure-rename.ts +11 -2
- package/server/service/figure/figure-tool-doors.test.ts +13 -13
- package/server/service/figure/figure-tools.ts +5 -5
- package/translations/en.json +4 -0
- package/translations/ko.json +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/figure-service",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.39",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public",
|
|
6
6
|
"@things-factory:registry": "https://registry.npmjs.org"
|
|
@@ -25,13 +25,13 @@
|
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@hatiolab/figure-model": "^0.1.38",
|
|
27
27
|
"@operato/typeorm-history": "^10.0.0",
|
|
28
|
-
"@things-factory/ai-client-base": "^10.1.
|
|
29
|
-
"@things-factory/auth-base": "^10.1.
|
|
28
|
+
"@things-factory/ai-client-base": "^10.1.39",
|
|
29
|
+
"@things-factory/auth-base": "^10.1.39",
|
|
30
30
|
"@things-factory/env": "^10.1.20",
|
|
31
|
-
"@things-factory/shell": "^10.1.
|
|
31
|
+
"@things-factory/shell": "^10.1.39",
|
|
32
32
|
"graphql-scalars": "^1.22.4",
|
|
33
33
|
"graphql-upload": "^15.0.2",
|
|
34
34
|
"koa-router": "^7.4.0"
|
|
35
35
|
},
|
|
36
|
-
"gitHead": "
|
|
36
|
+
"gitHead": "fe5257ad8995fe7e79275e287f36e95b5fd32d1c"
|
|
37
37
|
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* - V3 자산도 저장·발행·되돌리기·소비처를 지난다. 이 넷은 전에 V2 만 지날 수 있었다.
|
|
11
11
|
* - 예산 칸(`triangles`·`groups`)은 두 판에서 같은 뜻이다.
|
|
12
12
|
*/
|
|
13
|
-
import { createV3Asset, addV3Box, addV3Primitive, serializeV3Asset, applyV3Authoring, proposeV3Occupancy } from '@hatiolab/figure-model'
|
|
13
|
+
import { createV3Asset, addV3Box, addV3Primitive, serializeV3Asset, applyV3Authoring, proposeV3Occupancy, placeholderType } from '@hatiolab/figure-model'
|
|
14
14
|
import type { V3Asset } from '@hatiolab/figure-model'
|
|
15
15
|
|
|
16
16
|
import { consumablePayloadOf, formatOf, inspectAnySource, parseAnySource } from './figure-format'
|
|
@@ -219,6 +219,14 @@ describe('figure-format — 판을 알아보는 문', () => {
|
|
|
219
219
|
expect(finding.how).toContain('부품에 맞추기')
|
|
220
220
|
})
|
|
221
221
|
|
|
222
|
+
it('부품이 없는 새 도형에는 도형을 먼저 추가하라고 한다 — 화면은 그때 맞추기 단추를 내지 않는다', () => {
|
|
223
|
+
const empty = serializeV3Asset(createV3Asset('Empty'))
|
|
224
|
+
const finding = inspectAnySource(empty, null, translatorOf(KO)).findings.find(f => f.code === 'occupancy-undeclared')!
|
|
225
|
+
|
|
226
|
+
expect(finding.how).toBe('먼저 도형을 추가한 뒤, 「부품에 맞추기」로 공간을 선언하세요.')
|
|
227
|
+
expect(inspectAnySource(empty).findings.find(f => f.code === 'occupancy-undeclared')!.how).toMatch(/^Add a shape first/)
|
|
228
|
+
})
|
|
229
|
+
|
|
222
230
|
it('번역이 없으면 영어로 답한다 — 키를 찍지 않는다', () => {
|
|
223
231
|
const finding = inspectAnySource(outside()).findings.find(f => f.code === 'part-outside-occupancy')!
|
|
224
232
|
|
|
@@ -226,6 +234,22 @@ describe('figure-format — 판을 알아보는 문', () => {
|
|
|
226
234
|
expect(finding.message).not.toContain('figure.gate.')
|
|
227
235
|
})
|
|
228
236
|
|
|
237
|
+
/*
|
|
238
|
+
V3 자산에는 type 이 없어 V2 의 임시 이름 검사가 돌지 않았고, 새 V3 도형이 `FIGURE_<uuid>` 로 발행됐다
|
|
239
|
+
(:3300, 2026-09-24). 발행하면 이름이 굳으므로 행의 type 으로 같은 거절을 한다.
|
|
240
|
+
*/
|
|
241
|
+
it('임시 이름인 V3 도형은 발행을 막는다 — 이름은 행에 있다', () => {
|
|
242
|
+
/* The name a new V3 figure gets — the format's rule, the one the gate recognises. */
|
|
243
|
+
const placeholder = placeholderType()
|
|
244
|
+
const finding = inspectAnySource(v3Source(), null, translatorOf(KO), placeholder).findings.find(f => f.code === 'type-is-placeholder')
|
|
245
|
+
|
|
246
|
+
expect(finding?.blocking).toBe(true)
|
|
247
|
+
expect(finding!.message).toBe(`'${placeholder}' 은 처음 저장할 때 붙은 임시 타입입니다.`)
|
|
248
|
+
/* 화면의 칸 이름은 「타입」이다(`figure.label.figure-type`). */
|
|
249
|
+
expect(finding!.how).toContain('타입 칸')
|
|
250
|
+
expect(inspectAnySource(v3Source(), null, translatorOf(KO), 'ROLLER_CONVEYOR').blocked).toBe(false)
|
|
251
|
+
})
|
|
252
|
+
|
|
229
253
|
/* 코드가 묻는 키가 두 언어에 다 있어야 한다 — 없으면 그 언어의 화면에 영어가 섞인다. */
|
|
230
254
|
it('판정이 묻는 키는 ko · en 에 다 있다', () => {
|
|
231
255
|
const asked: string[] = []
|
|
@@ -233,6 +257,7 @@ describe('figure-format — 판을 알아보는 문', () => {
|
|
|
233
257
|
inspectAnySource(outside(), null, t)
|
|
234
258
|
inspectAnySource(bare(), null, t)
|
|
235
259
|
inspectAnySource(v3Source(), v3Source(), t)
|
|
260
|
+
inspectAnySource(v3Source(), null, t, 'FIGURE_ABCDEF12')
|
|
236
261
|
|
|
237
262
|
const keys = [...new Set(asked)]
|
|
238
263
|
expect(keys.length).toBeGreaterThan(5)
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* No conversion. This door reads what is stored; turning a V2 asset into a V3 one is `convertV2ToV3`, run
|
|
21
21
|
* deliberately against a recoverable copy (the designer's step 3), never as a side effect of a save.
|
|
22
22
|
*/
|
|
23
|
-
import { compile, compileV3Asset, inspectV3Asset, parseV3Asset, serializeV3Asset, v3CostOf } from '@hatiolab/figure-model'
|
|
23
|
+
import { compile, compileV3Asset, inspectV3Asset, isPlaceholderType, parseV3Asset, serializeV3Asset, v3CostOf } from '@hatiolab/figure-model'
|
|
24
24
|
import type { V3ContractError, V3Violation } from '@hatiolab/figure-model'
|
|
25
25
|
|
|
26
26
|
import type { GateFinding, Inspection } from './figure-gate.js'
|
|
@@ -31,7 +31,7 @@ import type { ParsedSource } from './figure-source.js'
|
|
|
31
31
|
export type FigureFormat = 'v2' | 'v3'
|
|
32
32
|
|
|
33
33
|
/** The version each format stamps on its own source. */
|
|
34
|
-
const V3_ASSET_VERSION = 'figure-v3-asset-1'
|
|
34
|
+
export const V3_ASSET_VERSION = 'figure-v3-asset-1'
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
37
|
* Which format this source is written in, read from the `version` the asset carries.
|
|
@@ -114,7 +114,7 @@ export type Translate = (key: string, options?: Record<string, unknown>) => stri
|
|
|
114
114
|
* (`figure.text.mounting-face-explained`). The same register as the V2 blockers since 2026-09-19 — a fact, then
|
|
115
115
|
* the way out — because at ADR-0087 step 5 these become the only findings an author sees.
|
|
116
116
|
*/
|
|
117
|
-
function v3Wording(violation: V3Violation, say: Say): Pick<GateFinding, 'message' | 'why' | 'how'> {
|
|
117
|
+
function v3Wording(violation: V3Violation, say: Say, hasParts: boolean): Pick<GateFinding, 'message' | 'why' | 'how'> {
|
|
118
118
|
const part = violation.part ?? ''
|
|
119
119
|
const axis = (violation.axis ?? '').toUpperCase()
|
|
120
120
|
const mm = violation.mm ?? 0
|
|
@@ -138,7 +138,14 @@ function v3Wording(violation: V3Violation, say: Say): Pick<GateFinding, 'message
|
|
|
138
138
|
: {
|
|
139
139
|
message: say(key('message'), {}, 'This figure has not declared the space it takes up.'),
|
|
140
140
|
why: say(key('why'), {}, 'Without a declared space the scale a figure is placed at cannot be set.'),
|
|
141
|
-
|
|
141
|
+
/*
|
|
142
|
+
A new figure is refused this way before it has a part, and 「fit to parts」 has nothing to measure then —
|
|
143
|
+
the screen does not offer it. Telling the author to press it sent them looking for a button that was not
|
|
144
|
+
there (:3300, 2026-09-24).
|
|
145
|
+
*/
|
|
146
|
+
how: hasParts
|
|
147
|
+
? say(key('how'), {}, 'Use "fit to parts" to declare the space from the parts as they are now.')
|
|
148
|
+
: say(key('how-no-parts'), {}, 'Add a shape first, then use "fit to parts" to declare the space from it.')
|
|
142
149
|
}
|
|
143
150
|
case 'PARTS_OFF_PLACEMENT_FACE':
|
|
144
151
|
return {
|
|
@@ -161,7 +168,13 @@ function sayerOf(t?: Translate): Say {
|
|
|
161
168
|
return (key, params, english) => (t ? t(key, { ...params, defaultValue: english }) : english)
|
|
162
169
|
}
|
|
163
170
|
|
|
164
|
-
export function inspectAnySource(
|
|
171
|
+
export function inspectAnySource(
|
|
172
|
+
sourceJson?: string | null,
|
|
173
|
+
releasedJson?: string | null,
|
|
174
|
+
t?: Translate,
|
|
175
|
+
/** The row's `type` — for V3 the only place the name lives, since the asset carries none. */
|
|
176
|
+
rowType?: string
|
|
177
|
+
): Inspection {
|
|
165
178
|
if (!sourceJson) return inspectSource(sourceJson, releasedJson)
|
|
166
179
|
|
|
167
180
|
let format: FigureFormat
|
|
@@ -174,8 +187,12 @@ export function inspectAnySource(sourceJson?: string | null, releasedJson?: stri
|
|
|
174
187
|
if (format === 'v2') return inspectSource(sourceJson, releasedJson)
|
|
175
188
|
|
|
176
189
|
let inspection
|
|
190
|
+
let hasParts = true
|
|
177
191
|
try {
|
|
178
|
-
|
|
192
|
+
const asset = parseV3Asset(sourceJson)
|
|
193
|
+
inspection = inspectV3Asset(asset)
|
|
194
|
+
const cost = v3CostOf(asset)
|
|
195
|
+
hasParts = cost.groups + cost.hidden > 0
|
|
179
196
|
} catch (e) {
|
|
180
197
|
const error = e as V3ContractError & { path?: string }
|
|
181
198
|
return {
|
|
@@ -187,10 +204,25 @@ export function inspectAnySource(sourceJson?: string | null, releasedJson?: stri
|
|
|
187
204
|
const say = sayerOf(t)
|
|
188
205
|
const findings: GateFinding[] = inspection.violations.map(violation => ({
|
|
189
206
|
code: violation.code.toLowerCase().replace(/_/g, '-'),
|
|
190
|
-
...v3Wording(violation, say),
|
|
207
|
+
...v3Wording(violation, say, hasParts),
|
|
191
208
|
at: violation.part,
|
|
192
209
|
blocking: true
|
|
193
210
|
}))
|
|
211
|
+
/*
|
|
212
|
+
V2 refuses a machine-made name at release (`type-is-placeholder`, from the source's own `type`). A V3 asset has
|
|
213
|
+
no `type`, so that check never ran and a new V3 figure was published as `FIGURE_<uuid>` — seen on :3300,
|
|
214
|
+
2026-09-24. Publishing freezes the name (ADR-0063 decision 4) and boards call a figure by it, so the same
|
|
215
|
+
refusal stands here, read off the row.
|
|
216
|
+
*/
|
|
217
|
+
if (rowType && isPlaceholderType(rowType)) {
|
|
218
|
+
findings.push({
|
|
219
|
+
code: 'type-is-placeholder',
|
|
220
|
+
message: say('figure.gate.type-is-placeholder.message', { type: rowType }, `'${rowType}' is a temporary type given when the figure was first saved.`),
|
|
221
|
+
why: say('figure.gate.type-is-placeholder.why', {}, 'Boards call a figure by its type, and it cannot change once the figure is published.'),
|
|
222
|
+
how: say('figure.gate.type-is-placeholder.how', {}, 'Set the type in the type field, then publish.'),
|
|
223
|
+
blocking: true
|
|
224
|
+
})
|
|
225
|
+
}
|
|
194
226
|
if (inspection.coverage.method === 'sampled') {
|
|
195
227
|
const states = inspection.coverage.states
|
|
196
228
|
findings.push({
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The inspect doors, read against a real row.
|
|
3
|
+
*
|
|
4
|
+
* `inspectAnySource` is tested on its own in figure-format.test.ts, and that is where the temporary-type refusal was
|
|
5
|
+
* first pinned. It still did not reach the release panel: `inspectFigure` selected only `id` and `source`, so the row's
|
|
6
|
+
* `type` arrived as `undefined` and the refusal never ran. The panel enabled 「발행하기」 and the release mutation —
|
|
7
|
+
* which loads the whole row — refused on the press (2026-09-24). A test of the function could not see that; this
|
|
8
|
+
* one goes through the resolvers and the database.
|
|
9
|
+
*/
|
|
10
|
+
import type { DataSource } from 'typeorm'
|
|
11
|
+
import {
|
|
12
|
+
addV3Box,
|
|
13
|
+
applyV3Authoring,
|
|
14
|
+
createV3Asset,
|
|
15
|
+
placeholderType,
|
|
16
|
+
proposeV3Occupancy,
|
|
17
|
+
serializeV3Asset
|
|
18
|
+
} from '@hatiolab/figure-model'
|
|
19
|
+
|
|
20
|
+
const { DataSource: TypeOrmDataSource } = require('typeorm')
|
|
21
|
+
const shell = require('@things-factory/shell')
|
|
22
|
+
const authBase = require('@things-factory/auth-base')
|
|
23
|
+
|
|
24
|
+
let dataSource: DataSource | null = null
|
|
25
|
+
let Figure: any
|
|
26
|
+
let FigureHistory: any
|
|
27
|
+
let FigureQuery: any
|
|
28
|
+
let domain: any
|
|
29
|
+
|
|
30
|
+
function loadCompiled(modulePath: string): any {
|
|
31
|
+
const { existsSync } = require('fs')
|
|
32
|
+
const { join } = require('path')
|
|
33
|
+
const resolved = join(__dirname, '..', '..', '..', 'dist-server', `${modulePath}.js`)
|
|
34
|
+
if (!existsSync(resolved)) {
|
|
35
|
+
throw new Error(`figure-service 산출물이 없다: ${resolved}\n yarn workspace @things-factory/figure-service build:server`)
|
|
36
|
+
}
|
|
37
|
+
return require(resolved)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A V3 figure that passes the gate on its own: a box standing on the floor, with its space declared. */
|
|
41
|
+
function sound(): string {
|
|
42
|
+
let asset = createV3Asset('Probe')
|
|
43
|
+
asset = addV3Box(asset, { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
|
|
44
|
+
asset = applyV3Authoring(asset, { kind: 'declare-occupancy', proposal: proposeV3Occupancy(asset, { over: 'range' }), placement: 'floor' })
|
|
45
|
+
return serializeV3Asset(asset)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
beforeAll(async () => {
|
|
49
|
+
Figure = loadCompiled('service/figure/figure').Figure
|
|
50
|
+
FigureHistory = loadCompiled('service/figure/figure-history').FigureHistory
|
|
51
|
+
FigureQuery = loadCompiled('service/figure/figure-query').FigureQuery
|
|
52
|
+
|
|
53
|
+
dataSource = new TypeOrmDataSource({
|
|
54
|
+
type: 'sqlite',
|
|
55
|
+
database: ':memory:',
|
|
56
|
+
synchronize: true,
|
|
57
|
+
dropSchema: true,
|
|
58
|
+
namingStrategy: new shell.NamingStrategy(),
|
|
59
|
+
entities: [...shell.entities, ...authBase.entities, Figure, FigureHistory],
|
|
60
|
+
logging: false
|
|
61
|
+
})
|
|
62
|
+
await dataSource.initialize()
|
|
63
|
+
shell.addDataSource('default', dataSource)
|
|
64
|
+
shell.addDataSource('tx', dataSource)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
beforeEach(async () => {
|
|
68
|
+
await dataSource!.synchronize(true)
|
|
69
|
+
domain = await dataSource!.getRepository(shell.Domain).save({ name: 'inspect', subdomain: 'inspect' })
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
afterAll(async () => {
|
|
73
|
+
if (!dataSource) return
|
|
74
|
+
shell.removeDataSource('default')
|
|
75
|
+
shell.removeDataSource('tx')
|
|
76
|
+
await dataSource.destroy()
|
|
77
|
+
dataSource = null
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const context = () => ({ state: { domain, user: { id: 'u' } } }) as any
|
|
81
|
+
const save = (type: string, source = sound()) =>
|
|
82
|
+
dataSource!.getRepository(Figure).save({ domain, type, name: 'probe', state: 'draft', source, version: 0 })
|
|
83
|
+
|
|
84
|
+
describe('inspectFigure — the release panel', () => {
|
|
85
|
+
it('refuses a V3 figure still under its temporary type — the type comes from the row', async () => {
|
|
86
|
+
const figure = await save(placeholderType())
|
|
87
|
+
|
|
88
|
+
const inspection = await new FigureQuery().inspectFigure(figure.id, context())
|
|
89
|
+
|
|
90
|
+
expect(inspection.blocked).toBe(true)
|
|
91
|
+
expect(inspection.findings.map((f: any) => f.code)).toContain('type-is-placeholder')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('lets the same figure through once it is named', async () => {
|
|
95
|
+
const figure = await save('ROLLER_CONVEYOR')
|
|
96
|
+
|
|
97
|
+
expect((await new FigureQuery().inspectFigure(figure.id, context())).blocked).toBe(false)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('inspectFigureDraft — the verdict while editing', () => {
|
|
102
|
+
it('judges the unsaved source, not the stored one, and still reads the row type', async () => {
|
|
103
|
+
const figure = await save(placeholderType())
|
|
104
|
+
let edited = createV3Asset('Probe')
|
|
105
|
+
edited = addV3Box(edited, { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
|
|
106
|
+
|
|
107
|
+
const inspection = await new FigureQuery().inspectFigureDraft(serializeV3Asset(edited), context(), figure.id)
|
|
108
|
+
const codes = inspection.findings.map((f: any) => f.code)
|
|
109
|
+
|
|
110
|
+
expect(codes).toContain('occupancy-undeclared')
|
|
111
|
+
expect(codes).toContain('type-is-placeholder')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('a figure never saved has no row type to refuse', async () => {
|
|
115
|
+
const inspection = await new FigureQuery().inspectFigureDraft(sound(), context())
|
|
116
|
+
|
|
117
|
+
expect(inspection.blocked).toBe(false)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('declares the same permission as inspectFigure', () => {
|
|
121
|
+
const { readFileSync } = require('fs')
|
|
122
|
+
const { join } = require('path')
|
|
123
|
+
const source = readFileSync(join(__dirname, 'figure-query.ts'), 'utf-8')
|
|
124
|
+
const at = source.indexOf('async inspectFigureDraft')
|
|
125
|
+
const preceding = source.slice(0, at)
|
|
126
|
+
const declaration = preceding.slice(preceding.lastIndexOf("@Directive('@privilege("))
|
|
127
|
+
|
|
128
|
+
expect(declaration).toContain('category: "figure", privilege: "query"')
|
|
129
|
+
})
|
|
130
|
+
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { In } from 'typeorm'
|
|
2
2
|
import { Arg, Ctx, Directive, Mutation, Resolver } from 'type-graphql'
|
|
3
3
|
|
|
4
|
+
import { placeholderType } from '@hatiolab/figure-model'
|
|
4
5
|
import { getRepository } from '@things-factory/shell'
|
|
5
6
|
|
|
6
7
|
import { Figure } from './figure.js'
|
|
@@ -37,8 +38,14 @@ export class FigureMutation {
|
|
|
37
38
|
if (previous.source !== prepared.source) throw Error('이 생성 요청은 이미 다른 내용으로 저장되었습니다. 저장된 모델을 다시 열어 주세요.')
|
|
38
39
|
return previous
|
|
39
40
|
}
|
|
41
|
+
/*
|
|
42
|
+
The temporary name comes from the format's `placeholderType()`, the one rule the release gate and the AI
|
|
43
|
+
tools use to recognise it. This used to build its own (`FIGURE_<32 hex>`), which `isPlaceholderType` does
|
|
44
|
+
not match, so a new V3 figure could be published under a machine name that then froze (ADR-0063 decision 4)
|
|
45
|
+
— seen on :3300, 2026-09-24. A retry is still safe: the row id is the creation id, not the name.
|
|
46
|
+
*/
|
|
40
47
|
// insert, not save: a concurrent retry must never turn into an unchecked overwrite.
|
|
41
|
-
await repository.insert({ ...prepared, id: creationId, type:
|
|
48
|
+
await repository.insert({ ...prepared, id: creationId, type: placeholderType(),
|
|
42
49
|
domain, creator: user, updater: user })
|
|
43
50
|
return repository.findOneOrFail({ where: { id: creationId, domain: { id: domain.id } } })
|
|
44
51
|
}
|
|
@@ -263,7 +270,7 @@ export class FigureMutation {
|
|
|
263
270
|
.limit(1)
|
|
264
271
|
.getOne()
|
|
265
272
|
|
|
266
|
-
const inspection = inspectAnySource(figure.source, released?.source, context.t)
|
|
273
|
+
const inspection = inspectAnySource(figure.source, released?.source, context.t, figure.type)
|
|
267
274
|
if (inspection.blocked) {
|
|
268
275
|
const reasons = inspection.findings
|
|
269
276
|
.filter(finding => finding.blocking)
|
|
@@ -114,7 +114,12 @@ export class FigureQuery {
|
|
|
114
114
|
|
|
115
115
|
const figure = await getRepository(Figure).findOne({
|
|
116
116
|
where: { domain: { id: In([domain.id, domain.parentId].filter(Boolean)) }, id },
|
|
117
|
-
|
|
117
|
+
/*
|
|
118
|
+
`type` is read too. A V3 asset carries no type, so the temporary-type refusal reads the row's; with only
|
|
119
|
+
id and source selected it saw `undefined`, the panel enabled 「발행하기」, and the release mutation — which
|
|
120
|
+
loads the whole row — refused on the press (2026-09-24).
|
|
121
|
+
*/
|
|
122
|
+
select: { id: true, source: true, type: true }
|
|
118
123
|
})
|
|
119
124
|
|
|
120
125
|
if (!figure) {
|
|
@@ -132,7 +137,41 @@ export class FigureQuery {
|
|
|
132
137
|
.limit(1)
|
|
133
138
|
.getOne()
|
|
134
139
|
|
|
135
|
-
return inspectAnySource(figure.source, released?.source, context.t)
|
|
140
|
+
return inspectAnySource(figure.source, released?.source, context.t, figure.type)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The same judgement for a source that has not been saved — what the modeller shows **while** the author edits.
|
|
145
|
+
*
|
|
146
|
+
* Without it a V3 author saw nothing until they saved and opened the release panel. The judgement runs here, not
|
|
147
|
+
* in the browser, because the sentences are made from the request's language (`inspectAnySource` with `context.t`)
|
|
148
|
+
* and a second copy on the screen would drift from the one the release refuses with. When `id` names a saved
|
|
149
|
+
* figure, its row type and last release are read as `inspectFigure` does; a figure never saved has neither.
|
|
150
|
+
*/
|
|
151
|
+
@Directive('@privilege(category: "figure", privilege: "query", domainOwnerGranted: true)')
|
|
152
|
+
@Query(returns => FigureInspection, { description: 'Whether an unsaved figure source would be released, with the reasons.' })
|
|
153
|
+
async inspectFigureDraft(
|
|
154
|
+
@Arg('source') source: string,
|
|
155
|
+
@Ctx() context: ResolverContext,
|
|
156
|
+
@Arg('id', { nullable: true }) id?: string
|
|
157
|
+
): Promise<FigureInspection> {
|
|
158
|
+
const { domain } = context.state
|
|
159
|
+
if (!id) return inspectAnySource(source, null, context.t)
|
|
160
|
+
|
|
161
|
+
const figure = await getRepository(Figure).findOne({
|
|
162
|
+
where: { domain: { id: In([domain.id, domain.parentId].filter(Boolean)) }, id },
|
|
163
|
+
select: { id: true, type: true }
|
|
164
|
+
})
|
|
165
|
+
if (!figure) throw new Error(`Figure ${id} 를 찾을 수 없다`)
|
|
166
|
+
|
|
167
|
+
const released = await getRepository(FigureHistory)
|
|
168
|
+
.createQueryBuilder('history')
|
|
169
|
+
.where('history.originalId = :id', { id })
|
|
170
|
+
.orderBy('history.version', 'DESC')
|
|
171
|
+
.limit(1)
|
|
172
|
+
.getOne()
|
|
173
|
+
|
|
174
|
+
return inspectAnySource(source, released?.source, context.t, figure.type)
|
|
136
175
|
}
|
|
137
176
|
|
|
138
177
|
@Directive('@privilege(category: "figure", privilege: "query", domainOwnerGranted: true)')
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
import { readFileSync } from 'fs'
|
|
8
8
|
import { join } from 'path'
|
|
9
9
|
|
|
10
|
+
import { createV3Asset, parseV3Asset, serializeV3Asset } from '@hatiolab/figure-model'
|
|
11
|
+
|
|
10
12
|
import { FigureRenameRefused, renameDraftType } from './figure-rename'
|
|
11
13
|
|
|
12
14
|
const draft = (over: Record<string, unknown> = {}) => ({
|
|
@@ -25,6 +27,19 @@ describe('초안의 type 고치기', () => {
|
|
|
25
27
|
expect(JSON.parse(result.source).type).toBe('ROBOT_ARM')
|
|
26
28
|
})
|
|
27
29
|
|
|
30
|
+
/*
|
|
31
|
+
V3 자산에는 type 칸이 없고 V3 형식은 모르는 칸을 거절한다. V2 처럼 원본에 이름을 써 넣었더니
|
|
32
|
+
그 도형을 다시 열 수 없었다(`parseV3Asset` — "unknown type"). V3 는 행만 바뀐다.
|
|
33
|
+
*/
|
|
34
|
+
it('★ V3 도형은 행만 바뀌고, 원본은 그대로 다시 열린다', () => {
|
|
35
|
+
const source = serializeV3Asset(createV3Asset('새 Figure'))
|
|
36
|
+
const result = renameDraftType({ type: 'FIGURE_ABCDEF12', source }, 'ROLLER_CONVEYOR', never)
|
|
37
|
+
|
|
38
|
+
expect(result.type).toBe('ROLLER_CONVEYOR')
|
|
39
|
+
expect(result.source).toBe(source)
|
|
40
|
+
expect(() => parseV3Asset(result.source)).not.toThrow()
|
|
41
|
+
})
|
|
42
|
+
|
|
28
43
|
it('정본의 다른 값은 건드리지 않는다', () => {
|
|
29
44
|
const before = JSON.parse(draft().source)
|
|
30
45
|
const after = JSON.parse(renameDraftType(draft(), 'ROBOT_ARM', never).source)
|
|
@@ -91,6 +106,18 @@ describe('문이 이 규칙을 지난다', () => {
|
|
|
91
106
|
expect(/renamed !== undefined \? \{ type: renamed \}/.test(mutation)).toBe(true)
|
|
92
107
|
})
|
|
93
108
|
|
|
109
|
+
/*
|
|
110
|
+
V3 저장이 임시 이름을 스스로 지었다(`FIGURE_<32 hex>`). 형식의 `isPlaceholderType` 이 그것을 못 알아봐서
|
|
111
|
+
발행 판정이 기계 이름을 통과시켰다. 짓는 쪽과 알아보는 쪽이 한 규칙이어야 한다.
|
|
112
|
+
*/
|
|
113
|
+
it('새 V3 도형의 임시 이름은 형식의 규칙으로 짓는다 — 판정이 알아보는 그 규칙', () => {
|
|
114
|
+
const mutation = readFileSync(join(__dirname, 'figure-mutation.ts'), 'utf8')
|
|
115
|
+
const save = mutation.slice(mutation.indexOf('async saveFigureV3'), mutation.indexOf('async createFigure'))
|
|
116
|
+
|
|
117
|
+
expect(save).toContain('type: placeholderType()')
|
|
118
|
+
expect(save).not.toMatch(/type: `FIGURE_/)
|
|
119
|
+
})
|
|
120
|
+
|
|
94
121
|
it('문이 type 을 받는 칸을 갖는다', () => {
|
|
95
122
|
const type = source('figure-type.ts')
|
|
96
123
|
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
* 그래야 규칙을 DB 없이 시험한다. 문(`figure-mutation.ts`)이 그 셋을 읽어 넘긴다.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
+
import { V3_ASSET_VERSION } from './figure-format.js'
|
|
23
|
+
|
|
22
24
|
/** 이름을 바꿀 때 알아야 하는 사실. 부르는 쪽이 저장소에서 읽어 온다. */
|
|
23
25
|
export interface RenameFacts {
|
|
24
26
|
/** 발행 판 번호. 0 이면 한 번도 발행되지 않았다. */
|
|
@@ -67,9 +69,16 @@ export function renameDraftType(
|
|
|
67
69
|
throw new FigureRenameRefused('type-taken', `type 「${next}」 는 이 도메인에서 이미 쓰고 있다`)
|
|
68
70
|
}
|
|
69
71
|
|
|
70
|
-
const parsed = JSON.parse(current.source) as { type?: string }
|
|
71
|
-
parsed.type = next
|
|
72
|
+
const parsed = JSON.parse(current.source) as { type?: string; version?: unknown }
|
|
72
73
|
|
|
74
|
+
/*
|
|
75
|
+
A V3 asset carries no `type`: the name a board uses is the row's alone, and the V3 schema refuses a field it
|
|
76
|
+
does not know. Writing it in, as V2 needs, left a V3 figure that could no longer be opened — `parseV3Asset`
|
|
77
|
+
answered "unknown type" (checked 2026-09-24). So only the row changes for V3.
|
|
78
|
+
*/
|
|
79
|
+
if (parsed.version === V3_ASSET_VERSION) return { type: next, source: current.source }
|
|
80
|
+
|
|
81
|
+
parsed.type = next
|
|
73
82
|
return { type: next, source: JSON.stringify(parsed) }
|
|
74
83
|
}
|
|
75
84
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* figure
|
|
2
|
+
* The doors figure's tools pass — ADR-0072 decision 2, ADR-0071 decision 2 addendum (2026-09-18).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* A declaration nobody reads can be deleted or changed by the next person with no effect. Now it has a
|
|
5
|
+
* reader — the gate decides from this list whether to offer the tool. So this asks two things: did the
|
|
6
|
+
* tool name its doors, and **does each door guard anything.**
|
|
7
7
|
*/
|
|
8
8
|
import { readdirSync, readFileSync } from 'fs'
|
|
9
9
|
import { join } from 'path'
|
|
@@ -33,15 +33,15 @@ describe('figure tools — declared doors', () => {
|
|
|
33
33
|
})
|
|
34
34
|
|
|
35
35
|
/*
|
|
36
|
-
*
|
|
36
|
+
* Does each door the tools point at guard anything — the fact the gate stands on.
|
|
37
37
|
*
|
|
38
|
-
* 2026-09-18
|
|
39
|
-
* (ADR-0071
|
|
40
|
-
*
|
|
38
|
+
* Until 2026-09-18 the tool specs copied a privilege and this spot compared the copy with the door.
|
|
39
|
+
* The copy is gone (ADR-0071 decision 2 addendum) — the gate reads the door's declaration from AUTH's
|
|
40
|
+
* field table directly, so there are no longer two values to compare.
|
|
41
41
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
42
|
+
* What remains is **the fact the gate stands on**: the doors these four tools point at carry a
|
|
43
|
+
* directive. Strip `@privilege` from `Query.figures` and that door opens within the tenant, and the
|
|
44
|
+
* tool opens with it — and nothing else would show it. This catches it.
|
|
45
45
|
*/
|
|
46
46
|
describe('figure tools — the doors they reach are guarded', () => {
|
|
47
47
|
beforeEach(() => {
|
|
@@ -104,13 +104,13 @@ describe('figure tools — the doors they reach are guarded', () => {
|
|
|
104
104
|
return found
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
it('★
|
|
107
|
+
it('★ every door a tool points at carries the directive that guards it', () => {
|
|
108
108
|
const doors = doorPrivileges()
|
|
109
109
|
|
|
110
110
|
const unguarded = getAllToolSpecs()
|
|
111
111
|
.flatMap(spec =>
|
|
112
112
|
((spec as any).doors ?? []).map((door: string) =>
|
|
113
|
-
doors.has(door) ? undefined : `${spec.name} → ${door}:
|
|
113
|
+
doors.has(door) ? undefined : `${spec.name} → ${door}: no such door, or the door declares no privilege`
|
|
114
114
|
)
|
|
115
115
|
)
|
|
116
116
|
.filter(Boolean)
|
|
@@ -18,11 +18,11 @@
|
|
|
18
18
|
* `ai-assistant:mutation` 이 지킨다 — 서로 다른 질문이라 서로 다른 권한이 지킨다. figure 가 board
|
|
19
19
|
* 의 권한을 빌리지 않는다.
|
|
20
20
|
*
|
|
21
|
-
* ──
|
|
22
|
-
* 2026-09-18
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
21
|
+
* ── No privilege here. The gate reads what the door declares ─────────────────────
|
|
22
|
+
* Until 2026-09-18 each tool carried `privilege: { category: 'figure', ... }`. It matched the door's
|
|
23
|
+
* directive then, and would drift the day the door changed its declaration — and the looser copy wins.
|
|
24
|
+
* Now the gate reads the declaration of each door in `doors` from AUTH's table (ADR-0071 decision 2,
|
|
25
|
+
* addendum). What stays here is **the doors it reaches**.
|
|
26
26
|
*
|
|
27
27
|
* ── 규율을 도구와 함께 보낸다 ───────────────────────────────────────────────
|
|
28
28
|
* 도구 목록만으로는 「언제 반드시 불러야 하는지」를 모른다. twin 이 그 공백에서 사고를 냈다 —
|
package/translations/en.json
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
"error.figure-release-does-not-compile": "the released source of {type} (version {version}) does not compile. open it in the modeller, fix it and release it again before it draws on a board.",
|
|
3
3
|
"figure.gate.occupancy-empty-axis.message": "The declared space is empty on {axis}.",
|
|
4
4
|
"figure.gate.occupancy-undeclared.how": "Use \"fit to parts\" to declare the space from the parts as they are now.",
|
|
5
|
+
"figure.gate.occupancy-undeclared.how-no-parts": "Add a shape first, then use \"fit to parts\" to declare the space from it.",
|
|
5
6
|
"figure.gate.occupancy-undeclared.message": "This figure has not declared the space it takes up.",
|
|
6
7
|
"figure.gate.occupancy-undeclared.why": "Without a declared space the scale a figure is placed at cannot be set.",
|
|
7
8
|
"figure.gate.part-outside-occupancy.how": "Use \"fit to parts\" to declare the space from the parts again, or move '{part}' {mm} mm inward on {axis}.",
|
|
@@ -16,6 +17,9 @@
|
|
|
16
17
|
"figure.gate.state-range-sampled.how": "Re-author the motion that could not be proven, and the whole range can be proven.",
|
|
17
18
|
"figure.gate.state-range-sampled.message": "The whole range of motion could not be proven, so {states} poses were checked.",
|
|
18
19
|
"figure.gate.state-range-sampled.why": "Between the poses that were checked, a part may still go past the declared space.",
|
|
20
|
+
"figure.gate.type-is-placeholder.how": "Set the type in the type field, then publish.",
|
|
21
|
+
"figure.gate.type-is-placeholder.message": "'{type}' is a temporary type given when the figure was first saved.",
|
|
22
|
+
"figure.gate.type-is-placeholder.why": "Boards call a figure by its type, and it cannot change once the figure is published.",
|
|
19
23
|
"figure.gate.v3-released-not-compared.message": "Not compared with the last release. V3 figures have no comparison between releases yet.",
|
|
20
24
|
"figure.gate.v3-released-not-compared.why": "Whether this release breaks the instances already placed has not been checked."
|
|
21
25
|
}
|
package/translations/ko.json
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
"error.figure-release-does-not-compile": "{type} 의 발행판({version})이 지금 컴파일되지 않습니다. 저작 화면에서 열어 고친 뒤 다시 발행해야 보드에 나옵니다.",
|
|
3
3
|
"figure.gate.occupancy-empty-axis.message": "공간 선언의 {axis} 가 비어 있습니다.",
|
|
4
4
|
"figure.gate.occupancy-undeclared.how": "「부품에 맞추기」를 누르면 지금 부품으로 공간을 선언합니다.",
|
|
5
|
+
"figure.gate.occupancy-undeclared.how-no-parts": "먼저 도형을 추가한 뒤, 「부품에 맞추기」로 공간을 선언하세요.",
|
|
5
6
|
"figure.gate.occupancy-undeclared.message": "이 도형은 차지하는 공간을 아직 선언하지 않았습니다.",
|
|
6
7
|
"figure.gate.occupancy-undeclared.why": "공간 선언이 없으면 도면에 놓일 때의 배율을 정할 수 없습니다.",
|
|
7
8
|
"figure.gate.part-outside-occupancy.how": "「부품에 맞추기」로 공간을 부품에서 다시 선언하거나, '{part}' 을 {axis} 로 {mm}mm 안으로 옮기세요.",
|
|
@@ -16,6 +17,9 @@
|
|
|
16
17
|
"figure.gate.state-range-sampled.how": "증명하지 못한 움직임을 다시 저작하면 범위 전체를 증명할 수 있습니다.",
|
|
17
18
|
"figure.gate.state-range-sampled.message": "움직이는 범위 전체를 증명하지 못해 자세 {states}개만 검사했습니다.",
|
|
18
19
|
"figure.gate.state-range-sampled.why": "검사한 자세 사이에서 부품이 공간 선언을 넘을 수 있습니다.",
|
|
20
|
+
"figure.gate.type-is-placeholder.how": "타입 칸에서 타입을 정한 뒤 발행하세요.",
|
|
21
|
+
"figure.gate.type-is-placeholder.message": "'{type}' 은 처음 저장할 때 붙은 임시 타입입니다.",
|
|
22
|
+
"figure.gate.type-is-placeholder.why": "보드는 이 타입으로 도형을 부르고, 발행한 뒤에는 바꿀 수 없습니다.",
|
|
19
23
|
"figure.gate.v3-released-not-compared.message": "직전 발행판과 견주지 못했습니다. V3 도형은 발행 사이의 비교가 아직 없습니다.",
|
|
20
24
|
"figure.gate.v3-released-not-compared.why": "이 발행이 이미 놓인 인스턴스를 깨는지는 확인되지 않았습니다."
|
|
21
25
|
}
|