@things-factory/headless-twin 10.0.6 → 10.0.8
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 +21 -4
- package/dist-server/engine/kpi-fold.js.map +1 -1
- package/dist-server/engine/kpi-query.d.ts +9 -0
- package/dist-server/engine/kpi-query.js +118 -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 +153 -17
- package/dist-server/engine/twin-engine.js +473 -81
- package/dist-server/engine/twin-engine.js.map +1 -1
- package/dist-server/engine/warm-start.d.ts +5 -5
- package/dist-server/engine/warm-start.js +4 -4
- package/dist-server/engine/warm-start.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/twin-event-keys.d.ts +1 -1
- package/dist-server/service/twin-event/twin-event-keys.js +3 -3
- package/dist-server/service/twin-event/twin-event-keys.js.map +1 -1
- package/dist-server/service/twin-event/twin-event.d.ts +11 -0
- package/dist-server/service/twin-event/twin-event.js +6 -1
- 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 +9 -0
- package/dist-server/service/twin-journal/twin-journal-query.js +54 -3
- 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 +7 -7
- package/server/engine/entity-delta.ts +135 -15
- package/server/engine/index.ts +4 -0
- package/server/engine/kpi-fold.ts +40 -8
- package/server/engine/kpi-query.ts +129 -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 +498 -84
- package/server/engine/warm-start.ts +8 -8
- 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/twin-event-keys.ts +2 -2
- package/server/service/twin-event/twin-event.ts +15 -1
- 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 +52 -4
- 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 +232 -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 +2 -2
- package/test/vocabulary-guard.test.ts +43 -0
- package/test/warm-start.test.ts +9 -9
|
@@ -21,10 +21,10 @@ function runSimCollect() {
|
|
|
21
21
|
space: { width: 90, depth: 40, unit: 'm' },
|
|
22
22
|
locations: [
|
|
23
23
|
{ id: 'a', type: 'warehouse', role: 'area' as const },
|
|
24
|
-
{ id: 'dock-recv', type: 'dock', role: '
|
|
25
|
-
{ id: 'rack-1', type: 'storage', role: '
|
|
26
|
-
{ id: 'staging', type: 'staging', role: '
|
|
27
|
-
{ id: 'dock-ship', type: 'dock-ship', role: '
|
|
24
|
+
{ id: 'dock-recv', type: 'dock', role: 'location' as const, capacity: 100 },
|
|
25
|
+
{ id: 'rack-1', type: 'storage', role: 'location' as const, capacity: 60 },
|
|
26
|
+
{ id: 'staging', type: 'staging', role: 'location' as const, capacity: 80 },
|
|
27
|
+
{ id: 'dock-ship', type: 'dock-ship', role: 'location' as const, capacity: 100 }
|
|
28
28
|
],
|
|
29
29
|
equipment: [{ id: 'fk-1', kind: 'forklift', homeLocationId: 'dock-recv', mtbfMs: 5000, mttrMs: 8000 }]
|
|
30
30
|
}
|
|
@@ -41,7 +41,7 @@ function runSimCollect() {
|
|
|
41
41
|
|
|
42
42
|
test('★ OEE 누적기: downMs 정확 · busyMs=run+setup · 근사 OEE 유효', () => {
|
|
43
43
|
const { events, snap } = runSimCollect()
|
|
44
|
-
const simOee = snap.
|
|
44
|
+
const simOee = snap.equipment.find((x: any) => x.id === 'fk-1')?.oee
|
|
45
45
|
assert.ok(simOee, 'sim fk-1 oee 존재')
|
|
46
46
|
assert.ok(simOee.downMs > 0, '시나리오가 고장(down) 유발 — 검증 의미 있음')
|
|
47
47
|
|
|
@@ -82,20 +82,20 @@ test('★ OEE 누적기: quality.output 델타 소비 → quality 정확 (good/s
|
|
|
82
82
|
assert.ok(Math.abs(oee.quality - 0.8) < 1e-9, 'quality = good/(good+scrap) = 0.8 (텔레메트리 없이 이벤트로 도출)')
|
|
83
83
|
})
|
|
84
84
|
|
|
85
|
-
test('★ withLiveOee: projector 미러(oee 없음) + 누적기 → live
|
|
85
|
+
test('★ withLiveOee: projector 미러(oee 없음) + 누적기 → live 설비 payload 에 oee 포함 (원 갭 종료)', () => {
|
|
86
86
|
const { events, board } = runSimCollect()
|
|
87
87
|
const proj = new StateProjector(board)
|
|
88
88
|
const acc = new OeeAccumulator()
|
|
89
89
|
for (const e of events) { proj.apply(e); acc.apply(e) }
|
|
90
90
|
|
|
91
91
|
const rawLive = proj.snapshot() as any
|
|
92
|
-
// 원 갭: projector 미러
|
|
93
|
-
assert.equal(rawLive.
|
|
92
|
+
// 원 갭: projector 미러 설비엔 oee 없음.
|
|
93
|
+
assert.equal(rawLive.equipment.find((m: any) => m.id === 'fk-1')?.oee, undefined, 'projector 미러엔 oee 부재(원 갭)')
|
|
94
94
|
|
|
95
95
|
// withLiveOee 로 보강 → buildEntityDeltas payload 에 oee 포함(sim 과 동일 형상 = 보드 OEE 컴포넌트 live 렌더).
|
|
96
96
|
const enriched = withLiveOee(rawLive, acc)
|
|
97
97
|
const fk = buildEntityDeltas(enriched).find(d => d.tag === 'fk-1')!.data as any
|
|
98
|
-
assert.ok(fk.oee, 'withLiveOee 후
|
|
98
|
+
assert.ok(fk.oee, 'withLiveOee 후 설비 data(tag) payload 에 oee 포함 — live OEE 갭 종료')
|
|
99
99
|
// buildEntityDeltas shapeOee = 정수 퍼센트(0-100), raw ms 제외.
|
|
100
100
|
assert.ok(fk.oee.overall >= 0 && fk.oee.overall <= 100, 'oee.overall ∈ [0,100] 정수 퍼센트')
|
|
101
101
|
assert.ok(fk.oee.availability < 100, 'availability < 100 (고장 시간 반영 — 누적기 downMs)')
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* 스케일 테스트 트윈 벤치 — 미국 공장(WMS+MES+YMS) 형태를 대규모로 스케일업, 5종 동기 엔티티 전부 부하.
|
|
3
|
-
* 동기 엔티티 =
|
|
3
|
+
* 동기 엔티티 = locations·items(재고)·equipment·tasks·orders (노드/무버뿐이 아님). area 는 계층(건물>층>존).
|
|
4
4
|
* 측정(가정 아닌 실측): masterToTwin(구조 생성)·apply(혼합 이벤트 처리량)·snapshot(전체 투영)·buildEntityDeltas(방송)·heap.
|
|
5
5
|
* 순수 커널+호스트 매핑(shell·DB 무관) → node:test. area 는 공간층(projector State 밖)이라 처리량엔 무영향 — 구조 생성 비용에만.
|
|
6
6
|
*/
|
|
@@ -15,7 +15,7 @@ const T0 = '2026-07-20T00:00:00.000Z'
|
|
|
15
15
|
const MB = (b: number) => Math.round(b / 1048576)
|
|
16
16
|
|
|
17
17
|
/** 미국 공장형 대규모 master 생성: 건물>층>존 area 계층 + 노드(storage/dock/station) + 무버(forklift/cutter/welder/hostler). */
|
|
18
|
-
function bigMaster(tier: { buildings: number; floorsPer: number; zonesPer: number; nodesPerZone: number;
|
|
18
|
+
function bigMaster(tier: { buildings: number; floorsPer: number; zonesPer: number; nodesPerZone: number; equipment: number }) {
|
|
19
19
|
const locations: any[] = []
|
|
20
20
|
let nodeN = 0
|
|
21
21
|
for (let b = 0; b < tier.buildings; b++) {
|
|
@@ -27,57 +27,57 @@ function bigMaster(tier: { buildings: number; floorsPer: number; zonesPer: numbe
|
|
|
27
27
|
locations.push({ id: zid, type: 'zone', role: 'area', name: `존 ${b}-${f}-${z}`, parentId: `bld-${b}-flr-${f}` })
|
|
28
28
|
for (let n = 0; n < tier.nodesPerZone; n++) {
|
|
29
29
|
const t = n % 4 === 0 ? 'dock' : n % 4 === 1 ? 'staging' : 'storage'
|
|
30
|
-
locations.push({ id: `nd-${nodeN}`, type: t, role: '
|
|
30
|
+
locations.push({ id: `nd-${nodeN}`, type: t, role: 'location', name: `노드 ${nodeN}`, parentId: zid, capacity: 500, footprint: { x: nodeN % 100, y: (nodeN / 100) | 0, w: 1, h: 1 } })
|
|
31
31
|
nodeN++
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
const KINDS = ['forklift', 'cutter', 'welder', 'hostler']
|
|
37
|
-
const equipment = Array.from({ length: tier.
|
|
37
|
+
const equipment = Array.from({ length: tier.equipment }, (_, i) => ({ id: `mv-${i}`, kind: KINDS[i % 4], homeLocationId: `nd-${i % nodeN}` }))
|
|
38
38
|
return {
|
|
39
39
|
master: { source: 'scale-us', system: 'wms' as const, siteName: 'US 공장(스케일)', space: { width: 500, depth: 300, unit: 'm', geo: { lat: 32.7, lon: -117.1 } }, locations, equipment },
|
|
40
|
-
|
|
40
|
+
locationCount: nodeN,
|
|
41
41
|
areaCount: locations.filter(l => l.role === 'area').length
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
function itemEnv(i: number,
|
|
46
|
-
const ev = objectEvent({ eventTime: T0, action: 'OBSERVE' as any, bizStep: BIZSTEP.storing, disposition: DISP.sellable, epcList: [`urn:epc:id:sscc:0614141.${i}`], readPoint:
|
|
45
|
+
function itemEnv(i: number, location: string): any {
|
|
46
|
+
const ev = objectEvent({ eventTime: T0, action: 'OBSERVE' as any, bizStep: BIZSTEP.storing, disposition: DISP.sellable, epcList: [`urn:epc:id:sscc:0614141.${i}`], readPoint: location })
|
|
47
47
|
return { eventId: `i${i}`, eventType: `epcis.${ev.type}`, eventTime: T0, tenantId: 't', data: ev }
|
|
48
48
|
}
|
|
49
|
-
const
|
|
49
|
+
const equipmentEnv = (id: string, kind: string, loc: string): any => ({ eventId: id, eventType: OP_EVENT.equipment, eventTime: T0, tenantId: 't', data: { moverId: id, kind, status: 'running', location: loc } })
|
|
50
50
|
const taskEnv = (id: string, to: string): any => ({ eventId: id, eventType: OP_EVENT.task, eventTime: T0, tenantId: 't', data: { taskId: id, kind: 'move', status: 'in-progress', toNode: to } })
|
|
51
51
|
const orderEnv = (id: string): any => ({ eventId: id, eventType: OP_EVENT.order, eventTime: T0, tenantId: 't', data: { orderId: id, kind: 'inbound', status: 'in-progress', requested: 100, fulfilled: 50 } })
|
|
52
52
|
|
|
53
53
|
const TIERS = {
|
|
54
|
-
medium: { buildings: 3, floorsPer: 3, zonesPer: 4, nodesPerZone: 8,
|
|
55
|
-
large: { buildings: 6, floorsPer: 4, zonesPer: 6, nodesPerZone: 12,
|
|
54
|
+
medium: { buildings: 3, floorsPer: 3, zonesPer: 4, nodesPerZone: 8, equipment: 80, items: 30000, tasks: 2000, orders: 500 },
|
|
55
|
+
large: { buildings: 6, floorsPer: 4, zonesPer: 6, nodesPerZone: 12, equipment: 300, items: 120000, tasks: 10000, orders: 2000 }
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
for (const [name, tier] of Object.entries(TIERS)) {
|
|
59
59
|
test(`스케일 트윈 [${name}] — 구조 생성 + 5종 엔티티 부하 실측`, () => {
|
|
60
60
|
// 1) 구조 생성(masterToTwin) — 노드·무버·area 계층
|
|
61
|
-
const { master,
|
|
61
|
+
const { master, locationCount, areaCount } = bigMaster(tier)
|
|
62
62
|
let t = performance.now()
|
|
63
63
|
const { board, spaceContent } = masterToTwin(master)
|
|
64
64
|
const tMaster = performance.now() - t
|
|
65
|
-
console.log(`\n [${name}] 구조: 노드 ${
|
|
66
|
-
assert.equal(board.
|
|
65
|
+
console.log(`\n [${name}] 구조: 노드 ${locationCount.toLocaleString()} · 무버 ${tier.equipment} · area ${areaCount}(계층) · masterToTwin ${tMaster.toFixed(0)}ms`)
|
|
66
|
+
assert.equal(board.locations.length, locationCount)
|
|
67
67
|
assert.equal((spaceContent.areas ?? []).length, areaCount, 'area 계층 생성')
|
|
68
68
|
|
|
69
69
|
const proj = new StateProjector(board)
|
|
70
|
-
const
|
|
70
|
+
const equipment = board.equipment as any[]
|
|
71
71
|
|
|
72
|
-
// 2) apply 처리량 — 5종 혼합(items·
|
|
72
|
+
// 2) apply 처리량 — 5종 혼합(items·equipment·tasks·orders)
|
|
73
73
|
t = performance.now()
|
|
74
|
-
for (let i = 0; i < tier.items; i++) proj.apply(itemEnv(i, `nd-${i %
|
|
75
|
-
for (let i = 0; i < tier.
|
|
76
|
-
for (let i = 0; i < tier.tasks; i++) proj.apply(taskEnv(`tk-${i}`, `nd-${i %
|
|
74
|
+
for (let i = 0; i < tier.items; i++) proj.apply(itemEnv(i, `nd-${i % locationCount}`))
|
|
75
|
+
for (let i = 0; i < tier.equipment; i++) proj.apply(equipmentEnv(equipment[i].id, equipment[i].kind, `nd-${i % locationCount}`))
|
|
76
|
+
for (let i = 0; i < tier.tasks; i++) proj.apply(taskEnv(`tk-${i}`, `nd-${i % locationCount}`))
|
|
77
77
|
for (let i = 0; i < tier.orders; i++) proj.apply(orderEnv(`or-${i}`))
|
|
78
|
-
const total = tier.items + tier.
|
|
78
|
+
const total = tier.items + tier.equipment + tier.tasks + tier.orders
|
|
79
79
|
const tApply = performance.now() - t
|
|
80
|
-
console.log(` [${name}] apply: ${total.toLocaleString()} 이벤트(items+
|
|
80
|
+
console.log(` [${name}] apply: ${total.toLocaleString()} 이벤트(items+equipment+tasks+orders) ${tApply.toFixed(0)}ms = ${Math.round((total / tApply) * 1000).toLocaleString()} ev/s`)
|
|
81
81
|
|
|
82
82
|
// 3) snapshot(전체 투영) — 5종 전부
|
|
83
83
|
let s: any
|
|
@@ -85,7 +85,7 @@ for (const [name, tier] of Object.entries(TIERS)) {
|
|
|
85
85
|
t = performance.now()
|
|
86
86
|
for (let k = 0; k < iters; k++) s = proj.snapshot()
|
|
87
87
|
const tSnap = (performance.now() - t) / iters
|
|
88
|
-
console.log(` [${name}] snapshot: ${tSnap.toFixed(2)}ms/call (items ${s.items.length.toLocaleString()}·
|
|
88
|
+
console.log(` [${name}] snapshot: ${tSnap.toFixed(2)}ms/call (items ${s.items.length.toLocaleString()}·locations ${s.locations.length.toLocaleString()}·equipment ${s.equipment.length}·tasks ${s.tasks.length}·orders ${s.orders.length}) → 최대 ${Math.round(1000 / tSnap).toLocaleString()} snap/s`)
|
|
89
89
|
|
|
90
90
|
// 4) 방송 payload(buildEntityDeltas)
|
|
91
91
|
t = performance.now()
|
|
@@ -95,7 +95,7 @@ for (const [name, tier] of Object.entries(TIERS)) {
|
|
|
95
95
|
console.log(` [${name}] heap: ${MB(process.memoryUsage().heapUsed)}MB`)
|
|
96
96
|
|
|
97
97
|
// 정합 sanity
|
|
98
|
-
assert.equal(s.
|
|
98
|
+
assert.equal(s.equipment.length, tier.equipment)
|
|
99
99
|
assert.equal(s.orders.length, tier.orders)
|
|
100
100
|
assert.ok(s.items.length > 0)
|
|
101
101
|
})
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 시뮬 명세 자격 판정 — **"이 예측으로 무엇을 말할 수 있나" 를 숫자로 가른다.**
|
|
3
|
+
*
|
|
4
|
+
* 배경: 공정 소요가 커널 상수였을 때와 현장 명세로 굴렸을 때가 응답에서 똑같이 "p50 168" 로 보였다.
|
|
5
|
+
* 그 둘을 구별하지 못하면 상대 비교용 숫자가 절대 예측처럼 읽힌다.
|
|
6
|
+
*/
|
|
7
|
+
import { test } from 'node:test'
|
|
8
|
+
import assert from 'node:assert/strict'
|
|
9
|
+
|
|
10
|
+
import { classifySpecCoverage, readSpecCoverage } from '../server/engine/spec-coverage.ts'
|
|
11
|
+
|
|
12
|
+
test('전부 기본값이면 상대 비교만 — 절대 시각을 주장할 근거가 없다', () => {
|
|
13
|
+
const r = classifySpecCoverage({
|
|
14
|
+
operations: [
|
|
15
|
+
{ kind: 'putaway', duration: 'default' },
|
|
16
|
+
{ kind: 'pick', duration: 'default' }
|
|
17
|
+
]
|
|
18
|
+
})
|
|
19
|
+
assert.equal(r!.qualification, 'relative')
|
|
20
|
+
assert.equal(r!.declaredDurations, 0)
|
|
21
|
+
assert.equal(r!.defaultDurations, 2)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('전부 선언되면 절대 예측 시도 가능 — 단 정확도는 별개(이름이 capable 이다)', () => {
|
|
25
|
+
const r = classifySpecCoverage({
|
|
26
|
+
operations: [
|
|
27
|
+
{ kind: 'cut', duration: 'declared', variability: 'triangular', parameters: ['yield'] },
|
|
28
|
+
{ kind: 'weld', duration: 'declared', variability: 'uniform' }
|
|
29
|
+
]
|
|
30
|
+
})
|
|
31
|
+
assert.equal(r!.qualification, 'absolute-capable')
|
|
32
|
+
assert.equal(r!.varyingDurations, 2)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('섞이면 partial — 한쪽만 접지된 사실을 감추지 않는다', () => {
|
|
36
|
+
const r = classifySpecCoverage({
|
|
37
|
+
operations: [
|
|
38
|
+
{ kind: 'cut', duration: 'declared' },
|
|
39
|
+
{ kind: 'paint', duration: 'default' }
|
|
40
|
+
]
|
|
41
|
+
})
|
|
42
|
+
assert.equal(r!.qualification, 'partial')
|
|
43
|
+
assert.equal(r!.declaredDurations, 1)
|
|
44
|
+
assert.equal(r!.defaultDurations, 1)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('변동 없는 선언은 varying 0 — 고정값이면 대기·병목이 과소평가된다는 신호', () => {
|
|
48
|
+
const r = classifySpecCoverage({ operations: [{ kind: 'cut', duration: 'declared' }] })
|
|
49
|
+
assert.equal(r!.varyingDurations, 0)
|
|
50
|
+
const c = classifySpecCoverage({ operations: [{ kind: 'cut', duration: 'declared', variability: 'constant' }] })
|
|
51
|
+
assert.equal(c!.varyingDurations, 0, 'constant 는 변동이 아니다')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('관측된 작업이 없으면 판정하지 않는다 — 모름을 relative 로 단정하지 않는다', () => {
|
|
55
|
+
assert.equal(classifySpecCoverage({ operations: [] }), undefined)
|
|
56
|
+
assert.equal(classifySpecCoverage({} as any), undefined)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('커널이 보고 API 를 갖지 않으면 undefined — "전부 기본값" 이라 단정하지 않는다', () => {
|
|
60
|
+
assert.equal(readSpecCoverage({}), undefined)
|
|
61
|
+
assert.equal(readSpecCoverage(undefined), undefined)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('커널 보고가 던져도 예측을 깨지 않는다', () => {
|
|
65
|
+
assert.equal(readSpecCoverage({ specCoverage: () => { throw new Error('boom') } }), undefined)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('커널 보고를 그대로 분류한다 — 카운트를 다시 세지 않고 준 값을 존중', () => {
|
|
69
|
+
const r = readSpecCoverage({
|
|
70
|
+
specCoverage: () => ({
|
|
71
|
+
operations: [{ kind: 'cut', duration: 'declared', parameters: ['yield'] }],
|
|
72
|
+
declaredDurations: 1,
|
|
73
|
+
defaultDurations: 0
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
assert.equal(r!.qualification, 'absolute-capable')
|
|
77
|
+
assert.deepEqual(r!.operations[0].parameters, ['yield'])
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
/* ── 실측이 들어오면 자격이 한 칸 올라간다 ─────────────────────────────── */
|
|
81
|
+
|
|
82
|
+
test('전부 이력에서 온 소요면 calibrated — 선언은 사람이 적은 값, 이것은 일어난 값', () => {
|
|
83
|
+
const r = classifySpecCoverage({
|
|
84
|
+
operations: [
|
|
85
|
+
{ kind: 'putaway', duration: 'measured' },
|
|
86
|
+
{ kind: 'pick', duration: 'measured' }
|
|
87
|
+
]
|
|
88
|
+
})
|
|
89
|
+
assert.equal(r!.qualification, 'calibrated')
|
|
90
|
+
assert.equal(r!.measuredDurations, 2)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('실측과 선언이 섞이면 absolute-capable — 상수가 없으면 시각을 말할 수 있다', () => {
|
|
94
|
+
const r = classifySpecCoverage({
|
|
95
|
+
operations: [
|
|
96
|
+
{ kind: 'putaway', duration: 'measured' },
|
|
97
|
+
{ kind: 'cut', duration: 'declared' }
|
|
98
|
+
]
|
|
99
|
+
})
|
|
100
|
+
assert.equal(r!.qualification, 'absolute-capable')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('상수가 하나라도 섞이면 partial — 실측이 있어도 감추지 않는다', () => {
|
|
104
|
+
const r = classifySpecCoverage({
|
|
105
|
+
operations: [
|
|
106
|
+
{ kind: 'putaway', duration: 'measured' },
|
|
107
|
+
{ kind: 'pick', duration: 'default' }
|
|
108
|
+
]
|
|
109
|
+
})
|
|
110
|
+
assert.equal(r!.qualification, 'partial')
|
|
111
|
+
assert.equal(r!.measuredDurations, 1)
|
|
112
|
+
assert.equal(r!.defaultDurations, 1)
|
|
113
|
+
})
|
|
@@ -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
|
+
})
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { test } from 'node:test'
|
|
8
8
|
import assert from 'node:assert/strict'
|
|
9
9
|
|
|
10
|
-
import { bizStepOf, epcOf, orderOf, locationOf,
|
|
10
|
+
import { bizStepOf, epcOf, orderOf, locationOf, equipmentIdOf, twinEventKeys } from '../server/service/twin-event/twin-event-keys.ts'
|
|
11
11
|
|
|
12
12
|
/** EPCIS ObjectEvent 한 건(입고) — 실제 인제스트가 싣는 모양. */
|
|
13
13
|
const EPCIS_OBJECT = {
|
|
@@ -70,7 +70,7 @@ test('없는 값은 빈 문자열이 아니라 undefined — 결측과 빈값을
|
|
|
70
70
|
|
|
71
71
|
test('설비·무버 축 — 운영 델타의 moverId 를 뽑는다', () => {
|
|
72
72
|
const ev = { eventType: 'equipment.status', data: { moverId: 'fork-3', status: 'down', location: 'rack-1' } }
|
|
73
|
-
assert.equal(
|
|
73
|
+
assert.equal(equipmentIdOf(ev), 'fork-3')
|
|
74
74
|
const k = twinEventKeys(ev)
|
|
75
75
|
assert.equal(k.moverId, 'fork-3')
|
|
76
76
|
/* 운영 델타는 readPoint/bizLocation 이 없다 — 평범한 location 을 위치로 받는다.
|