@things-factory/figure-service 10.1.107 → 10.1.110

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 (26) hide show
  1. package/dist-server/service/figure/event-subscriber.d.ts +0 -10
  2. package/dist-server/service/figure/event-subscriber.js +13 -7
  3. package/dist-server/service/figure/event-subscriber.js.map +1 -1
  4. package/dist-server/service/figure/figure-format.d.ts +11 -1
  5. package/dist-server/service/figure/figure-format.js +46 -0
  6. package/dist-server/service/figure/figure-format.js.map +1 -1
  7. package/dist-server/service/figure/figure-history.d.ts +2 -0
  8. package/dist-server/service/figure/figure-history.js +13 -0
  9. package/dist-server/service/figure/figure-history.js.map +1 -1
  10. package/dist-server/service/figure/figure-mutation.js +6 -2
  11. package/dist-server/service/figure/figure-mutation.js.map +1 -1
  12. package/dist-server/service/figure/figure-query.d.ts +1 -1
  13. package/dist-server/service/figure/figure-query.js +23 -6
  14. package/dist-server/service/figure/figure-query.js.map +1 -1
  15. package/dist-server/tsconfig.tsbuildinfo +1 -1
  16. package/package.json +3 -3
  17. package/server/service/figure/event-subscriber.ts +14 -5
  18. package/server/service/figure/figure-blueprint-query.test.ts +38 -0
  19. package/server/service/figure/figure-format.test.ts +30 -1
  20. package/server/service/figure/figure-format.ts +44 -0
  21. package/server/service/figure/figure-history.ts +16 -0
  22. package/server/service/figure/figure-mutation.ts +7 -3
  23. package/server/service/figure/figure-query.ts +30 -6
  24. package/server/service/figure/figure-release-record-db.test.ts +94 -0
  25. package/translations/en.json +12 -1
  26. package/translations/ko.json +12 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-service",
3
- "version": "10.1.107",
3
+ "version": "10.1.110",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -25,7 +25,7 @@
25
25
  "test:templates": "node --test scripts/templates.test.mts"
26
26
  },
27
27
  "dependencies": {
28
- "@hatiolab/figure-model": "^0.2.7",
28
+ "@hatiolab/figure-model": "^0.2.8",
29
29
  "@operato/typeorm-history": "^10.0.0",
30
30
  "@things-factory/ai-client-base": "^10.1.81",
31
31
  "@things-factory/auth-base": "^10.1.107",
@@ -35,5 +35,5 @@
35
35
  "graphql-upload": "^15.0.2",
36
36
  "koa-router": "^7.4.0"
37
37
  },
38
- "gitHead": "65a3d5889e687d8122a65f029b926b1a4769d1c0"
38
+ "gitHead": "b68feb6eccbef44af2a6365f384bd852dd70a8f9"
39
39
  }
@@ -13,8 +13,19 @@ import { FigureHistory } from './figure-history.js'
13
13
  * **발행한 것**이므로 그때만 베낀다. board 가 같은 규칙으로 돈다(`board-service` 의 같은 이름 파일).
14
14
  *
15
15
  * 발행 메모는 Figure 의 칸이 아니다. `releaseFigure` 가 저장할 엔티티에 `__releaseComment` 로 얹어
16
- * 두면 여기서 판본의 `comment` 로 옮긴다 — 메모는 그 판의 것이지 도형의 것이 아니다.
16
+ * 두면 여기서 판본의 `comment` 로 옮긴다 — 메모는 그 판의 것이지 도형의 것이 아니다. 정본 형식
17
+ * (`__releaseFormat`)과 직전 판에 견준 변경(`__releaseChanges`)도 같은 길로 옮긴다(ADR-0104).
17
18
  */
