@things-factory/figure-service 10.1.114 → 10.1.116

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-service",
3
- "version": "10.1.114",
3
+ "version": "10.1.116",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -25,16 +25,16 @@
25
25
  "test:templates": "node --test scripts/templates.test.mts"
26
26
  },
27
27
  "dependencies": {
28
- "@hatiolab/figure-model": "^0.2.12",
28
+ "@hatiolab/figure-model": "^0.3.2",
29
29
  "@operato/typeorm-history": "^10.0.0",
30
30
  "@things-factory/ai-client-base": "^10.1.114",
31
- "@things-factory/attachment-base": "^10.1.113",
32
- "@things-factory/auth-base": "^10.1.113",
31
+ "@things-factory/attachment-base": "^10.1.116",
32
+ "@things-factory/auth-base": "^10.1.116",
33
33
  "@things-factory/env": "^10.1.20",
34
34
  "@things-factory/shell": "^10.1.106",
35
35
  "graphql-scalars": "^1.22.4",
36
36
  "graphql-upload": "^15.0.2",
37
37
  "koa-router": "^7.4.0"
38
38
  },
39
- "gitHead": "0d8f9bc0ff0cb50431f2c76cb166aaf3809d3cf7"
39
+ "gitHead": "49fa935cc703be70200eef4d29f473de98f871ce"
40
40
  }
@@ -14,7 +14,7 @@ import { FigureHistory } from './figure-history.js'
14
14
  *
15
15
  * 발행 메모는 Figure 의 칸이 아니다. `releaseFigure` 가 저장할 엔티티에 `__releaseComment` 로 얹어
16
16
  * 두면 여기서 판본의 `comment` 로 옮긴다 — 메모는 그 판의 것이지 도형의 것이 아니다. 정본 형식
17
- * (`__releaseFormat`), 직전 판에 견준 변경(`__releaseChanges`), 발행 때 잰 값(`__releaseMetrics`)과
17
+ * (`__releaseFormat`), 잰 커널의 판(`__releaseKernel`, ADR-0109 C1), 직전 판에 견준 변경(`__releaseChanges`), 발행 때 잰 값(`__releaseMetrics`)과
18
18
  * 윗모습(`__releaseTopView`)도 같은 길로 옮긴다(ADR-0104).
19
19
  */
20
20
  /** What `releaseFigure` laid on the entity for this release, moved onto the history row. */
@@ -22,6 +22,7 @@ function releaseRecordOnto(history: FigureHistory, entity: Figure): FigureHistor
22
22
  const e = entity as any
23
23
  ;(history as any).comment = e.__releaseComment ?? null
24
24
  history.assetFormat = e.__releaseFormat ?? null
25
+ history.kernelVersion = e.__releaseKernel ?? null
25
26
  /* [] stays "[]" (nothing changed); null stays null (not compared). */
26
27
  history.changes = e.__releaseChanges == null ? null : JSON.stringify(e.__releaseChanges)
27
28
  history.metrics = e.__releaseMetrics == null ? null : JSON.stringify(e.__releaseMetrics)
@@ -350,7 +350,7 @@ describe('figure-format — 판을 알아보는 문', () => {
350
350
  it('every new change has its sentence in both languages', () => {
351
351
  const KO = require('../../../translations/ko.json') as Record<string, string>
352
352
  const EN = require('../../../translations/en.json') as Record<string, string>
353
- for (const code of ['occupancy-changed', 'contact-face-changed', 'public-input-gone', 'public-input-range-narrowed', 'public-input-added'])
353
+ for (const code of ['occupancy-changed', 'contact-face-changed', 'public-input-gone', 'public-input-range-narrowed', 'public-input-added', 'state-input-joints-changed', 'joint-axis-changed', 'joint-motion-unit-changed', 'joint-child-changed', 'joint-list-unread'])
354
354
  for (const dict of [KO, EN]) for (const field of ['message', 'why']) expect(dict[`figure.gate.contract.${code}.${field}`]).toBeTruthy()
355
355
  })
356
356
  })
@@ -3,7 +3,7 @@
3
3
  * board (`consumablePayloadOf`). The format is `figure-asset-1`, and the asset says so in its `version`; a source
4
4
  * that does not is refused by name, because a source the server cannot identify is one it must not save.
5
5
  */
