@things-factory/figure-service 10.1.167 → 10.1.169
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-context.d.ts +108 -0
- package/dist-server/service/figure/figure-context.js +368 -0
- package/dist-server/service/figure/figure-context.js.map +1 -0
- package/dist-server/service/figure/figure-format.js +10 -16
- package/dist-server/service/figure/figure-format.js.map +1 -1
- package/dist-server/service/figure/figure-mutation.js +2 -2
- package/dist-server/service/figure/figure-mutation.js.map +1 -1
- package/dist-server/service/figure/figure-proposal.js +4 -2
- package/dist-server/service/figure/figure-proposal.js.map +1 -1
- package/dist-server/service/figure/figure-propose-steps.d.ts +34 -6
- package/dist-server/service/figure/figure-propose-steps.js +128 -17
- package/dist-server/service/figure/figure-propose-steps.js.map +1 -1
- package/dist-server/service/figure/figure-tools.js +197 -49
- package/dist-server/service/figure/figure-tools.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/server/service/figure/figure-context.test.ts +288 -0
- package/server/service/figure/figure-context.ts +447 -0
- package/server/service/figure/figure-format.test.ts +18 -1
- package/server/service/figure/figure-format.ts +10 -16
- package/server/service/figure/figure-llm-smoke.test.ts +82 -13
- package/server/service/figure/figure-mutation.ts +3 -3
- package/server/service/figure/figure-proposal.ts +4 -2
- package/server/service/figure/figure-propose-steps.test.ts +146 -6
- package/server/service/figure/figure-propose-steps.ts +145 -26
- package/server/service/figure/figure-tools-door-db.test.ts +227 -0
- package/server/service/figure/figure-tools.test.ts +382 -22
- package/server/service/figure/figure-tools.ts +218 -50
- package/translations/en.json +11 -9
- package/translations/es.json +11 -9
- package/translations/ja.json +11 -9
- package/translations/ko.json +11 -9
- package/translations/ms.json +11 -9
- package/translations/zh.json +11 -9
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*
|
|
13
13
|
* 실제 모델은 부르지 않는다. 여기서 볼 것은 모델의 솜씨가 아니라 **우리 배선**이다.
|
|
14
14
|
*/
|
|
15
|
-
import { clearToolRegistry, getToolCategories, registryAssistantChat, setAIClientForTesting } from '@things-factory/ai-client-base'
|
|
15
|
+
import { clearToolRegistry, getToolCategories, registryAssistantChat, setAIClientForTesting, toolCallContextFixture } from '@things-factory/ai-client-base'
|
|
16
16
|
|
|
17
17
|
import { __setFigureToolDepsForTest, registerFigureToolCategories } from './figure-tools'
|
|
18
18
|
|
|
@@ -24,8 +24,17 @@ function stubRepository(rows: any[]) {
|
|
|
24
24
|
rows.find(row => row.id === where.id && row.domain?.id === where.domain?.id) ?? null
|
|
25
25
|
)
|
|
26
26
|
},
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
/* A where list is OR'ed, as typeorm does; an ILike operator is a case-blind pattern with `%` for anything. */
|
|
28
|
+
async find({ where, take }: any) {
|
|
29
|
+
const matches = (value: any, wanted: any) => {
|
|
30
|
+
if (wanted === undefined) return true
|
|
31
|
+
if (wanted?._type === 'ilike') return new RegExp(`^${String(wanted._value).split('%').map(escape).join('.*')}$`, 'i').test(String(value ?? ''))
|
|
32
|
+
return value === wanted
|
|
33
|
+
}
|
|
34
|
+
const escape = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
35
|
+
const one = (row: any, w: any) => row.domain?.id === w.domain?.id && matches(row.name, w.name) && matches(row.type, w.type)
|
|
36
|
+
const found = rows.filter(row => (Array.isArray(where) ? where.some(w => one(row, w)) : one(row, where)))
|
|
37
|
+
return take === undefined ? found : found.slice(0, take)
|
|
29
38
|
}
|
|
30
39
|
}
|
|
31
40
|
}
|
|
@@ -47,6 +56,13 @@ const ROWS = [
|
|
|
47
56
|
{ id: 'fig-other', type: 'SECRET', name: '남의 것', state: 'draft', domain: THEIRS, source: JSON.stringify(SOURCE) }
|
|
48
57
|
]
|
|
49
58
|
|
|
59
|
+
/** A request translator for one locale, read from this package's translations as i18next reads them. */
|
|
60
|
+
function translatorOf(locale: string) {
|
|
61
|
+
const words = require(`../../../translations/${locale}.json`)
|
|
62
|
+
return (key: string, params: Record<string, any> = {}) =>
|
|
63
|
+
String(words[key] ?? params.defaultValue ?? key).replace(/\{(\w+)\}/g, (whole, name) => (name in params ? String(params[name]) : whole))
|
|
64
|
+
}
|
|
65
|
+
|
|
50
66
|
/** 부른 것을 적어 두는 가짜 제안기. */
|
|
51
67
|
const proposed: any[] = []
|
|
52
68
|
|
|
@@ -86,9 +102,9 @@ function toolNamed(name: string) {
|
|
|
86
102
|
return spec as any
|
|
87
103
|
}
|
|
88
104
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
})
|
|
105
|
+
/* The shape the door hands a tool (A3) - translator and checked attachments included - built by the door's own fixture. */
|
|
106
|
+
const ctx = (host: any = { figureId: 'fig-1', palette: ['palette.primary'] }, extra: { t?: any; attachments?: any[] } = {}) =>
|
|
107
|
+
toolCallContextFixture({ domainId: MINE.id, host, ...extra })
|
|
92
108
|
|
|
93
109
|
beforeEach(() => {
|
|
94
110
|
clearToolRegistry()
|
|
@@ -222,22 +238,50 @@ describe('규율이 도구와 함께 나간다', () => {
|
|
|
222
238
|
|
|
223
239
|
expect(category.guidance).toBeTruthy()
|
|
224
240
|
expect(category.guidance).toContain('proposeFigureCandidate')
|
|
225
|
-
expect(category.guidance).toMatch(
|
|
226
|
-
expect(category.guidance).toMatch(
|
|
241
|
+
expect(category.guidance).toMatch(/\*\*false\*\*/)
|
|
242
|
+
expect(category.guidance).toMatch(/exists only as the result of that call/)
|
|
227
243
|
})
|
|
228
244
|
|
|
229
245
|
it('고치기 전에 정본을 읽으라고 말한다 — 이름이 갈리면 배치된 보드가 끊긴다', () => {
|
|
230
246
|
const category = getToolCategories().find((one: any) => one.name === 'figure-authoring')!
|
|
231
247
|
|
|
232
248
|
expect(category.guidance).toContain('getFigureSource')
|
|
233
|
-
expect(category.guidance).toMatch(
|
|
249
|
+
expect(category.guidance).toMatch(/breaks the bindings/)
|
|
234
250
|
})
|
|
235
251
|
|
|
236
252
|
it('타입 이름을 지어내지 말라고 말한다 — 저장되는 식별자다', () => {
|
|
237
253
|
const category = getToolCategories().find((one: any) => one.name === 'figure-authoring')!
|
|
238
254
|
|
|
239
255
|
expect(category.guidance).toContain('listFigures')
|
|
240
|
-
expect(category.guidance).toMatch(
|
|
256
|
+
expect(category.guidance).toMatch(/stored identifier/)
|
|
257
|
+
})
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
/*
|
|
261
|
+
What the model reads is written in English (ADR-0061 Figure AI follow-up 5): the category's description and rules,
|
|
262
|
+
each tool's description and argument descriptions, and the control messages a tool returns. The answer's language is
|
|
263
|
+
the request's; a person's own words, figure names and attachments are not translated.
|
|
264
|
+
*/
|
|
265
|
+
describe('모델이 읽는 글은 영어다', () => {
|
|
266
|
+
const hangul = /[\u3131-\u318e\uac00-\ud7a3]/
|
|
267
|
+
|
|
268
|
+
it('범주의 설명과 규율, 도구와 인자의 설명', () => {
|
|
269
|
+
const category = getToolCategories().find((one: any) => one.name === 'figure-authoring')!
|
|
270
|
+
const texts = [
|
|
271
|
+
category.description,
|
|
272
|
+
category.guidance,
|
|
273
|
+
...category.specs.flatMap((spec: any) => [spec.description, ...Object.values(spec.schema?.properties ?? {}).map((one: any) => one.description)])
|
|
274
|
+
]
|
|
275
|
+
for (const text of texts) expect(text).not.toMatch(hangul)
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('도구가 돌려주는 제어 메시지', async () => {
|
|
279
|
+
const missing = await toolNamed('getFigureSource').builder({}, ctx({}))
|
|
280
|
+
const notOpen = await toolNamed('proposeFigureCandidate').builder({ prompt: 'fix it', figureId: 'fig-blank' }, ctx(modeller()))
|
|
281
|
+
for (const result of [missing, notOpen]) {
|
|
282
|
+
expect(result.error).toBeTruthy()
|
|
283
|
+
expect(JSON.stringify(result)).not.toMatch(hangul)
|
|
284
|
+
}
|
|
241
285
|
})
|
|
242
286
|
})
|
|
243
287
|
|
|
@@ -303,11 +347,53 @@ describe('모델이 넘긴 id 를 믿지 않는다', () => {
|
|
|
303
347
|
expect(result.figures.map((one: any) => one.id)).toEqual(['fig-1', 'fig-blank'])
|
|
304
348
|
})
|
|
305
349
|
|
|
350
|
+
it('도구 설명이 저장 전 도형은 모델러에 열린 것만 읽는다고 말한다', () => {
|
|
351
|
+
const description = toolNamed('getFigureSource').schema.properties.figureId.description
|
|
352
|
+
expect(description).not.toMatch(/never saved is not open/)
|
|
353
|
+
expect(description).toMatch(/not saved yet/)
|
|
354
|
+
})
|
|
355
|
+
|
|
306
356
|
it('도메인 문맥이 없으면 아무것도 읽지 않는다', async () => {
|
|
307
357
|
await expect(toolNamed('listFigures').builder({}, { state: {} })).rejects.toThrow(/domain context is missing/)
|
|
308
358
|
})
|
|
309
359
|
})
|
|
310
360
|
|
|
361
|
+
/*
|
|
362
|
+
The list stops at the most recently changed ones. A figure past that point used to be absent with nothing said - the
|
|
363
|
+
model took the cut list for the whole one and told the person the figure did not exist.
|
|
364
|
+
*/
|
|
365
|
+
describe('목록이 잘릴 때', () => {
|
|
366
|
+
const many = [
|
|
367
|
+
...Array.from({ length: 205 }, (_, i) => ({ id: `fig-n${i}`, type: `NEW_${i}`, name: `새 것 ${i}`, state: 'draft', domain: MINE, source: null })),
|
|
368
|
+
{ id: 'fig-old', type: 'OLD_PRESS', name: 'Roll press', state: 'released', domain: MINE, source: null }
|
|
369
|
+
]
|
|
370
|
+
beforeEach(() => {
|
|
371
|
+
const model = require('@hatiolab/figure-model')
|
|
372
|
+
__setFigureToolDepsForTest({ getRepository: () => stubRepository(many), Figure: class {}, figureCostOf: model.figureCostOf, figureScoreOf: model.figureScoreOf })
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
it('잘렸다고 말한다', async () => {
|
|
376
|
+
const result = await toolNamed('listFigures').builder({}, ctx({}))
|
|
377
|
+
expect(result.figures).toHaveLength(200)
|
|
378
|
+
expect(result.truncated).toBe(true)
|
|
379
|
+
expect(result.note).toMatch(/search/)
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
it('이름이나 타입의 일부로 찾으면 잘린 뒤의 도형도 나온다 — 대소문자 없이', async () => {
|
|
383
|
+
const byName = await toolNamed('listFigures').builder({ search: 'roll PRESS' }, ctx({}))
|
|
384
|
+
const byType = await toolNamed('listFigures').builder({ search: 'old_press' }, ctx({}))
|
|
385
|
+
expect(byName.figures.map((one: any) => one.id)).toEqual(['fig-old'])
|
|
386
|
+
expect(byType.figures.map((one: any) => one.id)).toEqual(['fig-old'])
|
|
387
|
+
expect(byName.truncated).toBeUndefined()
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
it('찾는 말도 자기 도메인 안에서만 찾는다', async () => {
|
|
391
|
+
__setFigureToolDepsForTest({ getRepository: () => stubRepository(ROWS), Figure: class {} })
|
|
392
|
+
const result = await toolNamed('listFigures').builder({ search: 'secret' }, ctx({}))
|
|
393
|
+
expect(result.figures).toEqual([])
|
|
394
|
+
})
|
|
395
|
+
})
|
|
396
|
+
|
|
311
397
|
/*
|
|
312
398
|
제안은 모델러의 명령 목록이다(ADR-0094). 고치는 제안은 모델러가 모델이 본 판 위에 한 단계로 적용하므로, 고칠 수
|
|
313
399
|
있는 것은 모델러에 열린 도형뿐이고 그 판(context · revision)을 문맥이 싣고 와야 한다.
|
|
@@ -379,7 +465,7 @@ describe('제안을 요청할 때', () => {
|
|
|
379
465
|
).rejects.toThrow(/not found in this tenant/)
|
|
380
466
|
})
|
|
381
467
|
|
|
382
|
-
it('세 번 거절된 끝의 사유가 도구 경계를 넘어간다 —
|
|
468
|
+
it('세 번 거절된 끝의 사유가 도구 경계를 넘어간다 — 실패 한 줄만 남지 않게', async () => {
|
|
383
469
|
const { ProposeFigureFailure } = require('./figure-propose-steps')
|
|
384
470
|
const model = require('@hatiolab/figure-model')
|
|
385
471
|
__setFigureToolDepsForTest({
|
|
@@ -387,13 +473,13 @@ describe('제안을 요청할 때', () => {
|
|
|
387
473
|
Figure: class {},
|
|
388
474
|
parseSource: require('./figure-format').parseSource,
|
|
389
475
|
proposeFigureSteps: async () => {
|
|
390
|
-
throw new ProposeFigureFailure('
|
|
476
|
+
throw new ProposeFigureFailure('The figure could not be made.', ['ATTACH_FACE: steps[4]: attach: faces along x'], 3)
|
|
391
477
|
},
|
|
392
478
|
figureCostOf: model.figureCostOf,
|
|
393
479
|
figureScoreOf: model.figureScoreOf
|
|
394
480
|
})
|
|
395
481
|
await expect(toolNamed('proposeFigureCandidate').builder({ prompt: '고쳐줘' }, ctx(modeller()))).rejects.toThrow(
|
|
396
|
-
|
|
482
|
+
/The figure could not be made\.\nATTACH_FACE: steps\[4\]/
|
|
397
483
|
)
|
|
398
484
|
})
|
|
399
485
|
})
|
|
@@ -410,19 +496,92 @@ describe('제안이 카드가 된다', () => {
|
|
|
410
496
|
expect(out.steps).toEqual([{ kind: 'rename', name: 'Mixer' }])
|
|
411
497
|
})
|
|
412
498
|
|
|
413
|
-
|
|
414
|
-
|
|
499
|
+
/*
|
|
500
|
+
The card's two lines are sentences a person reads, so the tool writes them with the request's translator
|
|
501
|
+
(ADR-0061, 2026-10-16 decision 1). They were Korean strings in the code: an English screen read Korean.
|
|
502
|
+
*/
|
|
503
|
+
it('★ 카드에 그릴 두 줄이 요청 로케일로, 잰 값으로 온다', async () => {
|
|
504
|
+
const out = await toolNamed('proposeFigureCandidate').builder({ prompt: '교반기', create: true, name: 'Mixer' }, ctx({}, { t: translatorOf('ko') }))
|
|
415
505
|
expect(out.label).toBe('Mixer 만들기')
|
|
416
|
-
expect(out.reason).
|
|
417
|
-
|
|
418
|
-
|
|
506
|
+
expect(out.reason).toBe('명령 1개 · 등급 B · 삼각형 12 · 1번에 만들었다')
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
it('★ 같은 제안이 영어 화면에는 영어로 온다 — 글은 코드에 박히지 않는다', async () => {
|
|
510
|
+
const out = await toolNamed('proposeFigureCandidate').builder({ prompt: 'mixer', create: true, name: 'Mixer' }, ctx({}, { t: translatorOf('en') }))
|
|
511
|
+
expect(out.label).toBe('Make Mixer')
|
|
512
|
+
expect(out.reason).toBe('1 commands · grade B · 12 triangles · made in 1 tries')
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
it('번역기에 이 글이 없어도 영어 기본값을 쓴다 — 키가 화면에 뜨지 않게', async () => {
|
|
516
|
+
const out = await toolNamed('proposeFigureCandidate').builder({ prompt: 'mixer', create: true, name: 'Mixer' }, ctx({}))
|
|
517
|
+
expect(out.label).toBe('Make Mixer')
|
|
518
|
+
expect(out.reason).toBe('1 commands · grade B · 12 triangles · made in 1 tries')
|
|
419
519
|
})
|
|
420
520
|
|
|
421
521
|
it('고치는 것과 만드는 것을 이름으로 가른다', async () => {
|
|
422
|
-
const revise = await toolNamed('proposeFigureCandidate').builder({ prompt: '고쳐줘' }, ctx(modeller()))
|
|
423
|
-
const create = await toolNamed('proposeFigureCandidate').builder({ prompt: '만들어줘', create: true }, ctx({}))
|
|
424
|
-
expect(revise.label).
|
|
425
|
-
expect(create.label).
|
|
522
|
+
const revise = await toolNamed('proposeFigureCandidate').builder({ prompt: '고쳐줘' }, ctx(modeller(), { t: translatorOf('ko') }))
|
|
523
|
+
const create = await toolNamed('proposeFigureCandidate').builder({ prompt: '만들어줘', create: true }, ctx({}, { t: translatorOf('ko') }))
|
|
524
|
+
expect(revise.label).toBe('Probe 고치기')
|
|
525
|
+
expect(create.label).toBe('Figure 만들기')
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
it('카드의 글은 여섯 언어 모두에 있다', () => {
|
|
529
|
+
for (const locale of ['en', 'ko', 'ja', 'zh', 'es', 'ms']) {
|
|
530
|
+
const words = require(`../../../translations/${locale}.json`)
|
|
531
|
+
for (const key of ['figure.proposal.label-create', 'figure.proposal.label-revise', 'figure.proposal.reason']) {
|
|
532
|
+
expect([locale, key, typeof words[key]]).toEqual([locale, key, 'string'])
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
})
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
/*
|
|
539
|
+
A picture the person attached reaches the step generator (ADR-0061, 2026-10-16 decision 2). The dock uploads files by
|
|
540
|
+
attachmentIds and never fills hostContext.image, so the tool reads the server-checked attachments, every image of them.
|
|
541
|
+
*/
|
|
542
|
+
describe('사람이 붙인 그림', () => {
|
|
543
|
+
const FRONT = { id: 'att-1', name: 'front.png', kind: 'image', mediaType: 'image/png', size: 4, data: Buffer.from([1, 2, 3, 4]) }
|
|
544
|
+
const NOTES = { id: 'att-2', name: 'notes.txt', kind: 'text', mediaType: 'text/plain', size: 5, text: 'tall', truncated: false }
|
|
545
|
+
const SIDE = { id: 'att-3', name: 'side.jpg', kind: 'image', mediaType: 'image/jpeg', size: 3, data: Buffer.from([9, 9, 9]) }
|
|
546
|
+
|
|
547
|
+
it('★ 서버가 확인한 첨부의 그림이 모두, 붙인 순서로 제안기에 간다', async () => {
|
|
548
|
+
await toolNamed('proposeFigureCandidate').builder({ prompt: '이 그림처럼', create: true }, ctx({}, { attachments: [FRONT, NOTES, SIDE] }))
|
|
549
|
+
expect(proposed[0].images).toEqual([
|
|
550
|
+
{ data: FRONT.data, mediaType: 'image/png' },
|
|
551
|
+
{ data: SIDE.data, mediaType: 'image/jpeg' }
|
|
552
|
+
])
|
|
553
|
+
})
|
|
554
|
+
|
|
555
|
+
it('★ 화면이 hostContext 에 실은 그림은 제안기에 가지 않는다 — 서버가 확인한 것이 아니다', async () => {
|
|
556
|
+
const host = { image: { data: 'AQIDBA==', mediaType: 'image/png' }, attachments: [{ mediaType: 'image/png', data: 'AQIDBA==' }] }
|
|
557
|
+
await toolNamed('proposeFigureCandidate').builder({ prompt: '이 그림처럼', create: true }, ctx(host))
|
|
558
|
+
expect(proposed[0].images).toBeUndefined()
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
it('그림이 없으면 그림 없이 묻는다', async () => {
|
|
562
|
+
await toolNamed('proposeFigureCandidate').builder({ prompt: '믹서', create: true }, ctx({}, { attachments: [NOTES] }))
|
|
563
|
+
expect(proposed[0].images).toBeUndefined()
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
it('★ 문이 받은 첨부가 도구를 지나 제안기까지 간다 — registryAssistantChat 경계 그대로', async () => {
|
|
567
|
+
let turn = 0
|
|
568
|
+
await registryAssistantChat({
|
|
569
|
+
client: {
|
|
570
|
+
chatWithTools: async () =>
|
|
571
|
+
turn++ === 0
|
|
572
|
+
? { toolCalls: [{ id: 'p-1', name: 'proposeFigureCandidate', arguments: { prompt: '이 그림처럼 만들어줘', create: true }, type: 'tool_use' }], stopReason: 'tool_use' }
|
|
573
|
+
: { text: '후보를 냈습니다.', toolCalls: [], stopReason: 'end_turn' }
|
|
574
|
+
} as any,
|
|
575
|
+
messages: [{ role: 'user', content: '이 그림처럼 만들어줘' }],
|
|
576
|
+
systemPrompt: 'Author figures.',
|
|
577
|
+
toolCategories: ['figure-authoring'],
|
|
578
|
+
state: { domain: MINE },
|
|
579
|
+
hostContext: {},
|
|
580
|
+
attachments: [FRONT] as any,
|
|
581
|
+
toolAccess: async () => true
|
|
582
|
+
})
|
|
583
|
+
expect(proposed).toHaveLength(1)
|
|
584
|
+
expect(proposed[0].images).toEqual([{ data: FRONT.data, mediaType: 'image/png' }])
|
|
426
585
|
})
|
|
427
586
|
})
|
|
428
587
|
|
|
@@ -435,8 +594,209 @@ describe('도형이 모델러에 열려 있을 때', () => {
|
|
|
435
594
|
expect(got.score.grade).toBe('A')
|
|
436
595
|
})
|
|
437
596
|
|
|
597
|
+
/*
|
|
598
|
+
"This part", "the selected one", "the value I am trying": the screen sends what it has in view with the version it was
|
|
599
|
+
seen on (ADR-0097 §5.6 item 5). The model hears it only through a read tool (ADR-0061 decision 3), so a tool that
|
|
600
|
+
drops it leaves the model guessing which part was meant.
|
|
601
|
+
*/
|
|
602
|
+
it('선택한 부품과 시험 중인 값을 같은 판에서 본 것일 때 함께 읽는다', async () => {
|
|
603
|
+
const host = modeller({
|
|
604
|
+
selection: { part: 'body', context: 'ctx-7', revision: 4 },
|
|
605
|
+
trying: { clips: { lift: 0.5 }, controls: { height: 300 }, context: 'ctx-7', revision: 4 }
|
|
606
|
+
})
|
|
607
|
+
const got = await toolNamed('getFigureSource').builder({}, ctx(host))
|
|
608
|
+
expect(got.selection).toEqual({ part: 'body' })
|
|
609
|
+
expect(got.trying).toEqual({ clips: { lift: 0.5 }, controls: { height: 300 } })
|
|
610
|
+
})
|
|
611
|
+
|
|
612
|
+
it('다른 판에서 본 선택이나 초안에 없는 부품은 말하지 않는다 — 없는 부품을 가리키지 않게', async () => {
|
|
613
|
+
const stale = await toolNamed('getFigureSource').builder({}, ctx(modeller({ selection: { part: 'body', context: 'ctx-7', revision: 3 } })))
|
|
614
|
+
const missing = await toolNamed('getFigureSource').builder({}, ctx(modeller({ selection: { part: 'arm', context: 'ctx-7', revision: 4 } })))
|
|
615
|
+
expect(stale.selection).toBeUndefined()
|
|
616
|
+
expect(missing.selection).toBeUndefined()
|
|
617
|
+
})
|
|
618
|
+
|
|
619
|
+
it('시험 값은 수만 싣는다 — 화면이 보낸 것은 믿지 않는 자료다', async () => {
|
|
620
|
+
const got = await toolNamed('getFigureSource').builder(
|
|
621
|
+
{},
|
|
622
|
+
ctx(modeller({ trying: { clips: { lift: 'drop table', open: 1 }, controls: { height: Infinity }, context: 'ctx-7', revision: 4 } }))
|
|
623
|
+
)
|
|
624
|
+
expect(got.trying).toEqual({ clips: { open: 1 } })
|
|
625
|
+
})
|
|
626
|
+
|
|
627
|
+
it('저장된 정본을 읽을 때는 화면의 선택을 붙이지 않는다 — 다른 도형의 선택일 수 있다', async () => {
|
|
628
|
+
const got = await toolNamed('getFigureSource').builder({ figureId: 'fig-1' }, ctx({ selection: { part: 'body', context: 'c', revision: 1 }, context: 'c', revision: 1 }))
|
|
629
|
+
expect(got.draft).toBe(false)
|
|
630
|
+
expect(got.selection).toBeUndefined()
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
it('고치는 제안에는 선택한 부품이 제안기까지 간다', async () => {
|
|
634
|
+
await toolNamed('proposeFigureCandidate').builder({ prompt: '이것을 더 크게' }, ctx(modeller({ selection: { part: 'body', context: 'ctx-7', revision: 4 } })))
|
|
635
|
+
expect(proposed[0].focus).toEqual({ selection: { part: 'body' } })
|
|
636
|
+
})
|
|
637
|
+
|
|
438
638
|
it('형식을 어긴 초안은 저장 문의 말로 거절한다', async () => {
|
|
439
639
|
const broken = { ...figureDraft(), appearance: [] }
|
|
440
640
|
await expect(toolNamed('getFigureSource').builder({}, ctx({ figureId: 'fig-1', draftSource: broken }))).rejects.toThrow(/unsaved draft is invalid/)
|
|
441
641
|
})
|
|
442
642
|
})
|
|
643
|
+
|
|
644
|
+
/*
|
|
645
|
+
A figure the kernel keeps (1 MiB) can be larger than the model is shown in one read (ADR-0061 2026-10-16 decision 4).
|
|
646
|
+
Until then the tools refused any draft over 100,000 characters: a figure that opened and saved got no read, no
|
|
647
|
+
inspection and no proposal, and saving again did not help. Now it is read as an outline and the parts asked for.
|
|
648
|
+
*/
|
|
649
|
+
describe('도형이 한 번에 보일 만큼보다 클 때', () => {
|
|
650
|
+
const { FIGURE_CONTEXT_BUDGET } = require('./figure-context')
|
|
651
|
+
const model = require('@hatiolab/figure-model')
|
|
652
|
+
let under: any
|
|
653
|
+
let over: any
|
|
654
|
+
beforeAll(() => {
|
|
655
|
+
let rack = model.createFigureAsset('Rack')
|
|
656
|
+
for (let i = 0; ; i++) {
|
|
657
|
+
const next = model.applyFigureProposal(rack, [
|
|
658
|
+
{ kind: 'add-part', part: { id: `shelf-${i}`, kind: 'box', dimensions: { width: 1200, height: 40, depth: 600 } } },
|
|
659
|
+
i === 0
|
|
660
|
+
? { kind: 'attach', part: 'shelf-0', face: 'bottom', to: { plane: 'mounting-plane' } }
|
|
661
|
+
: { kind: 'attach', part: `shelf-${i}`, face: 'bottom', to: { part: `shelf-${i - 1}`, face: 'top' }, gap: 300 }
|
|
662
|
+
])
|
|
663
|
+
if (JSON.stringify(next).length > FIGURE_CONTEXT_BUDGET) {
|
|
664
|
+
under = rack
|
|
665
|
+
over = next
|
|
666
|
+
return
|
|
667
|
+
}
|
|
668
|
+
rack = next
|
|
669
|
+
}
|
|
670
|
+
})
|
|
671
|
+
|
|
672
|
+
it('바로 아래는 원본 전체를 읽는다', async () => {
|
|
673
|
+
const got = await toolNamed('getFigureSource').builder({}, ctx(modeller({ draftSource: under })))
|
|
674
|
+
expect(got.source).toEqual(under)
|
|
675
|
+
expect(got.outline).toBeUndefined()
|
|
676
|
+
})
|
|
677
|
+
|
|
678
|
+
it('바로 위도 거절하지 않는다 — 개요와 고른 부품을 읽고, 잰 값은 전체에서 잰다', async () => {
|
|
679
|
+
const got = await toolNamed('getFigureSource').builder({ parts: ['shelf-3'] }, ctx(modeller({ draftSource: over })))
|
|
680
|
+
expect(got.source).toBeUndefined()
|
|
681
|
+
expect(got.outline.parts).toHaveLength(over.appearance.length)
|
|
682
|
+
expect(Object.keys(got.partSources)).toEqual(['shelf-3'])
|
|
683
|
+
expect(JSON.stringify(got.outline).length).toBeLessThan(FIGURE_CONTEXT_BUDGET)
|
|
684
|
+
expect(got.note).toMatch(/parts/)
|
|
685
|
+
expect(got.cost).toEqual(model.figureCostOf(over))
|
|
686
|
+
expect(JSON.stringify(got).length).toBeLessThan(FIGURE_CONTEXT_BUDGET + 2_000)
|
|
687
|
+
})
|
|
688
|
+
|
|
689
|
+
it('절은 포인터로 읽는다', async () => {
|
|
690
|
+
const got = await toolNamed('getFigureSource').builder({ sections: ['/palette'] }, ctx(modeller({ draftSource: over })))
|
|
691
|
+
expect(got.sectionSources).toEqual({ '/palette': over.palette })
|
|
692
|
+
})
|
|
693
|
+
|
|
694
|
+
it('고른 부품이 없으면 선택한 부품을 읽는다', async () => {
|
|
695
|
+
const got = await toolNamed('getFigureSource').builder({}, ctx(modeller({ draftSource: over, selection: { part: 'shelf-6', context: 'ctx-7', revision: 4 } })))
|
|
696
|
+
expect(Object.keys(got.partSources)).toEqual(['shelf-6'])
|
|
697
|
+
})
|
|
698
|
+
|
|
699
|
+
it('저장된 원본도 같은 예산으로 읽는다', async () => {
|
|
700
|
+
__setFigureToolDepsForTest({
|
|
701
|
+
getRepository: () => stubRepository([{ id: 'fig-big', type: 'RACK', name: 'Rack', state: 'draft', domain: MINE, source: JSON.stringify(over) }]),
|
|
702
|
+
Figure: class {},
|
|
703
|
+
figureCostOf: model.figureCostOf,
|
|
704
|
+
figureScoreOf: model.figureScoreOf
|
|
705
|
+
})
|
|
706
|
+
const got = await toolNamed('getFigureSource').builder({ figureId: 'fig-big' }, ctx({}))
|
|
707
|
+
expect(got.source).toBeUndefined()
|
|
708
|
+
expect(got.outline.chars).toBe(JSON.stringify(over).length)
|
|
709
|
+
})
|
|
710
|
+
|
|
711
|
+
it('검사는 전체 원본으로 하고, 거절하지 않는다', async () => {
|
|
712
|
+
const seen: string[] = []
|
|
713
|
+
__setFigureToolDepsForTest({
|
|
714
|
+
getRepository: () => stubRepository(ROWS),
|
|
715
|
+
Figure: class {},
|
|
716
|
+
parseSource: require('./figure-format').parseSource,
|
|
717
|
+
inspectSource: (json: string) => {
|
|
718
|
+
seen.push(json)
|
|
719
|
+
return { blocked: false, findings: [] }
|
|
720
|
+
}
|
|
721
|
+
})
|
|
722
|
+
const got = await toolNamed('inspectFigure').builder({}, ctx(modeller({ draftSource: over })))
|
|
723
|
+
expect(got.blocked).toBe(false)
|
|
724
|
+
expect(seen[0].length).toBe(JSON.stringify(over).length)
|
|
725
|
+
})
|
|
726
|
+
|
|
727
|
+
it('검사 결과가 예산을 넘으면 앞에서부터 싣고 몇 개 중 몇 개인지 말한다', async () => {
|
|
728
|
+
const findings = Array.from({ length: 2_000 }, (_, i) => ({ code: 'part-outside-room', message: `shelf-${i} stands outside the room declared for the figure`, at: `shelf-${i}`, blocking: true }))
|
|
729
|
+
__setFigureToolDepsForTest({
|
|
730
|
+
getRepository: () => stubRepository(ROWS),
|
|
731
|
+
Figure: class {},
|
|
732
|
+
parseSource: require('./figure-format').parseSource,
|
|
733
|
+
inspectSource: () => ({ blocked: true, findings })
|
|
734
|
+
})
|
|
735
|
+
const got = await toolNamed('inspectFigure').builder({}, ctx(modeller({ draftSource: over })))
|
|
736
|
+
expect(JSON.stringify(got).length).toBeLessThanOrEqual(FIGURE_CONTEXT_BUDGET)
|
|
737
|
+
expect(got.truncated).toEqual({ shown: got.findings.length, total: 2_000 })
|
|
738
|
+
expect(got.findings[0]).toEqual(findings[0])
|
|
739
|
+
})
|
|
740
|
+
|
|
741
|
+
it('고치는 제안에 고른 부품이 제안기까지 가고, 판은 그대로 돌아온다', async () => {
|
|
742
|
+
const out = await toolNamed('proposeFigureCandidate').builder(
|
|
743
|
+
{ prompt: '3단을 빨갛게', parts: ['shelf-3'], sections: ['/palette', 7] },
|
|
744
|
+
ctx(modeller({ draftSource: over }))
|
|
745
|
+
)
|
|
746
|
+
expect(proposed[0].base).toEqual(over)
|
|
747
|
+
expect(proposed[0].read).toEqual({ parts: ['shelf-3'], sections: ['/palette'] })
|
|
748
|
+
expect(out.revision).toBe(4)
|
|
749
|
+
})
|
|
750
|
+
})
|
|
751
|
+
|
|
752
|
+
/*
|
|
753
|
+
A figure not saved yet is a subject too (ADR-0061 decision 3). The modeller names it by its creation id - the id its
|
|
754
|
+
first save inserts the row under (ADR-0072 decision 6) - so before that save there is no row: the tools read the draft
|
|
755
|
+
the screen sent, through the same door a save goes through. Only the figure open on that screen: an id the model passes
|
|
756
|
+
that has no row and is not the open one is still not found.
|
|
757
|
+
*/
|
|
758
|
+
describe('저장 전 도형', () => {
|
|
759
|
+
const CREATION = '33333333-3333-4333-8333-333333333333'
|
|
760
|
+
const unsaved = (over: any = {}) => modeller({ figureId: CREATION, editorMode: 'new', ...over })
|
|
761
|
+
|
|
762
|
+
it('★ 화면이 보낸 초안을 읽고 잰다 — 행이 없어도', async () => {
|
|
763
|
+
const got = await toolNamed('getFigureSource').builder({}, ctx(unsaved()))
|
|
764
|
+
expect(got).toMatchObject({ id: CREATION, name: 'Probe', draft: true })
|
|
765
|
+
expect(got.source.version).toBe('figure-asset-1')
|
|
766
|
+
expect(got.cost.triangles).toBe(12)
|
|
767
|
+
})
|
|
768
|
+
|
|
769
|
+
it('선택한 부품도 함께 읽는다 — 「이 부품」이 무엇인지', async () => {
|
|
770
|
+
const got = await toolNamed('getFigureSource').builder({}, ctx(unsaved({ selection: { part: 'body', context: 'ctx-7', revision: 4 } })))
|
|
771
|
+
expect(got.selection).toEqual({ part: 'body' })
|
|
772
|
+
})
|
|
773
|
+
|
|
774
|
+
it('발행 판정도 초안으로 한다', async () => {
|
|
775
|
+
const got = await toolNamed('inspectFigure').builder({}, ctx(unsaved()))
|
|
776
|
+
expect(got).toMatchObject({ blocked: false, draft: true })
|
|
777
|
+
})
|
|
778
|
+
|
|
779
|
+
it('★ 고치는 제안은 그 초안 위에, 모델러가 적용할 판과 함께 낸다', async () => {
|
|
780
|
+
const out = await toolNamed('proposeFigureCandidate').builder({ prompt: '이 부품을 더 크게' }, ctx(unsaved({ selection: { part: 'body', context: 'ctx-7', revision: 4 } })))
|
|
781
|
+
expect(proposed[0].base.name).toBe('Probe')
|
|
782
|
+
expect(proposed[0].focus).toEqual({ selection: { part: 'body' } })
|
|
783
|
+
expect(out).toMatchObject({ create: false, figureId: CREATION, context: 'ctx-7', revision: 4 })
|
|
784
|
+
})
|
|
785
|
+
|
|
786
|
+
it('형식을 어긴 초안은 저장 문의 말로 거절한다', async () => {
|
|
787
|
+
const broken = { ...figureDraft(), appearance: [] }
|
|
788
|
+
await expect(toolNamed('getFigureSource').builder({}, ctx(unsaved({ draftSource: broken })))).rejects.toThrow(/unsaved draft is invalid/)
|
|
789
|
+
})
|
|
790
|
+
|
|
791
|
+
it('★ 열려 있지 않은 id 는 행이 없으면 못 찾았다고 한다', async () => {
|
|
792
|
+
await expect(toolNamed('getFigureSource').builder({ figureId: CREATION }, ctx(modeller()))).rejects.toThrow(/not found in this tenant/)
|
|
793
|
+
})
|
|
794
|
+
|
|
795
|
+
it('초안을 보내지 않은 화면에서는 못 찾았다고 한다', async () => {
|
|
796
|
+
await expect(toolNamed('getFigureSource').builder({}, ctx({ figureId: CREATION, editorMode: 'new' }))).rejects.toThrow(/not found in this tenant/)
|
|
797
|
+
})
|
|
798
|
+
|
|
799
|
+
it('생성 식별자 꼴이 아닌 이름은 저장 전 도형으로 읽지 않는다', async () => {
|
|
800
|
+
await expect(toolNamed('getFigureSource').builder({}, ctx(unsaved({ figureId: 'fig-unknown' })))).rejects.toThrow(/not found in this tenant/)
|
|
801
|
+
})
|
|
802
|
+
})
|