19
+ /** What `releaseFigure` laid on the entity for this release, moved onto the history row. */
20
+ function releaseRecordOnto(history: FigureHistory, entity: Figure): FigureHistory {
21
+ const e = entity as any
22
+ ;(history as any).comment = e.__releaseComment ?? null
23
+ history.assetFormat = e.__releaseFormat ?? null
24
+ /* [] stays "[]" (nothing changed); null stays null (not compared). */
25
+ history.changes = e.__releaseChanges == null ? null : JSON.stringify(e.__releaseChanges)
26
+ return history
27
+ }
28
+
18
29
  @EventSubscriber()
19
30
  export class FigureHistoryEntitySubscriber extends HistoryEntitySubscriber<Figure, FigureHistory> {
20
31
  public get entity() {
@@ -26,13 +37,11 @@ export class FigureHistoryEntitySubscriber extends HistoryEntitySubscriber<Figur
26
37
  }
27
38
 
28
39
  public beforeInsertHistory(history: FigureHistory, entity: Figure): FigureHistory {
29
- ;(history as any).comment = (entity as any).__releaseComment ?? null
30
- return history
40
+ return releaseRecordOnto(history, entity)
31
41
  }
32
42
 
33
43
  public beforeUpdateHistory(history: FigureHistory, entity: Figure): FigureHistory {
34
- ;(history as any).comment = (entity as any).__releaseComment ?? null
35
- return history
44
+ return releaseRecordOnto(history, entity)
36
45
  }
37
46
 
38
47
  public async afterInsert(event: any): Promise<void> {
@@ -374,6 +374,44 @@ describe('figureBlueprint 질의 — 소비처(board, twin, things-scene)에 발
374
374
  expect(source.slice(0, at)).toContain('@UseMiddleware(SpeakRefusals)')
375
375
  })
376
376
 
377
+ /* ADR-0104 decision 2: a consumer that pins a release asks for it by number; every answer carries its number. */
378
+ describe('a release asked for by number', () => {
379
+ async function threeReleases(type: string) {
380
+ const figure = await dataSource!.getRepository(Figure).save({ domain: testDomain, type, name: 'pinned', state: 'draft', source: boxNamed(type, 'unreleased'), version: 3 })
381
+ for (const version of [1, 2, 3])
382
+ await dataSource!.getRepository(FigureHistory).save({ domain: testDomain, originalId: figure.id, type, name: `v${version}`, state: 'released', version, source: boxNamed(type, `v${version}`) })
383
+ return figure
384
+ }
385
+
386
+ it('answers the latest with its number when no number is asked', async () => {
387
+ await threeReleases('PINNED')
388
+ const result = await new FigureQuery().figureBlueprint('PINNED', makeContext())
389
+ expect([result.version, partsOf(result)]).toEqual([3, ['v3']])
390
+ })
391
+
392
+ it('answers an older release by its number, not the latest', async () => {
393
+ await threeReleases('PINNED')
394
+ const result = await new FigureQuery().figureBlueprint('PINNED', makeContext(), 1)
395
+ expect([result.version, partsOf(result)]).toEqual([1, ['v1']])
396
+ })
397
+
398
+ it('refuses a number that was never released by name, never answering another release', async () => {
399
+ await threeReleases('PINNED')
400
+ await expect(new FigureQuery().figureBlueprint('PINNED', makeContext(), 7)).rejects.toMatchObject({ code: 'figure-release-not-found' })
401
+ for (const lang of ['ko', 'en']) expect(require(`../../../translations/${lang}.json`)['error.figure-release-not-found']).toMatch(/\{latest\}/)
402
+ })
403
+
404
+ it('a type nobody released is still null, whatever number is asked', async () => {
405
+ expect(await new FigureQuery().figureBlueprint('NOBODY', makeContext(), 1)).toBeNull()
406
+ })
407
+
408
+ it('a released row is answered with its own number', async () => {
409
+ await dataSource!.getRepository(Figure).save({ domain: testDomain, type: 'ROW', name: 'row', state: 'released', source: sampleSource, version: 4 })
410
+ expect((await new FigureQuery().figureBlueprint('ROW', makeContext())).version).toBe(4)
411
+ expect((await new FigureQuery().figureBlueprint('ROW', makeContext(), 4)).version).toBe(4)
412
+ })
413
+ })
414
+
377
415
  describe('a child domain shadows its parent only with a released version', () => {
378
416
  const boxOf = (_type: string, part: string) => box(part)
379
417
 
@@ -9,7 +9,7 @@
9
9
  import { createFigureAsset, addFigureBox, addFigurePrimitive, serializeFigureAsset, applyFigureAuthoring, proposeFigureOccupancy, placeholderType, figureScoreOf } from '@hatiolab/figure-model'
10
10
  import type { FigureAsset } from '@hatiolab/figure-model'
11
11
 
12
- import { FigureSourceError, consumablePayloadOf, inspectSource, parseSource } from './figure-format'
12
+ import { FigureSourceError, consumablePayloadOf, inspectSource, parseSource, releaseRecordOf } from './figure-format'
13
13
 
14
14
  /** 온전한 자산 하나: 바닥에 선 상자와, 부품에서 제안받아 확정한 점유 부피. */
15
15
  function figureAsset(): FigureAsset {
@@ -320,4 +320,33 @@ describe('figure-format — 판을 알아보는 문', () => {
320
320
  expect(() => consumablePayloadOf(JSON.stringify(broken))).toThrow()
321
321
  })
322
322
  })
323
+
324
+ /* ADR-0104 decision 5: a release records its format and what changed against the last one, once, as it was then. */
325
+ describe('발행이 스스로 남기는 것 — 형식과 직전 판에 견준 변경', () => {
326
+ const wider = () => {
327
+ let asset = createFigureAsset('Mixer')
328
+ asset = addFigureBox(asset, { id: 'body', size: { x: 300, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
329
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'body', face: 'bottom', to: { plane: 'mounting-plane' } })
330
+ return serializeFigureAsset(applyFigureAuthoring(asset, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(asset, { over: 'range' }), placement: 'floor' }))
331
+ }
332
+ it('a first release: its format, and nothing changed', () => {
333
+ expect(releaseRecordOf(serializeFigureAsset(figureAsset()))).toEqual({ format: 'figure-asset-1', changes: [] })
334
+ })
335
+ it('the same again changes nothing; a wider room is said and does not block', () => {
336
+ const v1 = serializeFigureAsset(figureAsset())
337
+ expect(releaseRecordOf(v1, v1).changes).toEqual([])
338
+ const room = releaseRecordOf(wider(), v1).changes!.find(c => c.code === 'occupancy-changed')!
339
+ expect(room.blocking).toBe(false)
340
+ expect(String(room.values.after)).toMatch(/x -150…150/)
341
+ })
342
+ it('a last release that cannot be read is "not compared" (null), never "nothing changed" ([])', () => {
343
+ expect(releaseRecordOf(serializeFigureAsset(figureAsset()), '{ not json').changes).toBeNull()
344
+ })
345
+ it('every new change has its sentence in both languages', () => {
346
+ const KO = require('../../../translations/ko.json') as Record<string, string>
347
+ const EN = require('../../../translations/en.json') as Record<string, string>
348
+ for (const code of ['occupancy-changed', 'contact-face-changed', 'public-input-gone', 'public-input-range-narrowed', 'public-input-added'])
349
+ for (const dict of [KO, EN]) for (const field of ['message', 'why']) expect(dict[`figure.gate.contract.${code}.${field}`]).toBeTruthy()
350
+ })
351
+ })
323
352
  })