6
- import { compileFigureAsset, inspectFigureAsset, isPlaceholderType, parseFigureAsset, serializeFigureAsset, figureContractChanges, figureCostOf, figureOriginCentredOf, figureReleaseMetricsOf, figureScoreOf, figureTopViewOf } from '@hatiolab/figure-model'
6
+ import { FIGURE_KERNEL_VERSION, compileFigureAsset, inspectFigureAsset, isPlaceholderType, parseFigureAsset, serializeFigureAsset, figureContractChanges, figureCostOf, figureOriginCentredOf, figureReleaseMetricsOf, figureScoreOf, figureTopViewOf } from '@hatiolab/figure-model'
7
7
  import type { FigureAsset, FigureContractChange, FigureContractError, FigureReleaseMetrics, FigureTopView, FigureViolation } from '@hatiolab/figure-model'
8
8
 
9
9
  import type { FigureFinding } from './figure-type.js'
@@ -461,6 +461,37 @@ function figureContractWording(change: FigureContractChange, say: Say): Pick<Gat
461
461
  message: say(key('message'), { ...v, input: change.subject ?? '' }, `'${change.subject}' can now be set on a board.`),
462
462
  why: say(key('why'), {}, 'Placed instances that give no value stand at its default.')
463
463
  }
464
+ /* The joints a controller drives (ADR-0107 decision 7): what writing a state input moves, compared release to release. */
465
+ case 'state-input-joints-changed': {
466
+ const after = v.after || say('figure.gate.contract.joints-none', {}, 'nothing')
467
+ return {
468
+ message: say(key('message'), { ...v, after }, `The control '${v.id}' moved ${v.before}. It now moves ${after}.`),
469
+ why: say(key('why'), {}, 'A controller writes this value to drive those joints. After this release the same value moves other joints, or by other amounts.'),
470
+ ...gone
471
+ }
472
+ }
473
+ case 'joint-axis-changed':
474
+ return {
475
+ message: say(key('message'), v, `The axis of the joint '${v.joint}' changed from ${v.before} to ${v.after}.`),
476
+ why: say(key('why'), {}, "A controller's stored values turn or slide the joint about this axis. After this release the same value moves it another way."),
477
+ ...gone
478
+ }
479
+ case 'joint-motion-unit-changed':
480
+ return {
481
+ message: say(key('message'), v, `The joint '${v.joint}' now moves in ${v.after}, not ${v.before}.`),
482
+ why: say(key('why'), {}, "A controller's stored values were written for the old unit. After this release the same value means another motion."),
483
+ ...gone
484
+ }
485
+ case 'joint-child-changed':
486
+ return {
487
+ message: say(key('message'), v, `The joint '${v.joint}' now moves '${v.after}' instead of '${v.before}'.`),
488
+ why: say(key('why'), {}, "A controller's values keep their meaning. On screen the joint now moves the new part.")
489
+ }
490
+ case 'joint-list-unread':
491
+ return {
492
+ message: say(key('message'), v, `The last release's joint list could not be read, so its joints were not compared: ${v.reason}`),
493
+ why: say(key('why'), {}, "Whether this release changes what a controller's values move has not been checked. The last release was made by a kernel that did not measure joints.")
494
+ }
464
495
  }
465
496
  }
466
497
 
