@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.
- package/dist-server/service/figure/event-subscriber.d.ts +0 -10
- package/dist-server/service/figure/event-subscriber.js +13 -7
- package/dist-server/service/figure/event-subscriber.js.map +1 -1
- package/dist-server/service/figure/figure-format.d.ts +11 -1
- package/dist-server/service/figure/figure-format.js +46 -0
- package/dist-server/service/figure/figure-format.js.map +1 -1
- package/dist-server/service/figure/figure-history.d.ts +2 -0
- package/dist-server/service/figure/figure-history.js +13 -0
- package/dist-server/service/figure/figure-history.js.map +1 -1
- package/dist-server/service/figure/figure-mutation.js +6 -2
- package/dist-server/service/figure/figure-mutation.js.map +1 -1
- package/dist-server/service/figure/figure-query.d.ts +1 -1
- package/dist-server/service/figure/figure-query.js +23 -6
- package/dist-server/service/figure/figure-query.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/server/service/figure/event-subscriber.ts +14 -5
- package/server/service/figure/figure-blueprint-query.test.ts +38 -0
- package/server/service/figure/figure-format.test.ts +30 -1
- package/server/service/figure/figure-format.ts +44 -0
- package/server/service/figure/figure-history.ts +16 -0
- package/server/service/figure/figure-mutation.ts +7 -3
- package/server/service/figure/figure-query.ts +30 -6
- package/server/service/figure/figure-release-record-db.test.ts +94 -0
- package/translations/en.json +12 -1
- 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.
|
|
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.
|
|
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": "
|
|
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
|
-
|
|
30
|
-
return history
|
|
40
|
+
return releaseRecordOnto(history, entity)
|
|
31
41
|
}
|
|
32
42
|
|
|
33
43
|
public beforeUpdateHistory(history: FigureHistory, entity: Figure): FigureHistory {
|
|
34
|
-
|
|
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
|
-
/* 판본 구독자가
|
|
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:
|
|
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
|
|
262
|
+
const latest =
|
|
261
263
|
(await releasedSourceOf(domain.id, type)) ?? (domain.parentId ? await releasedSourceOf(domain.parentId, type) : null)
|
|
262
264
|
|
|
263
|
-
if (!
|
|
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
|
+
})
|
package/translations/en.json
CHANGED
|
@@ -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
|
}
|
package/translations/ko.json
CHANGED
|
@@ -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
|
}
|