@@ -420,7 +420,51 @@ function figureContractWording(change: FigureContractChange, say: Say): Pick<Gat
420
420
  message: say(key('message'), v, `The capability '${v.name}' is new.`),
421
421
  why: say(key('why'), {}, 'It adds what the figure can do. Placed instances stand as they are.')
422
422
  }
423
+ /* Said, never refused (ADR-0104): a consumer that pins a release reads these before it moves to this one. */
424
+ case 'occupancy-changed':
425
+ return {
426
+ message: say(key('message'), v, `The room the figure takes changed from ${v.before || 'none'} to ${v.after || 'none'}.`),
427
+ why: say(key('why'), {}, 'A consumer places the figure by this room. One that moves to this release sees it take the new room.')
428
+ }
429
+ case 'contact-face-changed':
430
+ return {
431
+ message: say(key('message'), v, 'The face the figure is mounted by changed.'),
432
+ why: say(key('why'), {}, 'A consumer that moves to this release sees it stand on the new face.')
433
+ }
434
+ case 'public-input-gone':
435
+ return {
436
+ message: say(key('message'), { ...v, input: change.subject ?? '' }, `'${change.subject}' is no longer set on a board.`),
437
+ why: say(key('why'), {}, 'Values placed instances give it do nothing after this release.')
438
+ }
439
+ case 'public-input-range-narrowed':
440
+ return {
441
+ message: say(key('message'), { ...v, input: change.subject ?? '' }, `The range of '${change.subject}' narrowed from ${v.before} to ${v.after}.`),
442
+ why: say(key('why'), {}, 'An instance may have set a value outside the new range. Which values are placed is not recorded.')
443
+ }
444
+ case 'public-input-added':
445
+ return {
446
+ message: say(key('message'), { ...v, input: change.subject ?? '' }, `'${change.subject}' can now be set on a board.`),
447
+ why: say(key('why'), {}, 'Placed instances that give no value stand at its default.')
448
+ }
449
+ }
450
+ }
451
+
452
+ /**
453
+ * What a release records about itself (ADR-0104 decision 5): the format its asset is written in, and what changed
454
+ * against the previous release -- the kernel's own comparison, kept as it was then so a consumer that pins releases
455
+ * reads it rather than computing it again. `[]` for a first release; `null` when the previous release could not be
456
+ * read, which is a different fact from "nothing changed".
457
+ */
458
+ export function releaseRecordOf(sourceJson: string, releasedJson?: string | null): { format: string; changes: FigureContractChange[] | null } {
459
+ const asset = parseFigureAsset(sourceJson)
460
+ if (!releasedJson) return { format: asset.version, changes: [] }
461
+ let before: FigureAsset
462
+ try {
463
+ before = parseFigureAsset(releasedJson)
464
+ } catch {
465
+ return { format: asset.version, changes: null }
423
466
  }
467
+ return { format: asset.version, changes: figureContractChanges(before, asset) }
424
468
  }