@@ -473,9 +504,9 @@ function figureContractWording(change: FigureContractChange, say: Say): Pick<Gat
473
504
  export function releaseRecordOf(
474
505
  sourceJson: string,
475
506
  releasedJson?: string | null
476
- ): { format: string; changes: FigureContractChange[] | null; metrics: FigureReleaseMetrics; topView: FigureTopView } {
507
+ ): { format: string; kernel: string; changes: FigureContractChange[] | null; metrics: FigureReleaseMetrics; topView: FigureTopView } {
477
508
  const asset = parseFigureAsset(sourceJson)
478
- const measured = { format: asset.version, metrics: figureReleaseMetricsOf(asset), topView: figureTopViewOf(asset, null) }
509
+ const measured = { format: asset.version, kernel: FIGURE_KERNEL_VERSION, metrics: figureReleaseMetricsOf(asset), topView: figureTopViewOf(asset, null) }
479
510
  if (!releasedJson) return { ...measured, changes: [] }
480
511
  let before: FigureAsset
481
512
  try {
@@ -124,11 +124,24 @@ export class FigureHistory implements HistoryEntityInterface<Figure> {
124
124
  })
125
125
  changes?: string
126
126
 
127
+ /*
128
+ * 이 판을 잰 커널의 판(figure-model `FIGURE_KERNEL_VERSION`). 점수 · 묶음 · metrics 는 그 커널의 규칙으로 센 값이다 —
129
+ * 규칙이 바뀌어도 발행된 값을 다시 세서 덮지 않고(ADR-0109 C1), 읽는 쪽은 이 칸으로 「남긴 값」과 「지금 규칙의 값」을
130
+ * 가른다. 이 칸이 생기기 전에 발행된 판은 비어 있다.
131
+ */
132
+ @Column({ nullable: true })
133
+ @Field({
134
+ nullable: true,
135
+ description:
136
+ 'The figure-model kernel version that measured this release (score, groups, metrics). null for releases made before releases recorded it.'
137
+ })
138
+ kernelVersion?: string
139
+
127
140
  @Column({ nullable: true, ...longTextColumn() })
128
141
  @Field({
129
142
  nullable: true,
130
143
  description:
131
- 'What this version measured once, as JSON (figure-model figureReleaseMetricsOf): { atLargest?: { size, triangles, groups }, linear?: { scalable, parts, probe } }. Only for a figure that takes its size from the instance; {} otherwise.'
144
+ 'What this version measured once, as JSON (figure-model figureReleaseMetricsOf): { atLargest?: { size, triangles, groups }, linear?: { scalable, parts, probe }, origin?: { centred, off }, joints?: { schema, derivation, joints, unlisted } }. atLargest and linear only for a figure that takes its size from the instance. joints is the joint list a controller reads (schema 2, figure-model figureJointsOf), present when anything moves.'
132
145
  })
133
146
  metrics?: string
134
147
 
@@ -348,9 +348,10 @@ export class FigureMutation {
348
348
  version: nextVersion,
349
349
  state: 'released',
350
350
  updater: user,
351
- /* 판본 구독자가 이것들을 판본의 comment · assetFormat · changes 로 옮긴다. Figure 의 칸이 아니다. */
351
+ /* 판본 구독자가 이것들을 판본의 comment · assetFormat · kernelVersion · changes 로 옮긴다. Figure 의 칸이 아니다. */
352
352
  __releaseComment: comment ?? null,
353
353
  __releaseFormat: record.format,
354
+ __releaseKernel: record.kernel,
354
355
  __releaseChanges: record.changes,
355
356
  __releaseMetrics: record.metrics,
356
357
  __releaseTopView: record.topView
@@ -7,13 +7,14 @@ import type { DataSource } from 'typeorm'
7
7
  const { DataSource: TypeOrmDataSource } = require('typeorm')
8
8
  const shell = require('@things-factory/shell')
9
9
  const authBase = require('@things-factory/auth-base')
10
- const { createFigureAsset, addFigureBox, serializeFigureAsset, applyFigureAuthoring, proposeFigureOccupancy } = require('@hatiolab/figure-model')
10
+ const { createFigureAsset, addFigureBox, serializeFigureAsset, applyFigureAuthoring, proposeFigureOccupancy, palletizerRobotFigure, FIGURE_KERNEL_VERSION } = require('@hatiolab/figure-model')
11
11
 
12
12
  let dataSource: DataSource | null = null
13
13
  let Figure: any
14
14
  let FigureHistory: any
15
15
  let Subscriber: any
16
16
  let FigureMutation: any
17
+ let releaseRecordOf: any
17
18
 
18
19
  function loadCompiled(modulePath: string): any {
19
20
  const { existsSync } = require('fs')
@@ -29,6 +30,7 @@ beforeAll(async () => {
29
30
  FigureHistory = loadCompiled('service/figure/figure-history').FigureHistory
30
31
  Subscriber = loadCompiled('service/figure/event-subscriber').FigureHistoryEntitySubscriber
31
32
  FigureMutation = loadCompiled('service/figure/figure-mutation').FigureMutation
33
+ releaseRecordOf = loadCompiled('service/figure/figure-format').releaseRecordOf
32
34
  dataSource = new TypeOrmDataSource({
33
35
  type: 'sqlite',
34
36
  database: ':memory:',
@@ -65,6 +67,8 @@ describe('a release record on its history row', () => {
65
67
  expect(row.comment).toBe('first')
66
68
  expect(row.metrics).toBeNull()
67
69
  expect(row.topView).toBeNull()
70
+ /* A row the release did not stamp says so: not measured by a known kernel, rather than by today's. */
71
+ expect(row.kernelVersion).toBeNull()
68
72
  })
69
73
  it('[] stays nothing changed, null stays not compared', async () => {
70
74
  expect((await release({ __releaseFormat: 'figure-asset-1', __releaseChanges: [] })).changes).toBe('[]')
@@ -90,6 +94,8 @@ describe('releaseFigure records it', () => {
90
94
  const rows = await dataSource!.getRepository(FigureHistory).find({ where: { originalId: figure.id }, order: { version: 'ASC' } })
91
95
  expect(rows.map((r: any) => [r.version, r.assetFormat, r.comment])).toEqual([[1, 'figure-asset-1', 'one'], [2, 'figure-asset-1', 'two']])
92
96
  expect(rows[0].changes).toBe('[]')
97
+ /* Each release names the kernel that measured it (ADR-0109 C1). */
98
+ expect(rows.map((r: any) => r.kernelVersion)).toEqual([FIGURE_KERNEL_VERSION, FIGURE_KERNEL_VERSION])
93
99
  const codes = JSON.parse(rows[1].changes).map((c: any) => c.code)
94
100
  expect(codes).toContain('occupancy-changed')
95
101
  /* What each release measured, and its plan from above, are on its own row. */
@@ -97,3 +103,41 @@ describe('releaseFigure records it', () => {
97
103
  expect(JSON.parse(rows[1].topView).shapes.map((s: any) => s.id)).toEqual(['body'])
98
104
  })
99
105
  })
106
+
107
+ /*
108
+ The joint list a controller reads rides the release's metrics (ADR-0107 decision 7): measured once, at release, and
109
+ read back from the history row. A re-release that turns a joint about another axis is refused with the sentence.
110
+ */
111
+ describe('a release of a figure that moves keeps its joint list', () => {
112
+ const palletizer = (j4Axis?: string) => {
113
+ const { asset } = palletizerRobotFigure()
114
+ if (!j4Axis) return serializeFigureAsset(asset)
115
+ const j4 = asset.joints.find((j: any) => j.id === 'j4')
116
+ return serializeFigureAsset(applyFigureAuthoring(asset, { kind: 'set-joint', joint: { ...j4, axis: j4Axis } }))
117
+ }
118
+ it('schema 2, the wrist following -(j2 + j3), controls with both units; another axis is refused', async () => {
119
+ const domain = await dataSource!.getRepository(shell.Domain).save({ name: 'release-joints', subdomain: 'release-joints' })
120
+ const context = { state: { domain, user: undefined, tx: dataSource!.manager } }
121
+ const figure = await dataSource!.getRepository(Figure).save({ type: 'PALLETIZER', name: 'palletizer', source: palletizer(), state: 'draft', version: 0, domain })
122
+ await new FigureMutation().releaseFigure(figure.id, 'one', context)
123
+
124
+ const [row] = await dataSource!.getRepository(FigureHistory).find({ where: { originalId: figure.id } })
125
+ const list = JSON.parse(row.metrics).joints
126
+ expect(list.schema).toBe(2)
127
+ const byId = new Map(list.joints.map((j: any) => [j.id, j]))
128
+ expect(byId.get('wrist')).toMatchObject({
129
+ type: 'revolute',
130
+ follows: { sum: [{ joint: 'j2', multiplier: -1 }, { joint: 'j3', multiplier: -1 }], offset: 0 }
131
+ })
132
+ expect((byId.get('wrist') as any).control).toBeUndefined()
133
+ for (const id of ['j1', 'j2', 'j3', 'j4']) expect((byId.get(id) as any).control).toMatchObject({ inputUnit: 'deg', motionUnit: 'deg' })
134
+ expect(list.unlisted).toEqual([])
135
+
136
+ /* The same figure with J4 turning about X: the kernel's comparison blocks it, and the door refuses with its sentence. */
137
+ const changes = releaseRecordOf(palletizer('X'), row.source).changes
138
+ expect(changes).toContainEqual(expect.objectContaining({ code: 'joint-axis-changed', blocking: true, subject: 'j4' }))
139
+ await dataSource!.getRepository(Figure).save({ ...(await dataSource!.getRepository(Figure).findOneOrFail({ where: { id: figure.id } })), domain, source: palletizer('X'), state: 'draft' })
140
+ await expect(new FigureMutation().releaseFigure(figure.id, 'two', context)).rejects.toThrow(/The axis of the joint 'j4' changed/)
141
+ expect(await dataSource!.getRepository(FigureHistory).count({ where: { originalId: figure.id } })).toBe(1)
142
+ })
143
+ })
@@ -75,6 +75,17 @@
75
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
76
  "figure.gate.contract.public-input-added.message": "'{input}' can now be set on a board.",
77
77
  "figure.gate.contract.public-input-added.why": "Placed instances that give no value stand at its default.",
78
+ "figure.gate.contract.joints-none": "nothing",
79
+ "figure.gate.contract.state-input-joints-changed.message": "The control '{id}' moved {before}. It now moves {after}.",
80
+ "figure.gate.contract.state-input-joints-changed.why": "A controller writes this value to drive those joints. After this release the same value moves other joints, or by other amounts.",
81
+ "figure.gate.contract.joint-axis-changed.message": "The axis of the joint '{joint}' changed from {before} to {after}.",
82
+ "figure.gate.contract.joint-axis-changed.why": "A controller's stored values turn or slide the joint about this axis. After this release the same value moves it another way.",
83
+ "figure.gate.contract.joint-motion-unit-changed.message": "The joint '{joint}' now moves in {after}, not {before}.",
84
+ "figure.gate.contract.joint-motion-unit-changed.why": "A controller's stored values were written for the old unit. After this release the same value means another motion.",
85
+ "figure.gate.contract.joint-child-changed.message": "The joint '{joint}' now moves '{after}' instead of '{before}'.",
86
+ "figure.gate.contract.joint-child-changed.why": "A controller's values keep their meaning. On screen the joint now moves the new part.",
87
+ "figure.gate.contract.joint-list-unread.message": "The last release's joint list could not be read, so its joints were not compared: {reason}",
88
+ "figure.gate.contract.joint-list-unread.why": "Whether this release changes what a controller's values move has not been checked. The last release was made by a kernel that did not measure joints.",
78
89
  "error.figure-release-not-found": "{type} has no release {version}. its latest release is {latest}.",
79
90
  "figure.gate.origin-off-centre.message": "The origin is off the middle of the declared room: {off}.",
80
91
  "figure.gate.origin-off-centre.why": "A drawing that places figures by the middle of their room puts this one that far from where it is drawn.",
@@ -75,6 +75,17 @@
75
75
  "figure.gate.contract.public-input-range-narrowed.why": "새 범위 밖의 값을 준 인스턴스가 있을 수 있습니다. 어떤 값이 놓였는지는 기록하지 않습니다.",
76
76
  "figure.gate.contract.public-input-added.message": "'{input}' 값을 이제 보드에서 정할 수 있습니다.",
77
77
  "figure.gate.contract.public-input-added.why": "값을 주지 않은 인스턴스는 기본값으로 섭니다.",
78
+ "figure.gate.contract.joints-none": "없음",
79
+ "figure.gate.contract.state-input-joints-changed.message": "'{id}' 조작값이 움직이던 관절이 {before} 에서 {after} 로 바뀌었습니다.",
80
+ "figure.gate.contract.state-input-joints-changed.why": "컨트롤러는 이 값을 써서 그 관절들을 움직입니다. 이 발행 뒤에는 같은 값이 다른 관절을 움직이거나 다른 양만큼 움직입니다.",
81
+ "figure.gate.contract.joint-axis-changed.message": "'{joint}' 관절의 축이 {before} 에서 {after} 로 바뀌었습니다.",
82
+ "figure.gate.contract.joint-axis-changed.why": "컨트롤러가 저장한 값은 이 축을 기준으로 관절을 회전하거나 이동합니다. 이 발행 뒤에는 같은 값이 다른 방향으로 움직입니다.",
83
+ "figure.gate.contract.joint-motion-unit-changed.message": "'{joint}' 관절의 움직임 단위가 {before} 에서 {after} 로 바뀌었습니다.",
84
+ "figure.gate.contract.joint-motion-unit-changed.why": "컨트롤러가 저장한 값은 옛 단위에 맞춰 쓰였습니다. 이 발행 뒤에는 같은 값이 다른 움직임이 됩니다.",
85
+ "figure.gate.contract.joint-child-changed.message": "'{joint}' 관절이 '{before}' 대신 '{after}' 부품을 움직입니다.",
86
+ "figure.gate.contract.joint-child-changed.why": "컨트롤러의 값은 뜻이 그대로입니다. 화면에서는 그 관절이 새 부품을 움직입니다.",
87
+ "figure.gate.contract.joint-list-unread.message": "직전 발행본의 관절 목록을 읽지 못해 관절을 비교하지 않았습니다: {reason}",
88
+ "figure.gate.contract.joint-list-unread.why": "이 발행이 컨트롤러의 값이 움직이는 것을 바꾸는지는 확인하지 않았습니다. 직전 발행본은 관절을 재지 않던 커널로 발행되었습니다.",
78
89
  "error.figure-release-not-found": "{type} 에는 {version} 번 발행판이 없습니다. 최신 발행판은 {latest} 번입니다.",
79
90
  "figure.gate.origin-off-centre.message": "원점이 차지 공간 가운데에서 비켜 있습니다: {off}.",
80
91
  "figure.gate.origin-off-centre.why": "공간 가운데로 도형을 놓는 도면에서는 그린 자리에서 그만큼 떨어져 놓입니다.",