@things-factory/headless-twin 10.0.5 → 10.0.7
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/README.md +37 -24
- package/dist-server/engine/entity-delta.d.ts +13 -1
- package/dist-server/engine/entity-delta.js +130 -16
- package/dist-server/engine/entity-delta.js.map +1 -1
- package/dist-server/engine/index.d.ts +4 -0
- package/dist-server/engine/index.js +4 -0
- package/dist-server/engine/index.js.map +1 -1
- package/dist-server/engine/kpi-fold.d.ts +20 -3
- package/dist-server/engine/kpi-fold.js +45 -53
- package/dist-server/engine/kpi-fold.js.map +1 -1
- package/dist-server/engine/kpi-query.d.ts +16 -0
- package/dist-server/engine/kpi-query.js +129 -19
- package/dist-server/engine/kpi-query.js.map +1 -1
- package/dist-server/engine/kpi-target.d.ts +141 -0
- package/dist-server/engine/kpi-target.js +137 -0
- package/dist-server/engine/kpi-target.js.map +1 -0
- package/dist-server/engine/live-attentions.d.ts +4 -2
- package/dist-server/engine/live-attentions.js +5 -1
- package/dist-server/engine/live-attentions.js.map +1 -1
- package/dist-server/engine/measured-estimator.d.ts +50 -0
- package/dist-server/engine/measured-estimator.js +78 -0
- package/dist-server/engine/measured-estimator.js.map +1 -0
- package/dist-server/engine/model-basis.d.ts +23 -0
- package/dist-server/engine/model-basis.js +100 -0
- package/dist-server/engine/model-basis.js.map +1 -0
- package/dist-server/engine/oee-accumulator.d.ts +2 -2
- package/dist-server/engine/oee-accumulator.js +4 -4
- package/dist-server/engine/oee-accumulator.js.map +1 -1
- package/dist-server/engine/spec-coverage.d.ts +49 -0
- package/dist-server/engine/spec-coverage.js +60 -0
- package/dist-server/engine/spec-coverage.js.map +1 -0
- package/dist-server/engine/structure-diff.d.ts +25 -0
- package/dist-server/engine/structure-diff.js +63 -0
- package/dist-server/engine/structure-diff.js.map +1 -0
- package/dist-server/engine/travel-estimator.d.ts +57 -0
- package/dist-server/engine/travel-estimator.js +120 -0
- package/dist-server/engine/travel-estimator.js.map +1 -0
- package/dist-server/engine/twin-engine.d.ts +181 -18
- package/dist-server/engine/twin-engine.js +545 -96
- package/dist-server/engine/twin-engine.js.map +1 -1
- package/dist-server/engine/warm-start.d.ts +39 -0
- package/dist-server/engine/warm-start.js +37 -0
- package/dist-server/engine/warm-start.js.map +1 -0
- package/dist-server/index.js +8 -0
- package/dist-server/index.js.map +1 -1
- package/dist-server/service/index.d.ts +4 -2
- package/dist-server/service/index.js +21 -14
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/reference/reference-live.js +2 -1
- package/dist-server/service/reference/reference-live.js.map +1 -1
- package/dist-server/service/reference/reference-master.d.ts +307 -1
- package/dist-server/service/reference/reference-master.js +96 -8
- package/dist-server/service/reference/reference-master.js.map +1 -1
- package/dist-server/service/reference/reference-resolver.js +3 -3
- package/dist-server/service/reference/reference-resolver.js.map +1 -1
- package/dist-server/service/reference/template-registry.d.ts +1 -1
- package/dist-server/service/reference/template-registry.js.map +1 -1
- package/dist-server/service/twin-attention/twin-attention-query.js +1 -1
- package/dist-server/service/twin-attention/twin-attention-query.js.map +1 -1
- package/dist-server/service/twin-control/twin-control-mutation.js +1 -1
- package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
- package/dist-server/service/twin-event/backfill-keys.d.ts +11 -0
- package/dist-server/service/twin-event/backfill-keys.js +63 -0
- package/dist-server/service/twin-event/backfill-keys.js.map +1 -0
- package/dist-server/service/twin-event/twin-event-keys.d.ts +35 -0
- package/dist-server/service/twin-event/twin-event-keys.js +95 -0
- package/dist-server/service/twin-event/twin-event-keys.js.map +1 -0
- package/dist-server/service/twin-event/twin-event-type.d.ts +6 -0
- package/dist-server/service/twin-event/twin-event-type.js +32 -0
- package/dist-server/service/twin-event/twin-event-type.js.map +1 -0
- package/dist-server/service/twin-event/twin-event.d.ts +16 -0
- package/dist-server/service/twin-event/twin-event.js +50 -0
- package/dist-server/service/twin-event/twin-event.js.map +1 -1
- package/dist-server/service/twin-forecast/twin-forecast-query.js +50 -7
- package/dist-server/service/twin-forecast/twin-forecast-query.js.map +1 -1
- package/dist-server/service/twin-instance/twin-instance.js +1 -1
- package/dist-server/service/twin-instance/twin-instance.js.map +1 -1
- package/dist-server/service/twin-journal/twin-journal-query.d.ts +28 -0
- package/dist-server/service/twin-journal/twin-journal-query.js +127 -2
- package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -1
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.d.ts +1 -1
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js +4 -3
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
- package/dist-server/service/twin-space/twin-space-area.js +1 -1
- package/dist-server/service/twin-space/twin-space-area.js.map +1 -1
- package/dist-server/service/twin-space/twin-space-resolver.js +3 -3
- package/dist-server/service/twin-space/twin-space-resolver.js.map +1 -1
- package/dist-server/service/twin-space/twin-space.d.ts +11 -0
- package/dist-server/service/twin-space/twin-space.js +5 -0
- package/dist-server/service/twin-space/twin-space.js.map +1 -1
- package/dist-server/service/twin-structure/index.d.ts +2 -0
- package/dist-server/service/twin-structure/index.js +6 -0
- package/dist-server/service/twin-structure/index.js.map +1 -0
- package/dist-server/service/twin-structure/twin-structure.d.ts +28 -0
- package/dist-server/service/twin-structure/twin-structure.js +97 -0
- package/dist-server/service/twin-structure/twin-structure.js.map +1 -0
- package/dist-server/service/twin-target/index.d.ts +4 -0
- package/dist-server/service/twin-target/index.js +8 -0
- package/dist-server/service/twin-target/index.js.map +1 -0
- package/dist-server/service/twin-target/twin-target-resolver.d.ts +7 -0
- package/dist-server/service/twin-target/twin-target-resolver.js +143 -0
- package/dist-server/service/twin-target/twin-target-resolver.js.map +1 -0
- package/dist-server/service/twin-target/twin-target.d.ts +25 -0
- package/dist-server/service/twin-target/twin-target.js +112 -0
- package/dist-server/service/twin-target/twin-target.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -6
- package/server/engine/entity-delta.ts +135 -15
- package/server/engine/index.ts +4 -0
- package/server/engine/kpi-fold.ts +62 -59
- package/server/engine/kpi-query.ts +140 -19
- package/server/engine/kpi-target.ts +226 -0
- package/server/engine/live-attentions.ts +12 -2
- package/server/engine/measured-estimator.ts +91 -0
- package/server/engine/model-basis.ts +94 -0
- package/server/engine/oee-accumulator.ts +5 -5
- package/server/engine/spec-coverage.ts +85 -0
- package/server/engine/structure-diff.ts +88 -0
- package/server/engine/travel-estimator.ts +133 -0
- package/server/engine/twin-engine.ts +587 -106
- package/server/engine/warm-start.ts +53 -0
- package/server/index.ts +9 -0
- package/server/service/index.ts +7 -0
- package/server/service/reference/reference-live.ts +2 -1
- package/server/service/reference/reference-master.ts +383 -10
- package/server/service/reference/reference-resolver.ts +3 -3
- package/server/service/reference/template-registry.ts +1 -1
- package/server/service/twin-attention/twin-attention-query.ts +1 -1
- package/server/service/twin-control/twin-control-mutation.ts +1 -1
- package/server/service/twin-event/backfill-keys.ts +72 -0
- package/server/service/twin-event/twin-event-keys.ts +102 -0
- package/server/service/twin-event/twin-event-type.ts +27 -0
- package/server/service/twin-event/twin-event.ts +62 -0
- package/server/service/twin-forecast/twin-forecast-query.ts +56 -8
- package/server/service/twin-instance/twin-instance.ts +1 -1
- package/server/service/twin-journal/twin-journal-query.ts +129 -5
- package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +4 -3
- package/server/service/twin-space/twin-space-area.ts +1 -1
- package/server/service/twin-space/twin-space-resolver.ts +3 -3
- package/server/service/twin-space/twin-space.ts +14 -0
- package/server/service/twin-structure/index.ts +3 -0
- package/server/service/twin-structure/twin-structure.ts +93 -0
- package/server/service/twin-target/index.ts +5 -0
- package/server/service/twin-target/twin-target-resolver.ts +124 -0
- package/server/service/twin-target/twin-target.ts +97 -0
- package/test/capability-mapping.test.ts +5 -5
- package/test/duration-estimators.test.ts +144 -0
- package/test/entity-delta.test.ts +150 -19
- package/test/ingest-bench.test.ts +9 -9
- package/test/kpi-fold.test.ts +254 -3
- package/test/live-mirror-parity.test.ts +53 -19
- package/test/master-to-twin.test.ts +87 -13
- package/test/model-basis.test.ts +86 -0
- package/test/oee-accumulator.test.ts +9 -9
- package/test/scale-twin-bench.test.ts +22 -22
- package/test/spec-coverage.test.ts +113 -0
- package/test/streamline-e2e.test.ts +10 -10
- package/test/structure-revision-db.test.ts +310 -0
- package/test/twin-event-keys.test.ts +108 -0
- package/test/vocabulary-guard.test.ts +43 -0
- package/test/warm-start.test.ts +78 -0
|
@@ -17,11 +17,11 @@ const wmsMaster = () => ({
|
|
|
17
17
|
space: { width: 90, depth: 40, unit: 'm' },
|
|
18
18
|
locations: [
|
|
19
19
|
{ id: 'a-dc', type: 'warehouse', role: 'area' as const, name: '창고' },
|
|
20
|
-
{ id: 'dock-recv', type: 'dock', role: '
|
|
21
|
-
{ id: 'rack-1', type: 'storage', role: '
|
|
22
|
-
{ id: 'rack-2', type: 'storage', role: '
|
|
23
|
-
{ id: 'staging', type: 'staging', role: '
|
|
24
|
-
{ id: 'dock-ship', type: 'dock-ship', role: '
|
|
20
|
+
{ id: 'dock-recv', type: 'dock', role: 'location' as const, name: '입고 도크', capacity: 100 },
|
|
21
|
+
{ id: 'rack-1', type: 'storage', role: 'location' as const, name: '랙1', parentId: 'a-dc', capacity: 60 },
|
|
22
|
+
{ id: 'rack-2', type: 'storage', role: 'location' as const, name: '랙2', parentId: 'a-dc', capacity: 60 },
|
|
23
|
+
{ id: 'staging', type: 'staging', role: 'location' as const, name: '스테이징', capacity: 80 },
|
|
24
|
+
{ id: 'dock-ship', type: 'dock-ship', role: 'location' as const, name: '출고 도크', capacity: 100 }
|
|
25
25
|
],
|
|
26
26
|
equipment: [
|
|
27
27
|
{ id: 'fk-1', kind: 'forklift', homeLocationId: 'dock-recv' },
|
|
@@ -31,14 +31,14 @@ const wmsMaster = () => ({
|
|
|
31
31
|
|
|
32
32
|
const snapshotState = (kernel: any) => {
|
|
33
33
|
const snap = kernel.getSnapshot()
|
|
34
|
-
return snap?.
|
|
34
|
+
return snap?.locations || snap?.equipment ? snap : snap?.state ?? snap
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
test('★ 캡스톤: master → masterToTwin → 커널 로드 → tick → 라이브 payload (전 사슬 결합)', () => {
|
|
38
38
|
// 1) 모델 → board 토폴로지
|
|
39
39
|
const { board } = masterToTwin(wmsMaster())
|
|
40
|
-
assert.equal(board.
|
|
41
|
-
assert.equal(board.
|
|
40
|
+
assert.equal(board.locations.length, 5, '노드 5')
|
|
41
|
+
assert.equal(board.equipment.length, 2, '무버 2')
|
|
42
42
|
|
|
43
43
|
// 2) 커널 로드 + 런타임 tick
|
|
44
44
|
const kernel = new WmsKernel('e2e')
|
|
@@ -52,8 +52,8 @@ test('★ 캡스톤: master → masterToTwin → 커널 로드 → tick → 라
|
|
|
52
52
|
const tags = new Set(deltas.map(d => d.tag))
|
|
53
53
|
|
|
54
54
|
// 4) 보드의 모든 노드·무버가 payload 에 도달(커널→보드 결합 성립)
|
|
55
|
-
for (const n of board.
|
|
56
|
-
for (const m of board.
|
|
55
|
+
for (const n of board.locations) assert.ok(tags.has(n.id), `노드 ${n.id} payload 도달`)
|
|
56
|
+
for (const m of board.equipment) assert.ok(tags.has(m.id), `무버 ${m.id} payload 도달`)
|
|
57
57
|
|
|
58
58
|
// 5) 능력별 상태필드가 실림 — storable 노드 occupancy/capacity, mobile+operable 무버 location/status
|
|
59
59
|
const rack = deltas.find(d => d.tag === 'rack-1')!.data as any
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ★ 구조 리비전 — **실 DB로 경계를 넘어 본다.**
|
|
3
|
+
*
|
|
4
|
+
* 커널의 마디 접기(`replaySegments`)는 그쪽 테스트가 지킨다. 여기서 지키는 것은 **호스트 배선**이다:
|
|
5
|
+
* 리비전을 언제 새로 만드는가 · 이벤트에 번호를 찍는가 · 재생이 그 번호로 마디를 나누는가 ·
|
|
6
|
+
* 컬럼이 생기기 전 행(번호 없음)을 어디에 붙이는가.
|
|
7
|
+
*
|
|
8
|
+
* 이 배선은 **경계를 넘어야만** 틀리는 것이 드러난다. 그런데 지금 앱의 트윈은 전부 리비전 1이라
|
|
9
|
+
* 경계를 한 번도 안 넘어 봤다 — 오늘 하루에만 "테스트는 통과하는데 배선이 틀린" 경우를 셋 봤으므로,
|
|
10
|
+
* 그 상태로 두지 않는다.
|
|
11
|
+
*
|
|
12
|
+
* 실 엔티티·실 네이밍 전략으로 만든 sqlite in-memory 스키마에 **빌드 산출물**을 그대로 실행한다.
|
|
13
|
+
* 소스를 고친 뒤에는 `npm run build:server` 를 먼저 돌려야 반영된다.
|
|
14
|
+
*/
|
|
15
|
+
import { test, before, after, beforeEach } from 'node:test'
|
|
16
|
+
import assert from 'node:assert/strict'
|
|
17
|
+
import { createRequire } from 'node:module'
|
|
18
|
+
import type { DataSource } from 'typeorm'
|
|
19
|
+
|
|
20
|
+
/*
|
|
21
|
+
* **CJS 로 실어 온다.** `dist-server` 는 CJS 이고 그 안의 twin-engine 은 커널을 `require` 로 든다.
|
|
22
|
+
* 그것을 ESM `import` 로 끌면 Node 의 ESM→CJS 번역기가 내부 단언에서 터진다(ERR_INTERNAL_ASSERTION).
|
|
23
|
+
* 실 산출물을 그대로 시험하는 것이 이 스펙의 요점이므로, 부르는 쪽이 CJS 로 맞춘다.
|
|
24
|
+
*/
|
|
25
|
+
const req = createRequire(import.meta.url)
|
|
26
|
+
const { NamingStrategy, addDataSource, entities: shellEntities, Domain } = req('@things-factory/shell')
|
|
27
|
+
const { entities: authEntities } = req('@things-factory/auth-base')
|
|
28
|
+
const { entities: twinEntities } = req('../dist-server/service/index.js')
|
|
29
|
+
const { TwinEngine } = req('../dist-server/engine/index.js')
|
|
30
|
+
const { TwinEvent } = req('../dist-server/service/twin-event/twin-event.js')
|
|
31
|
+
const { TwinStructure } = req('../dist-server/service/twin-structure/twin-structure.js')
|
|
32
|
+
const { TwinInstance } = req('../dist-server/service/twin-instance/twin-instance.js')
|
|
33
|
+
const { WmsKernel, EventJournal } = req('@operato/twin-kernel')
|
|
34
|
+
const { DataSource: TypeOrmDataSource } = req('typeorm')
|
|
35
|
+
|
|
36
|
+
let ds: DataSource
|
|
37
|
+
let domainId: string
|
|
38
|
+
const INSTANCE = 'struct-probe'
|
|
39
|
+
const GTIN = 'urn:epc:idpat:sgtin:0614141.012345.*'
|
|
40
|
+
|
|
41
|
+
/** 저장 자리 `bins` 개 + 지게차 `forklifts` 대 — 구조를 다이얼한다. */
|
|
42
|
+
const boardWith = (bins: number, forklifts: number): any => ({
|
|
43
|
+
spaceId: 'struct-space',
|
|
44
|
+
utcOffsetMinutes: 0,
|
|
45
|
+
locations: [
|
|
46
|
+
{ id: 'dock-recv', type: 'dock', capacity: 100 },
|
|
47
|
+
{ id: 'staging', type: 'staging', capacity: 100 },
|
|
48
|
+
...Array.from({ length: bins }, (_, i) => ({ id: `bin-${i + 1}`, type: 'storage', capacity: 20 }))
|
|
49
|
+
],
|
|
50
|
+
equipment: Array.from({ length: forklifts }, (_, i) => ({ id: `fk-${i + 1}`, kind: 'forklift', homeLocation: 'dock-recv' }))
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
/** 커널을 실제로 굴려 봉투를 얻는다 — 손으로 지어낸 모양은 검증이 아니다. */
|
|
54
|
+
function realEvents(board: any, ticks: number): any[] {
|
|
55
|
+
const journal = new EventJournal()
|
|
56
|
+
const k = new WmsKernel('struct')
|
|
57
|
+
k.onEvent((e: any) => journal.append(e))
|
|
58
|
+
k.loadBoard(board)
|
|
59
|
+
k.scenario.load({
|
|
60
|
+
seed: 11,
|
|
61
|
+
generators: [{ kind: 'inbound-arrival', rate: { distribution: 'constant', meanPerHour: 120 }, content: { skuMix: [{ gtin: GTIN, weight: 1 }], qtyPerLine: { min: 1, max: 1 } } }]
|
|
62
|
+
})
|
|
63
|
+
k.scenario.start()
|
|
64
|
+
for (let i = 0; i < ticks; i++) k.tick(1000)
|
|
65
|
+
return journal.all() as any[]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
before(async () => {
|
|
69
|
+
ds = new TypeOrmDataSource({
|
|
70
|
+
type: 'sqlite',
|
|
71
|
+
database: ':memory:',
|
|
72
|
+
synchronize: true,
|
|
73
|
+
dropSchema: true,
|
|
74
|
+
namingStrategy: new NamingStrategy(),
|
|
75
|
+
entities: [...shellEntities, ...authEntities, ...twinEntities],
|
|
76
|
+
logging: false
|
|
77
|
+
})
|
|
78
|
+
await ds.initialize()
|
|
79
|
+
addDataSource('default', ds)
|
|
80
|
+
const d = await ds.getRepository(Domain).save({ name: 'struct-test', subdomain: 'struct-test' } as any)
|
|
81
|
+
domainId = d.id
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
after(async () => {
|
|
85
|
+
await ds?.destroy()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
beforeEach(async () => {
|
|
89
|
+
for (const target of [TwinEvent, TwinStructure, TwinInstance]) await ds.query(`DELETE FROM "${ds.getMetadata(target).tableName}"`)
|
|
90
|
+
/* 엔진의 리비전 캐시도 비운다 — 스펙 간에 남으면 다음 스펙이 옛 번호를 찍는다. */
|
|
91
|
+
;(TwinEngine as any).structureRevCache = {}
|
|
92
|
+
delete (TwinEngine as any).recovered[INSTANCE]
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('같은 구조로 다시 프로비저닝하면 리비전이 늘지 않는다 — 부팅마다 늘면 이력이 잘게 쪼개진다', async () => {
|
|
96
|
+
const board = boardWith(2, 1)
|
|
97
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', board)
|
|
98
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', board)
|
|
99
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', JSON.parse(JSON.stringify(board)))
|
|
100
|
+
|
|
101
|
+
const revs = await ds.getRepository(TwinStructure).find({ where: { domain: { id: domainId }, instanceId: INSTANCE } })
|
|
102
|
+
assert.equal(revs.length, 1)
|
|
103
|
+
assert.equal(revs[0].rev, 1)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('구조가 바뀌면 새 리비전이 생기고 **저널은 살아남는다**', async () => {
|
|
107
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1))
|
|
108
|
+
const first = realEvents(boardWith(2, 1), 300)
|
|
109
|
+
assert.ok(first.length > 0, '이벤트가 있어야 "살아남는지" 를 물을 수 있다')
|
|
110
|
+
await TwinEngine.persistBatch(domainId, INSTANCE, first, 0)
|
|
111
|
+
|
|
112
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(6, 3))
|
|
113
|
+
|
|
114
|
+
const kept = await ds.getRepository(TwinEvent).count({ where: { domain: { id: domainId }, instanceId: INSTANCE } })
|
|
115
|
+
assert.equal(kept, first.length, '구조가 바뀌었다고 이력을 지우면 안 된다 — 그것이 이 작업의 전부다')
|
|
116
|
+
|
|
117
|
+
const revs = await ds.getRepository(TwinStructure).find({ where: { domain: { id: domainId }, instanceId: INSTANCE }, order: { rev: 'ASC' } })
|
|
118
|
+
assert.deepEqual(revs.map(r => r.rev), [1, 2])
|
|
119
|
+
assert.notEqual(revs[0].signature, revs[1].signature)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('이벤트가 자기 구조 번호를 달고 저장된다 — 안 달면 나중에 어느 공장인지 알 길이 없다', async () => {
|
|
123
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1))
|
|
124
|
+
await TwinEngine.persistBatch(domainId, INSTANCE, realEvents(boardWith(2, 1), 200), 0)
|
|
125
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(6, 3))
|
|
126
|
+
const before = await ds.getRepository(TwinEvent).count({ where: { domain: { id: domainId }, instanceId: INSTANCE } })
|
|
127
|
+
await TwinEngine.persistBatch(domainId, INSTANCE, realEvents(boardWith(6, 3), 200), before)
|
|
128
|
+
|
|
129
|
+
const rows = await ds.getRepository(TwinEvent).find({ where: { domain: { id: domainId }, instanceId: INSTANCE } })
|
|
130
|
+
const byRev = new Map<number | undefined, number>()
|
|
131
|
+
for (const r of rows) byRev.set(r.structureRev, (byRev.get(r.structureRev) ?? 0) + 1)
|
|
132
|
+
assert.deepEqual([...byRev.keys()].sort(), [1, 2], '두 구조에서 각각 쓰인 이벤트가 있어야 한다')
|
|
133
|
+
assert.ok((byRev.get(1) ?? 0) > 0 && (byRev.get(2) ?? 0) > 0)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('★ 재생이 경계를 넘는다 — 앞선 사실은 남고, 새 구조의 설비가 들어온다', async () => {
|
|
137
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1))
|
|
138
|
+
const first = realEvents(boardWith(2, 1), 300)
|
|
139
|
+
await TwinEngine.persistBatch(domainId, INSTANCE, first, 0)
|
|
140
|
+
|
|
141
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(6, 3))
|
|
142
|
+
await TwinEngine.persistBatch(domainId, INSTANCE, realEvents(boardWith(6, 3), 300), first.length)
|
|
143
|
+
|
|
144
|
+
const state = await TwinEngine.recover(domainId, INSTANCE)
|
|
145
|
+
assert.ok(state.items.length > 0, '앞 마디의 물품이 사라지면 이력을 지운 것과 다를 바 없다')
|
|
146
|
+
assert.equal(state.equipment.length, 3, '새 구조의 지게차 셋이 상태에 들어와야 한다')
|
|
147
|
+
assert.equal(state.locations.filter((n: any) => n.type === 'storage').length, 6, '늘린 자리가 안 보이면 늘린 의미가 없다')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
test('구조 번호가 없는 옛 행은 **가장 오래된 구조**로 접는다 — 새 공장에 대고 접지 않는다', async () => {
|
|
151
|
+
/*
|
|
152
|
+
* 컬럼이 생기기 전에 쓰인 행은 `structureRev` 가 비어 있다. 그것을 현재 보드로 접으면 그때 없던
|
|
153
|
+
* 설비에서 일이 있었던 것처럼 보인다 — 이 규칙이 그 행들을 제자리에 붙인다.
|
|
154
|
+
*/
|
|
155
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1))
|
|
156
|
+
const legacy = realEvents(boardWith(2, 1), 300)
|
|
157
|
+
await TwinEngine.persistBatch(domainId, INSTANCE, legacy, 0)
|
|
158
|
+
/* 컬럼이 없던 시절을 흉내 낸다 — 번호를 지운다. */
|
|
159
|
+
await ds.query(`UPDATE "${ds.getMetadata(TwinEvent).tableName}" SET structure_rev = NULL`)
|
|
160
|
+
|
|
161
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(6, 3))
|
|
162
|
+
const state = await TwinEngine.recover(domainId, INSTANCE)
|
|
163
|
+
assert.ok(state.items.length > 0, '번호 없는 옛 행도 읽혀야 한다 — 조용히 버리지 않는다')
|
|
164
|
+
assert.equal(state.equipment.length, 3, '마지막 구조가 자원의 진실이다')
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test('리비전이 하나뿐이면 예전과 똑같이 접는다 — 갈아타기가 결과를 바꾸지 않는다', async () => {
|
|
168
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(3, 2))
|
|
169
|
+
const events = realEvents(boardWith(3, 2), 300)
|
|
170
|
+
await TwinEngine.persistBatch(domainId, INSTANCE, events, 0)
|
|
171
|
+
|
|
172
|
+
const state = await TwinEngine.recover(domainId, INSTANCE)
|
|
173
|
+
assert.equal(state.equipment.length, 2)
|
|
174
|
+
assert.equal(state.locations.filter((n: any) => n.type === 'storage').length, 3)
|
|
175
|
+
assert.ok(state.items.length > 0)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('꺼진 트윈의 이력도 읽을 수 있다 — 저널은 트윈이 꺼져 있을 때 가장 필요하다', async () => {
|
|
179
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1))
|
|
180
|
+
assert.equal(TwinEngine.owns(domainId, INSTANCE), false, '떠 있지 않다')
|
|
181
|
+
assert.equal(await TwinEngine.ownsRegistered(domainId, INSTANCE), true, '그래도 이 테넌트 것이다')
|
|
182
|
+
assert.equal(await TwinEngine.ownsRegistered(domainId, 'someone-elses-twin'), false)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
test('시간여행은 **그때의 공장**을 보여준다 — 과거에 최신 구조를 얹으면 없던 설비가 선다', async () => {
|
|
186
|
+
/*
|
|
187
|
+
* "지금" 을 물으면 최신 구조를 얹는 것이 맞다(재프로비저닝 직후 늘린 설비가 보여야 하니까).
|
|
188
|
+
* 그런데 그 규칙을 과거 조회에도 적용하면, 그 시점에 존재하지도 않던 설비가 화면에 선다 —
|
|
189
|
+
* 이 작업이 막으려던 바로 그 거짓말이다. 규칙이 "지금" 에만 걸리는지 못박는다.
|
|
190
|
+
*/
|
|
191
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1))
|
|
192
|
+
const first = realEvents(boardWith(2, 1), 300)
|
|
193
|
+
await TwinEngine.persistBatch(domainId, INSTANCE, first, 0)
|
|
194
|
+
|
|
195
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(6, 3))
|
|
196
|
+
|
|
197
|
+
const now = await TwinEngine.recover(domainId, INSTANCE)
|
|
198
|
+
assert.equal(now.equipment.length, 3, '"지금" 은 늘린 설비를 보여준다')
|
|
199
|
+
|
|
200
|
+
const past = await TwinEngine.recover(domainId, INSTANCE, first.length)
|
|
201
|
+
assert.equal(past.equipment.length, 1, '그 시점에는 지게차가 하나뿐이었다')
|
|
202
|
+
assert.equal(past.locations.filter((n: any) => n.type === 'storage').length, 2, '그때는 자리도 둘이었다')
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
test('구조 이력이 **무엇이 달라졌는지** 말한다 — 보드 두 개를 눈으로 비교할 수는 없다', async () => {
|
|
206
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1))
|
|
207
|
+
const first = realEvents(boardWith(2, 1), 200)
|
|
208
|
+
await TwinEngine.persistBatch(domainId, INSTANCE, first, 0)
|
|
209
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(6, 3), '도장 능력 보강')
|
|
210
|
+
await TwinEngine.persistBatch(domainId, INSTANCE, realEvents(boardWith(6, 3), 100), first.length)
|
|
211
|
+
|
|
212
|
+
const history = await TwinEngine.structureHistory(domainId, INSTANCE)
|
|
213
|
+
assert.equal(history.length, 2)
|
|
214
|
+
|
|
215
|
+
/* 최초 리비전은 비교 대상이 없다 — 전부 새로 생긴 것으로 센다. */
|
|
216
|
+
assert.equal(history[0].rev, 1)
|
|
217
|
+
assert.equal(history[0].diff.locationsAdded, 4, '자리 넷(도크·스테이징·빈 둘)')
|
|
218
|
+
assert.ok(history[0].events > 0, '그 구조 아래 일어난 일의 수를 함께 말한다')
|
|
219
|
+
|
|
220
|
+
const second = history[1]
|
|
221
|
+
assert.equal(second.rev, 2)
|
|
222
|
+
assert.equal(second.comment, '도장 능력 보강')
|
|
223
|
+
assert.equal(second.diff.locationsAdded, 4, '저장 자리 넷이 늘었다')
|
|
224
|
+
assert.equal(second.diff.equipmentAdded, 2, '지게차 둘이 늘었다')
|
|
225
|
+
assert.deepEqual(
|
|
226
|
+
second.diff.locationTypes.find(c => c.key === 'storage'),
|
|
227
|
+
{ key: 'storage', before: 2, after: 6, delta: 4 },
|
|
228
|
+
'"저장 자리 2 → 6" 이라고 읽혀야 한다'
|
|
229
|
+
)
|
|
230
|
+
assert.deepEqual(second.diff.equipmentKinds.find(c => c.key === 'forklift'), { key: 'forklift', before: 1, after: 3, delta: 2 })
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
test('번호 없는 옛 행도 가장 오래된 리비전의 몫으로 센다 — 재생과 같은 규칙', async () => {
|
|
234
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1))
|
|
235
|
+
const legacy = realEvents(boardWith(2, 1), 200)
|
|
236
|
+
await TwinEngine.persistBatch(domainId, INSTANCE, legacy, 0)
|
|
237
|
+
await ds.query(`UPDATE "${ds.getMetadata(TwinEvent).tableName}" SET structure_rev = NULL`)
|
|
238
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(6, 3))
|
|
239
|
+
|
|
240
|
+
const history = await TwinEngine.structureHistory(domainId, INSTANCE)
|
|
241
|
+
assert.equal(history[0].events, legacy.length, '재생이 rev 1 로 접는 행들을 세는 쪽만 빠뜨리면 두 화면이 다른 말을 한다')
|
|
242
|
+
assert.equal(history[1].events, 0)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
test('지문 표기가 낡았을 뿐이면 리비전을 만들지 않는다 — 계산 방식을 바꿔도 이력이 부풀지 않는다', async () => {
|
|
246
|
+
/*
|
|
247
|
+
* 실제로 당했다. 지문을 원문에서 해시로 바꾸자 저장된 문자열이 무엇과도 안 맞아, 아무것도 안
|
|
248
|
+
* 바뀐 트윈들이 재기동 한 번에 전부 "구조가 바뀌었다" 로 기록됐다. 저장된 **보드**와 비교하면
|
|
249
|
+
* 그런 일이 없다.
|
|
250
|
+
*/
|
|
251
|
+
const board = boardWith(2, 1)
|
|
252
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', board)
|
|
253
|
+
const table = ds.getMetadata(TwinStructure).tableName
|
|
254
|
+
await ds.query(`UPDATE "${table}" SET signature = 'old-style-signature-from-a-previous-algorithm'`)
|
|
255
|
+
|
|
256
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', board)
|
|
257
|
+
|
|
258
|
+
const revs = await ds.getRepository(TwinStructure).find({ where: { domain: { id: domainId }, instanceId: INSTANCE } })
|
|
259
|
+
assert.equal(revs.length, 1, '공장은 그대로인데 리비전이 늘면 이력이 거짓으로 부푼다')
|
|
260
|
+
assert.notEqual(revs[0].signature, 'old-style-signature-from-a-previous-algorithm', '표기는 새 방식으로 고쳐 둔다')
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
/*
|
|
264
|
+
* ── 이어지는 현실(sim-world) — 재기동해도 이력이 끊기지 않는다 ────────────────
|
|
265
|
+
*
|
|
266
|
+
* 예전에는 sim-world 선언이 저널을 지우고 fresh 로 띄웠다. 실제로 없던 것은 **번호 이어 세기
|
|
267
|
+
* 하나**였다: 상태 복원은 이미 저널 재생으로 하고 있었고, 막힌 것은 커널이 0부터 다시 세어 기존
|
|
268
|
+
* 행과 겹치는 문제였다. 그 겹침은 오류를 내지 않고 조용히 재생 순서를 뒤섞는다.
|
|
269
|
+
*/
|
|
270
|
+
test('sim-world 로 다시 띄워도 저널이 살아남는다 — 선언이 지키는 약속', async () => {
|
|
271
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1))
|
|
272
|
+
const first = realEvents(boardWith(2, 1), 300)
|
|
273
|
+
await TwinEngine.persistBatch(domainId, INSTANCE, first, 0)
|
|
274
|
+
|
|
275
|
+
const rt = await TwinEngine.bootDeclared(domainId, INSTANCE, 'sim-world')
|
|
276
|
+
try {
|
|
277
|
+
const kept = await ds.getRepository(TwinEvent).count({ where: { domain: { id: domainId }, instanceId: INSTANCE } })
|
|
278
|
+
assert.equal(kept, first.length, 'sim-world 는 이어지는 현실이다 — 지우면 선언이 거짓이 된다')
|
|
279
|
+
} finally {
|
|
280
|
+
await TwinEngine.stop(INSTANCE)
|
|
281
|
+
}
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
test('★ 재기동 후 새 이벤트는 마지막 번호 뒤에 온다 — 겹치면 재생 순서가 뒤섞인다', async () => {
|
|
285
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1))
|
|
286
|
+
const first = realEvents(boardWith(2, 1), 300)
|
|
287
|
+
await TwinEngine.persistBatch(domainId, INSTANCE, first, 0)
|
|
288
|
+
|
|
289
|
+
const rt = await TwinEngine.bootDeclared(domainId, INSTANCE, 'sim-world')
|
|
290
|
+
try {
|
|
291
|
+
const kernel: any = rt.kernel
|
|
292
|
+
assert.ok(kernel, '커널이 떠야 이어 세기를 물을 수 있다')
|
|
293
|
+
assert.ok(
|
|
294
|
+
kernel.getSnapshot().revision >= first.length,
|
|
295
|
+
`이어 세지 않았다 — 저널 마지막 ${first.length}, 커널 ${kernel.getSnapshot().revision}`
|
|
296
|
+
)
|
|
297
|
+
} finally {
|
|
298
|
+
await TwinEngine.stop(INSTANCE)
|
|
299
|
+
}
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
test('저널이 비어 있으면 이어 셀 것이 없다 — 새로 시작하는 트윈에 영향 없음', async () => {
|
|
303
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1))
|
|
304
|
+
const rt = await TwinEngine.bootDeclared(domainId, INSTANCE, 'sim-world')
|
|
305
|
+
try {
|
|
306
|
+
assert.equal((rt.kernel as any).getSnapshot().revision, 0)
|
|
307
|
+
} finally {
|
|
308
|
+
await TwinEngine.stop(INSTANCE)
|
|
309
|
+
}
|
|
310
|
+
})
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ★ 저널 검색 키 승격 검증 — payload(JSON) 안의 값을 인덱스 가능한 실컬럼으로 꺼내는 규칙.
|
|
3
|
+
*
|
|
4
|
+
* 이 규칙이 틀리면 "저널에는 있는데 검색으로는 안 나오는 이벤트" 가 생긴다. 화면에서는 그냥
|
|
5
|
+
* **없는 것으로 보인다** — 저널에서 가장 나쁜 결함이라 규칙을 테스트로 고정한다.
|
|
6
|
+
*/
|
|
7
|
+
import { test } from 'node:test'
|
|
8
|
+
import assert from 'node:assert/strict'
|
|
9
|
+
|
|
10
|
+
import { bizStepOf, epcOf, orderOf, locationOf, equipmentIdOf, twinEventKeys } from '../server/service/twin-event/twin-event-keys.ts'
|
|
11
|
+
|
|
12
|
+
/** EPCIS ObjectEvent 한 건(입고) — 실제 인제스트가 싣는 모양. */
|
|
13
|
+
const EPCIS_OBJECT = {
|
|
14
|
+
eventType: 'epcis.ObjectEvent',
|
|
15
|
+
eventTime: '2026-07-31T02:10:00.000Z',
|
|
16
|
+
data: {
|
|
17
|
+
bizStep: 'urn:epcglobal:cbv:bizstep:receiving',
|
|
18
|
+
disposition: 'urn:epcglobal:cbv:disp:in_progress',
|
|
19
|
+
epcList: ['urn:epc:id:sgtin:0614141.107346.2018'],
|
|
20
|
+
readPoint: { id: 'urn:epc:id:sgln:0614141.00777.DOCK-3' },
|
|
21
|
+
bizLocation: { id: 'urn:epc:id:sgln:0614141.00888.0' },
|
|
22
|
+
bizTransactionList: [{ type: 'urn:epcglobal:cbv:btt:po', bizTransaction: 'urn:epc:id:gdti:0614141.402.2' }]
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test('EPCIS 이벤트에서 네 축이 모두 뽑힌다', () => {
|
|
27
|
+
const k = twinEventKeys(EPCIS_OBJECT)
|
|
28
|
+
assert.equal(k.bizStep, 'receiving')
|
|
29
|
+
assert.equal(k.epc, 'urn:epc:id:sgtin:0614141.107346.2018')
|
|
30
|
+
assert.equal(k.orderId, 'urn:epc:id:gdti:0614141.402.2')
|
|
31
|
+
assert.equal(k.locationId, 'urn:epc:id:sgln:0614141.00777.DOCK-3')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('식별자는 끝마디가 아니라 전체를 저장한다 — 부분일치 검색이 양방향으로 성립해야 한다', () => {
|
|
35
|
+
const k = twinEventKeys(EPCIS_OBJECT)
|
|
36
|
+
/* 화면은 '2018' 로 축약해 보여주지만 색인은 원본을 갖는다. contains 검색이라
|
|
37
|
+
* 끝마디로도 URN 전체로도 찾힌다 — 끝마디만 저장하면 URN 경로가 사라진다. */
|
|
38
|
+
assert.ok(k.epc!.includes('2018'), '끝마디로 찾을 수 있어야 한다')
|
|
39
|
+
assert.ok(k.epc!.startsWith('urn:epc:id:sgtin'), 'URN 전체로도 찾을 수 있어야 한다')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('readPoint 가 없으면 bizLocation 으로 내려간다', () => {
|
|
43
|
+
const ev = { ...EPCIS_OBJECT, data: { ...EPCIS_OBJECT.data, readPoint: undefined } }
|
|
44
|
+
assert.equal(locationOf(ev), 'urn:epc:id:sgln:0614141.00888.0')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('bizStep 이 없으면 이벤트 타입에서 유추한다 — 운영 델타도 단계를 말할 수 있어야 한다', () => {
|
|
48
|
+
assert.equal(bizStepOf({ eventType: 'task.status', data: { task: 't1' } }), 'task.status')
|
|
49
|
+
assert.equal(bizStepOf({ eventType: 'epcis.AggregationEvent', data: {} }), 'AggregationEvent')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('집합 이벤트는 parentID 를, 수량 이벤트는 epcClass 를 품목으로 쓴다', () => {
|
|
53
|
+
assert.equal(epcOf({ data: { parentID: 'urn:epc:id:sscc:0614141.1234567890' } }), 'urn:epc:id:sscc:0614141.1234567890')
|
|
54
|
+
assert.equal(epcOf({ data: { quantityList: [{ epcClass: 'urn:epc:idpat:sgtin:0614141.107346.*', quantity: 40 }] } }), 'urn:epc:idpat:sgtin:0614141.107346.*')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('운영 델타의 오더는 bizTransactionList 가 아니라 order 필드에 있다', () => {
|
|
58
|
+
assert.equal(orderOf({ eventType: 'order.status', data: { order: 'SO-10021', status: 'released' } }), 'SO-10021')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('없는 값은 빈 문자열이 아니라 undefined — 결측과 빈값을 섞지 않는다', () => {
|
|
62
|
+
const k = twinEventKeys({ eventType: 'equipment.status', data: { equipment: 'fork-1', status: 'idle' } })
|
|
63
|
+
assert.equal(k.epc, undefined)
|
|
64
|
+
assert.equal(k.orderId, undefined)
|
|
65
|
+
assert.equal(k.locationId, undefined)
|
|
66
|
+
assert.equal(k.moverId, undefined)
|
|
67
|
+
/* bizStep 은 이벤트 타입에서 유추되므로 남는다 — 이건 결측이 아니다. */
|
|
68
|
+
assert.equal(k.bizStep, 'equipment.status')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('설비·무버 축 — 운영 델타의 moverId 를 뽑는다', () => {
|
|
72
|
+
const ev = { eventType: 'equipment.status', data: { moverId: 'fork-3', status: 'down', location: 'rack-1' } }
|
|
73
|
+
assert.equal(equipmentIdOf(ev), 'fork-3')
|
|
74
|
+
const k = twinEventKeys(ev)
|
|
75
|
+
assert.equal(k.moverId, 'fork-3')
|
|
76
|
+
/* 운영 델타는 readPoint/bizLocation 이 없다 — 평범한 location 을 위치로 받는다.
|
|
77
|
+
* 빠뜨리면 "이 설비가 어디서 무엇을 했나" 가 위치 축에서 통째로 사라진다. */
|
|
78
|
+
assert.equal(k.locationId, 'rack-1')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('EPCIS 의 readPoint 는 운영 델타의 location 보다 우선한다', () => {
|
|
82
|
+
assert.equal(locationOf({ data: { readPoint: { id: 'DOCK-1' }, location: 'rack-9' } }), 'DOCK-1')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('payload 가 data 로 감싸이지 않은 모양도 받는다', () => {
|
|
86
|
+
assert.equal(epcOf({ epcList: ['urn:epc:id:sgtin:1.2.3'] }), 'urn:epc:id:sgtin:1.2.3')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('상한을 넘는 값은 조용히 사라지지 않는다 — 잘리되 경고가 남는다', () => {
|
|
90
|
+
const long = 'urn:epc:id:sgtin:' + 'x'.repeat(400)
|
|
91
|
+
const warned: string[] = []
|
|
92
|
+
const orig = console.warn
|
|
93
|
+
console.warn = (...a: unknown[]) => void warned.push(String(a[0]))
|
|
94
|
+
try {
|
|
95
|
+
const k = twinEventKeys({ data: { epcList: [long] } })
|
|
96
|
+
assert.equal(k.epc!.length, 255)
|
|
97
|
+
assert.equal(warned.length, 1, '잘렸다는 사실이 어딘가에는 남아야 한다')
|
|
98
|
+
assert.ok(warned[0].includes('epc'))
|
|
99
|
+
} finally {
|
|
100
|
+
console.warn = orig
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('빈 봉투에도 터지지 않는다', () => {
|
|
105
|
+
const empty = { bizStep: undefined, epc: undefined, orderId: undefined, locationId: undefined, moverId: undefined }
|
|
106
|
+
assert.deepEqual(twinEventKeys(undefined), empty)
|
|
107
|
+
assert.deepEqual(twinEventKeys({}), empty)
|
|
108
|
+
})
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 어휘 가드 — 규칙은 커널이 한 벌 들고 있고(`@operato/twin-kernel` `retiredVocabularyIn`),
|
|
3
|
+
* 이 테스트는 **자기 패키지 소스를 훑는다.** 판정이 패키지마다 갈릴 수 없다.
|
|
4
|
+
*
|
|
5
|
+
* 계기(2026-08-01): 개명을 손으로 쓸다 같은 실수를 세 번 반복했다. 단어 경계로 찾으면 파생 식별자가
|
|
6
|
+
* 빠지고(`moverCount`·`locationType`), 빠진 것은 다음 파도에서야 드러난다. 그 사이 코드에는 두 어휘가
|
|
7
|
+
* 섞여 있고 다음 사람은 어느 쪽을 믿을지 모른다.
|
|
8
|
+
*
|
|
9
|
+
* 실패하면 위반 목록이 그대로 나온다 — 고치거나, 정당하면 그 줄에 `vocabulary-guard: allow <이유>` 를
|
|
10
|
+
* 남긴다(예외가 코드에 이유와 함께 남는다).
|
|
11
|
+
*/
|
|
12
|
+
import { test } from 'node:test'
|
|
13
|
+
import assert from 'node:assert/strict'
|
|
14
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
15
|
+
import { join } from 'node:path'
|
|
16
|
+
|
|
17
|
+
import { retiredVocabularyIn } from '@operato/twin-kernel'
|
|
18
|
+
|
|
19
|
+
const ROOTS = ['server', 'client'].map(d => new URL(`../${d}`, import.meta.url).pathname)
|
|
20
|
+
|
|
21
|
+
function walk(dir: string, out: string[] = []): string[] {
|
|
22
|
+
let entries: string[]
|
|
23
|
+
try { entries = readdirSync(dir) } catch { return out }
|
|
24
|
+
for (const e of entries) {
|
|
25
|
+
const p = join(dir, e)
|
|
26
|
+
if (statSync(p).isDirectory()) walk(p, out)
|
|
27
|
+
else if (p.endsWith('.ts') && !p.endsWith('.d.ts')) out.push(p)
|
|
28
|
+
}
|
|
29
|
+
return out
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
test('소스에 은퇴한 어휘(location·mover)가 남아 있지 않다', () => {
|
|
33
|
+
const bad: string[] = []
|
|
34
|
+
for (const root of ROOTS) {
|
|
35
|
+
for (const f of walk(root)) {
|
|
36
|
+
readFileSync(f, 'utf-8').split('\n').forEach((line, i) => {
|
|
37
|
+
const hits = retiredVocabularyIn(line)
|
|
38
|
+
if (hits.length) bad.push(`${f.split('/').slice(-2).join('/')}:${i + 1} ${[...new Set(hits)].join(', ')}`)
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
assert.deepEqual(bad, [], `은퇴 어휘 ${bad.length}곳:\n${bad.slice(0, 60).join('\n')}${bad.length > 60 ? `\n… ${bad.length - 60} more` : ''}`)
|
|
43
|
+
})
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ★ 웜스타트 판정 검증 — 재기동하는 커널에 직전 관측 상태를 심을지.
|
|
3
|
+
*
|
|
4
|
+
* 이 판정은 **양쪽으로 조용히 틀린다**: 심어야 할 때 안 심으면 있는 재고가 0 으로 보이고
|
|
5
|
+
* (2026-07-31 hatiolab-wms — 저널에 입고 540·출고 94 인데 재고 화면이 비어 있었다),
|
|
6
|
+
* 벤치에 심으면 용량 측정이 오염된다. 둘 다 화면에는 아무 이상이 없어 보인다.
|
|
7
|
+
*/
|
|
8
|
+
import { test } from 'node:test'
|
|
9
|
+
import assert from 'node:assert/strict'
|
|
10
|
+
|
|
11
|
+
import { planWarmStart } from '../server/engine/warm-start.ts'
|
|
12
|
+
|
|
13
|
+
const STATE = {
|
|
14
|
+
locations: [{ id: 'dock-recv' }, { id: 'rack-1' }],
|
|
15
|
+
items: [{ epc: 'a' }, { epc: 'b' }, { epc: 'c' }],
|
|
16
|
+
equipment: [{ id: 'fork-1' }],
|
|
17
|
+
orders: [{ id: 'SO-1', kind: 'outbound', status: 'created', progress: 0.5 }],
|
|
18
|
+
tasks: [{ id: 't1' }]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
test('상태가 있으면 심는다 — 재고·노드·무버', () => {
|
|
22
|
+
const plan = planWarmStart(STATE, undefined, true)
|
|
23
|
+
assert.equal(plan.action, 'hydrate')
|
|
24
|
+
if (plan.action !== 'hydrate') return
|
|
25
|
+
assert.equal(plan.itemCount, 3)
|
|
26
|
+
assert.equal(plan.equipmentCount, 1)
|
|
27
|
+
assert.deepEqual(plan.seed.locations, STATE.locations)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('오더는 심지 않는다 — 스냅샷에 requested/fulfilled 가 없어 지어내야 하기 때문', () => {
|
|
31
|
+
const plan = planWarmStart(STATE, undefined, true)
|
|
32
|
+
assert.equal(plan.action, 'hydrate')
|
|
33
|
+
if (plan.action !== 'hydrate') return
|
|
34
|
+
assert.deepEqual(Object.keys(plan.seed).sort(), ['equipment', 'items', 'locations'])
|
|
35
|
+
assert.equal((plan.seed as any).orders, undefined)
|
|
36
|
+
assert.equal((plan.seed as any).tasks, undefined)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('벤치 트윈에는 심지 않는다 — 새 시작에서 용량을 재는 것이 목적이다', () => {
|
|
40
|
+
const plan = planWarmStart(STATE, 'bench', true)
|
|
41
|
+
assert.deepEqual(plan, { action: 'skip', reason: 'bench' })
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('벤치 판정은 상태 유무보다 먼저다 — 상태가 없어도 이유는 bench 여야 한다', () => {
|
|
45
|
+
/* 이유가 뒤바뀌면 로그가 "심을 게 없었다" 로 남아, 벤치를 의도적으로 비웠다는 사실이 사라진다. */
|
|
46
|
+
assert.deepEqual(planWarmStart(null, 'bench', true), { action: 'skip', reason: 'bench' })
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('심을 상태가 없으면 조용히 넘어간다 — 처음 만든 트윈의 정상 경로다', () => {
|
|
50
|
+
assert.deepEqual(planWarmStart(null, undefined, true), { action: 'skip', reason: 'no-state' })
|
|
51
|
+
assert.deepEqual(planWarmStart(undefined, undefined, true), { action: 'skip', reason: 'no-state' })
|
|
52
|
+
assert.deepEqual(planWarmStart({ locations: [], items: [], equipment: [] }, undefined, true), { action: 'skip', reason: 'no-state' })
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('노드만 있어도 심는다 — 구조 배치가 복원되면 화면이 살아난다', () => {
|
|
56
|
+
const plan = planWarmStart({ locations: [{ id: 'n1' }], items: [], equipment: [] }, undefined, true)
|
|
57
|
+
assert.equal(plan.action, 'hydrate')
|
|
58
|
+
if (plan.action !== 'hydrate') return
|
|
59
|
+
assert.equal(plan.itemCount, 0)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('커널이 주입을 지원하지 않으면 알린다 — 조용히 빈 채로 뜨면 안 된다', () => {
|
|
63
|
+
assert.deepEqual(planWarmStart(STATE, undefined, false), { action: 'skip', reason: 'unsupported' })
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('심을 게 없으면 미지원이어도 경고할 일이 아니다', () => {
|
|
67
|
+
/* 미지원 경고가 매 기동마다 뜨면 진짜 경고가 묻힌다. */
|
|
68
|
+
assert.deepEqual(planWarmStart(null, undefined, false), { action: 'skip', reason: 'no-state' })
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('빠진 배열은 빈 배열로 다뤄지되 없는 것을 지어내지 않는다', () => {
|
|
72
|
+
const plan = planWarmStart({ items: [{ epc: 'a' }] } as any, undefined, true)
|
|
73
|
+
assert.equal(plan.action, 'hydrate')
|
|
74
|
+
if (plan.action !== 'hydrate') return
|
|
75
|
+
assert.deepEqual(plan.seed.locations, [])
|
|
76
|
+
assert.deepEqual(plan.seed.equipment, [])
|
|
77
|
+
assert.equal(plan.itemCount, 1)
|
|
78
|
+
})
|