425
469
 
426
470
  /**
@@ -94,6 +94,22 @@ export class FigureHistory implements HistoryEntityInterface<Figure> {
94
94
  @Field({ nullable: true, description: 'The note left when this version was released.' })
95
95
  comment?: string
96
96
 
97
+ /*
98
+ * 이 판의 정본 형식(`figure-asset-1`)과, 직전 판에 견준 변경(ADR-0104 결정 5). 발행할 때 한 번 정하고 다시
99
+ * 계산하지 않는다 — 판을 고정해 쓰는 쪽은 다음 판으로 옮기기 전에 이것을 읽는다.
100
+ */
101
+ @Column({ nullable: true })
102
+ @Field({ nullable: true, description: 'The asset format this version was written in (figure-asset-1).' })
103
+ assetFormat?: string
104
+
105
+ @Column({ nullable: true, ...longTextColumn() })
106
+ @Field({
107
+ nullable: true,
108
+ description:
109
+ 'What changed against the previous release, as JSON: [{ code, blocking, subject?, values, detail }]. [] for the first release. null when it was not compared: the previous release could not be read, or this release was made before releases recorded it (figure-service 10.1.109).'
110
+ })
111
+ changes?: string
112
+
97
113
  @Column({ nullable: true })
98
114
  @Field({ nullable: true, description: 'When the original figure was created.' })
99
115
  createdAt?: Date
@@ -11,7 +11,7 @@ import { FigureReplacedDraft } from './figure-replaced-draft.js'
11
11
  import { revertKeepingDraft, undoRevert } from './figure-revert.js'
12
12
  import { recordProposals } from './figure-proposal.js'
