@things-factory/figure-service 10.1.167 → 10.1.168

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist-server/service/figure/figure-context.d.ts +108 -0
  2. package/dist-server/service/figure/figure-context.js +368 -0
  3. package/dist-server/service/figure/figure-context.js.map +1 -0
  4. package/dist-server/service/figure/figure-format.js +10 -16
  5. package/dist-server/service/figure/figure-format.js.map +1 -1
  6. package/dist-server/service/figure/figure-mutation.js +2 -2
  7. package/dist-server/service/figure/figure-mutation.js.map +1 -1
  8. package/dist-server/service/figure/figure-proposal.js +4 -2
  9. package/dist-server/service/figure/figure-proposal.js.map +1 -1
  10. package/dist-server/service/figure/figure-propose-steps.d.ts +33 -5
  11. package/dist-server/service/figure/figure-propose-steps.js +87 -15
  12. package/dist-server/service/figure/figure-propose-steps.js.map +1 -1
  13. package/dist-server/service/figure/figure-tools.js +197 -49
  14. package/dist-server/service/figure/figure-tools.js.map +1 -1
  15. package/dist-server/tsconfig.tsbuildinfo +1 -1
  16. package/package.json +4 -4
  17. package/server/service/figure/figure-context.test.ts +288 -0
  18. package/server/service/figure/figure-context.ts +447 -0
  19. package/server/service/figure/figure-format.test.ts +18 -1
  20. package/server/service/figure/figure-format.ts +10 -16
  21. package/server/service/figure/figure-llm-smoke.test.ts +82 -13
  22. package/server/service/figure/figure-mutation.ts +3 -3
  23. package/server/service/figure/figure-proposal.ts +4 -2
  24. package/server/service/figure/figure-propose-steps.test.ts +95 -3
  25. package/server/service/figure/figure-propose-steps.ts +105 -23
  26. package/server/service/figure/figure-tools-door-db.test.ts +178 -0
  27. package/server/service/figure/figure-tools.test.ts +382 -22
  28. package/server/service/figure/figure-tools.ts +218 -50
  29. package/translations/en.json +11 -9
  30. package/translations/es.json +11 -9
  31. package/translations/ja.json +11 -9
  32. package/translations/ko.json +11 -9
  33. package/translations/ms.json +11 -9
  34. 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
- async find({ where }: any) {
28
- return rows.filter(row => row.domain?.id === where.domain?.id)
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
- const ctx = (host: any = { figureId: 'fig-1', palette: ['palette.primary'] }) => ({
90
- state: { domain: MINE }, host
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('세 번 거절된 끝의 사유가 도구 경계를 넘어간다 — 「만들지 못했습니다」만 남지 않게', async () => {
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('만들지 못했습니다.', ['ATTACH_FACE: steps[4]: attach: faces along x'], 3)
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
- /만들지 못했습니다\.\nATTACH_FACE: steps\[4\]/
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
- it('카드에 그릴 두 줄이 잰 값으로 온다', async () => {
414
- const out = await toolNamed('proposeFigureCandidate').builder({ prompt: '교반기', create: true, name: 'Mixer' }, ctx({}))
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).toMatch(/명령 1개/)
417
- expect(out.reason).toMatch(/등급 B/)
418
- expect(out.reason).toMatch(/삼각형 12/)
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).toMatch(/고치기/)
425
- expect(create.label).toMatch(/만들기/)
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
+ })