13
13
  import { FigureProposal, ProposeRequest } from './figure-propose-type.js'
14
- import { inspectSource } from './figure-format.js'
14
+ import { inspectSource, releaseRecordOf } from './figure-format.js'
15
15
  import { renameDraftType, renameFacts } from './figure-rename.js'
16
16
  import { parseSource } from './figure-format.js'
17
17
  import { ProposeFigureFailure, proposeFigureSteps } from './figure-propose-steps.js'
@@ -319,6 +319,8 @@ export class FigureMutation {
319
319
  }
320
320
 
321
321
  const nextVersion = released?.version ? Number(released.version) + 1 : 1
322
+ /* What this release records about itself: its format, and what changed against the last one (ADR-0104). */
323
+ const record = releaseRecordOf(figure.source!, released?.source)
322
324
 
323
325
  return await repository.save({
324
326
  /*
@@ -333,8 +335,10 @@ export class FigureMutation {
333
335
  version: nextVersion,
334
336
  state: 'released',
335
337
  updater: user,
336
- /* 판본 구독자가 이것을 판본의 comment 로 옮긴다. Figure 의 칸이 아니다. */
337
- __releaseComment: comment ?? null
338
+ /* 판본 구독자가 이것들을 판본의 comment · assetFormat · changes 로 옮긴다. Figure 의 칸이 아니다. */
339
+ __releaseComment: comment ?? null,
340
+ __releaseFormat: record.format,
341
+ __releaseChanges: record.changes
338
342
  } as any)
339
343
  }
340
344
 
@@ -1,5 +1,5 @@
1
1
  import { In } from 'typeorm'
2
- import { Args, Arg, Ctx, Directive, Query, Resolver, UseMiddleware } from 'type-graphql'
2
+ import { Args, Arg, Ctx, Directive, Int, Query, Resolver, UseMiddleware } from 'type-graphql'
3
3
  import { GraphQLJSON } from 'graphql-scalars'
4
4
 
5
5
  import {
@@ -242,11 +242,13 @@ export class FigureQuery {
242
242
  @Directive('@privilege(category: "figure-published", privilege: "query", domainOwnerGranted: true)')
243
243
  @Query(returns => GraphQLJSON, {
244
244
  nullable: true,
245
- description: 'A released figure for its consumers (board, twin, things-scene): { type, asset } with the figure asset. Only returns released figures.'
245
+ description:
246
+ 'A released figure for its consumers (board, twin, things-scene): { type, version, asset }. The latest release, or the one numbered `version` for a consumer that pins releases (ADR-0104). Only returns released figures.'
246
247
  })
247
248
  async figureBlueprint(
248
249
  @Arg('type') type: string,
249
- @Ctx() context: ResolverContext
250
+ @Ctx() context: ResolverContext,
251
+ @Arg('version', type => Int, { nullable: true, description: 'A release number. Absent: the latest release.' }) pinned?: number
250
252
  ): Promise<any> {
251
253
  const { domain } = context.state
252
254
 
@@ -257,18 +259,32 @@ export class FigureQuery {
257
259
  architect ruling, 2026-09-17). This used to skip the fallback as soon as a child row existed and
258
260
  return null.
259
261
  */
260
- const found =
262
+ const latest =
261
263
  (await releasedSourceOf(domain.id, type)) ?? (domain.parentId ? await releasedSourceOf(domain.parentId, type) : null)
262
264
 
263
- if (!found) {
265
+ if (!latest) {
264
266
  return null
265
267
  }
266
268
 
269
+ /*
270
+ A pinned release is read from the same figure the latest comes from -- the shadowing rule decides whose
271
+ releases these are, then the number picks one. Releases are never deleted, so a number once handed out is
272
+ always there; a number that never was is refused by name rather than answered with another release.
273
+ */
274
+ const found = pinned === undefined || pinned === null || pinned === latest.version ? latest : await releaseNumbered(latest.figure, pinned)
275
+ if (!found) {
276
+ throw new Refusal(
277
+ 'figure-release-not-found',
278
+ { type, version: pinned, latest: latest.version },
279
+ '{type} has no release {version}. its latest release is {latest}.'
280
+ )
281
+ }
282
+
267
283
  const { figure, source: sourceToCompile, version } = found
268
284
 
269
285
  try {
270
286
  const text = typeof sourceToCompile === 'string' ? sourceToCompile : JSON.stringify(sourceToCompile)
271
- return { type, asset: consumablePayloadOf(text).asset }
287
+ return { type, version, asset: consumablePayloadOf(text).asset }
272
288
  } catch (err) {
273
289
  console.error(`[figureBlueprint] 타입 '${type}' (figure id: ${figure.id}) 자산 컴파일 실패:`, err)
274
290
 
@@ -290,6 +306,14 @@ export class FigureQuery {
290
306
  }
291
307
  }
292
308
 
309
+ /** One numbered release of a figure, or null when it has none by that number. */
310
+ async function releaseNumbered(figure: Figure, version: number): Promise<{ figure: Figure; source: string; version: number } | null> {
311
+ const row = await getRepository(FigureHistory).findOne({
312
+ where: { originalId: figure.id, state: 'released', version }
313
+ })
314
+ return row?.source ? { figure, source: row.source, version: row.version } : null
315
+ }
316
+
293
317
  /**
294
318
  * Finds the released source of a type in one domain, or null.
295
319
  *
@@ -0,0 +1,94 @@
1
+ /**
2
+ * A release keeps its format and what changed against the last one on its history row -- on sqlite, through the
3
+ * subscriber that writes history (ADR-0104 decision 5). `[]` and `null` stay apart: nothing changed is not "not compared".
4
+ */
5
+ import type { DataSource } from 'typeorm'
6
+
7
+ const { DataSource: TypeOrmDataSource } = require('typeorm')
8
+ const shell = require('@things-factory/shell')
9
+ const authBase = require('@things-factory/auth-base')
10
+ const { createFigureAsset, addFigureBox, serializeFigureAsset, applyFigureAuthoring, proposeFigureOccupancy } = require('@hatiolab/figure-model')
11
+
12
+ let dataSource: DataSource | null = null
13
+ let Figure: any
14
+ let FigureHistory: any
15
+ let Subscriber: any
16
+ let FigureMutation: any
17
+
18
+ function loadCompiled(modulePath: string): any {
19
+ const { existsSync } = require('fs')
20
+ const { join } = require('path')
21
+ const resolved = join(__dirname, '..', '..', '..', 'dist-server', `${modulePath}.js`)
22
+ if (!existsSync(resolved))
23
+ throw new Error(`figure-service 산출물이 없다: ${resolved}\n먼저 빌드할 것:\n yarn workspace @things-factory/figure-service build:server`)
24
+ return require(resolved)
25
+ }
26
+
27
+ beforeAll(async () => {
28
+ Figure = loadCompiled('service/figure/figure').Figure
29
+ FigureHistory = loadCompiled('service/figure/figure-history').FigureHistory
30
+ Subscriber = loadCompiled('service/figure/event-subscriber').FigureHistoryEntitySubscriber
31
+ FigureMutation = loadCompiled('service/figure/figure-mutation').FigureMutation
32
+ dataSource = new TypeOrmDataSource({
33
+ type: 'sqlite',
34
+ database: ':memory:',
35
+ synchronize: true,
36
+ dropSchema: true,
37
+ namingStrategy: new shell.NamingStrategy(),
38
+ entities: [...shell.entities, ...authBase.entities, Figure, FigureHistory],
39
+ subscribers: [Subscriber],
40
+ logging: false
41
+ })
42
+ await dataSource.initialize()
43
+ })
44
+
45
+ afterAll(async () => {
46
+ await dataSource?.destroy()
47
+ dataSource = null
48
+ })
49
+
50
+ const SOURCE = serializeFigureAsset(addFigureBox(createFigureAsset('Probe'), { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } }))
51
+
52
+ async function release(extra: Record<string, unknown>) {
53
+ const domain = await dataSource!.getRepository(shell.Domain).save({ name: `d-${Math.random()}`, subdomain: `d${Date.now()}${Math.random()}` })
54
+ const figure = await dataSource!.getRepository(Figure).save({ type: 'RECORD_PROBE', name: 'probe', source: SOURCE, state: 'draft', version: 0, domain })
55
+ await dataSource!.getRepository(Figure).save({ ...figure, domain, version: 1, state: 'released', __releaseComment: 'first', ...extra })
56
+ return dataSource!.getRepository(FigureHistory).findOneOrFail({ where: { originalId: figure.id } })
57
+ }
58
+
59
+ describe('a release record on its history row', () => {
60
+ it('keeps the format and the changes as they were', async () => {
61
+ const changes = [{ code: 'occupancy-changed', blocking: false, values: { before: 'x -50…50', after: 'x -150…150' }, detail: 'declared room changed' }]
62
+ const row = await release({ __releaseFormat: 'figure-asset-1', __releaseChanges: changes })
63
+ expect(row.assetFormat).toBe('figure-asset-1')
64
+ expect(JSON.parse(row.changes)).toEqual(changes)
65
+ expect(row.comment).toBe('first')
66
+ })
67
+ it('[] stays nothing changed, null stays not compared', async () => {
68
+ expect((await release({ __releaseFormat: 'figure-asset-1', __releaseChanges: [] })).changes).toBe('[]')
69
+ expect((await release({ __releaseFormat: 'figure-asset-1', __releaseChanges: null })).changes).toBeNull()
70
+ })
71
+ })
72
+
73
+ /* The whole door, called as the resolver is: releaseFigure works the record out and the row carries it. */
74
+ describe('releaseFigure records it', () => {
75
+ const releasable = (width: number) => {
76
+ let a = addFigureBox(createFigureAsset('Mixer'), { id: 'body', size: { x: width, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
77
+ a = applyFigureAuthoring(a, { kind: 'attach', part: 'body', face: 'bottom', to: { plane: 'mounting-plane' } })
78
+ return serializeFigureAsset(applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(a, { over: 'range' }), placement: 'floor' }))
79
+ }
80
+ it('first release: [], the second: the wider room', async () => {
81
+ const domain = await dataSource!.getRepository(shell.Domain).save({ name: 'release-door', subdomain: 'release-door' })
82
+ const tx = dataSource!.manager
83
+ const context = { state: { domain, user: undefined, tx } }
84
+ const figure = await dataSource!.getRepository(Figure).save({ type: 'RELEASE_DOOR', name: 'door', source: releasable(100), state: 'draft', version: 0, domain })
85
+ await new FigureMutation().releaseFigure(figure.id, 'one', context)
86
+ await dataSource!.getRepository(Figure).save({ ...(await dataSource!.getRepository(Figure).findOneOrFail({ where: { id: figure.id } })), domain, source: releasable(300), state: 'draft' })
87
+ await new FigureMutation().releaseFigure(figure.id, 'two', context)
88
+ const rows = await dataSource!.getRepository(FigureHistory).find({ where: { originalId: figure.id }, order: { version: 'ASC' } })
89
+ expect(rows.map((r: any) => [r.version, r.assetFormat, r.comment])).toEqual([[1, 'figure-asset-1', 'one'], [2, 'figure-asset-1', 'two']])
90
+ expect(rows[0].changes).toBe('[]')
91
+ const codes = JSON.parse(rows[1].changes).map((c: any) => c.code)
92
+ expect(codes).toContain('occupancy-changed')
93
+ })
94
+ })
@@ -64,5 +64,16 @@
64
64
  "figure.gate.contract.capability-gone.message": "The capability '{name}' is gone.",
65
65
  "figure.gate.contract.capability-gone.why": "Scenes attach behaviour by this name. Placed instances lose what it did.",
66
66
  "figure.gate.contract.capability-added.message": "The capability '{name}' is new.",
67
- "figure.gate.contract.capability-added.why": "It adds what the figure can do. Placed instances stand as they are."
67
+ "figure.gate.contract.capability-added.why": "It adds what the figure can do. Placed instances stand as they are.",
68
+ "figure.gate.contract.occupancy-changed.message": "The room the figure takes changed: {before} → {after}.",
69
+ "figure.gate.contract.occupancy-changed.why": "A consumer places the figure by this room. One that moves to this release sees it take the new room.",
70
+ "figure.gate.contract.contact-face-changed.message": "The face the figure is mounted by changed.",
71
+ "figure.gate.contract.contact-face-changed.why": "A consumer that moves to this release sees it stand on the new face.",
72
+ "figure.gate.contract.public-input-gone.message": "'{input}' is no longer set on a board.",
73
+ "figure.gate.contract.public-input-gone.why": "Values placed instances give it do nothing after this release.",
74
+ "figure.gate.contract.public-input-range-narrowed.message": "The range of '{input}' narrowed: {before} → {after}.",
75
+ "figure.gate.contract.public-input-range-narrowed.why": "An instance may have set a value outside the new range. Which values are placed is not recorded.",
76
+ "figure.gate.contract.public-input-added.message": "'{input}' can now be set on a board.",
77
+ "figure.gate.contract.public-input-added.why": "Placed instances that give no value stand at its default.",
78
+ "error.figure-release-not-found": "{type} has no release {version}. its latest release is {latest}."
68
79
  }
@@ -64,5 +64,16 @@
64
64
  "figure.gate.contract.capability-gone.message": "'{name}' 능력이 없어졌습니다.",
65
65
  "figure.gate.contract.capability-gone.why": "씬이 이 이름으로 동작을 붙입니다. 놓인 인스턴스가 그 동작을 잃습니다.",
66
66
  "figure.gate.contract.capability-added.message": "'{name}' 능력이 새로 생겼습니다.",
67
- "figure.gate.contract.capability-added.why": "도형이 할 수 있는 일이 늘어납니다. 놓인 인스턴스는 그대로 서 있습니다."
67
+ "figure.gate.contract.capability-added.why": "도형이 할 수 있는 일이 늘어납니다. 놓인 인스턴스는 그대로 서 있습니다.",
68
+ "figure.gate.contract.occupancy-changed.message": "도형이 차지하는 공간이 바뀌었습니다: {before} → {after}.",
69
+ "figure.gate.contract.occupancy-changed.why": "받는 쪽은 이 공간으로 도형을 놓습니다. 이 발행본으로 옮기면 새 공간으로 놓입니다.",
70
+ "figure.gate.contract.contact-face-changed.message": "도형이 붙는 면이 바뀌었습니다.",
71
+ "figure.gate.contract.contact-face-changed.why": "이 발행본으로 옮기면 새 면으로 섭니다.",
72
+ "figure.gate.contract.public-input-gone.message": "'{input}' 값은 이제 보드에서 정할 수 없습니다.",
73
+ "figure.gate.contract.public-input-gone.why": "놓인 인스턴스가 준 값은 이 발행본부터 아무 일도 하지 않습니다.",
74
+ "figure.gate.contract.public-input-range-narrowed.message": "'{input}' 값의 범위가 좁아졌습니다: {before} → {after}.",
75
+ "figure.gate.contract.public-input-range-narrowed.why": "새 범위 밖의 값을 준 인스턴스가 있을 수 있습니다. 어떤 값이 놓였는지는 기록하지 않습니다.",
76
+ "figure.gate.contract.public-input-added.message": "'{input}' 값을 이제 보드에서 정할 수 있습니다.",
77
+ "figure.gate.contract.public-input-added.why": "값을 주지 않은 인스턴스는 기본값으로 섭니다.",
78
+ "error.figure-release-not-found": "{type} 에는 {version} 번 발행판이 없습니다. 최신 발행판은 {latest} 번입니다."
68
79
  }