@things-factory/headless-twin 10.0.11 → 10.0.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-server/engine/canonical-ingest.d.ts +25 -2
- package/dist-server/engine/canonical-ingest.js +48 -10
- package/dist-server/engine/canonical-ingest.js.map +1 -1
- package/dist-server/engine/declared-stimulus.d.ts +43 -0
- package/dist-server/engine/declared-stimulus.js +57 -0
- package/dist-server/engine/declared-stimulus.js.map +1 -0
- package/dist-server/engine/index.d.ts +2 -0
- package/dist-server/engine/index.js +2 -0
- package/dist-server/engine/index.js.map +1 -1
- package/dist-server/engine/kpi-fold.d.ts +12 -0
- package/dist-server/engine/kpi-fold.js +21 -1
- package/dist-server/engine/kpi-fold.js.map +1 -1
- package/dist-server/engine/kpi-query.d.ts +3 -3
- package/dist-server/engine/kpi-query.js +4 -4
- package/dist-server/engine/kpi-query.js.map +1 -1
- package/dist-server/engine/live-feed-registry.d.ts +1 -1
- package/dist-server/engine/live-feed-registry.js +1 -1
- package/dist-server/engine/live-feed-registry.js.map +1 -1
- package/dist-server/engine/local-declarations.d.ts +3 -6
- package/dist-server/engine/local-declarations.js +97 -16
- package/dist-server/engine/local-declarations.js.map +1 -1
- package/dist-server/engine/measured-yield.d.ts +42 -0
- package/dist-server/engine/measured-yield.js +75 -0
- package/dist-server/engine/measured-yield.js.map +1 -0
- package/dist-server/engine/operation-basis.d.ts +16 -0
- package/dist-server/engine/operation-basis.js +20 -2
- package/dist-server/engine/operation-basis.js.map +1 -1
- package/dist-server/engine/property-effects.js +17 -0
- package/dist-server/engine/property-effects.js.map +1 -1
- package/dist-server/engine/restart-policy.d.ts +13 -0
- package/dist-server/engine/restart-policy.js +52 -0
- package/dist-server/engine/restart-policy.js.map +1 -0
- package/dist-server/engine/spec-coverage.d.ts +8 -0
- package/dist-server/engine/spec-coverage.js +3 -1
- package/dist-server/engine/spec-coverage.js.map +1 -1
- package/dist-server/engine/twin-engine.d.ts +48 -15
- package/dist-server/engine/twin-engine.js +179 -39
- package/dist-server/engine/twin-engine.js.map +1 -1
- package/dist-server/index.js +1 -1
- package/dist-server/index.js.map +1 -1
- package/dist-server/service/index.d.ts +1 -1
- package/dist-server/service/reference/control-routing.d.ts +14 -0
- package/dist-server/service/reference/control-routing.js +64 -0
- package/dist-server/service/reference/control-routing.js.map +1 -0
- package/dist-server/service/reference/index.d.ts +1 -0
- package/dist-server/service/reference/index.js +2 -0
- package/dist-server/service/reference/index.js.map +1 -1
- package/dist-server/service/reference/reference-adapter.d.ts +67 -0
- package/dist-server/service/reference/reference-adapter.js +18 -0
- package/dist-server/service/reference/reference-adapter.js.map +1 -1
- package/dist-server/service/reference/reference-live.js +3 -3
- package/dist-server/service/reference/reference-live.js.map +1 -1
- package/dist-server/service/reference/reference-resolver.d.ts +3 -3
- package/dist-server/service/reference/reference-resolver.js +35 -18
- package/dist-server/service/reference/reference-resolver.js.map +1 -1
- package/dist-server/service/twin-audit/command-audit.d.ts +34 -0
- package/dist-server/service/twin-audit/command-audit.js +15 -1
- package/dist-server/service/twin-audit/command-audit.js.map +1 -1
- package/dist-server/service/twin-audit/twin-audit-event.d.ts +3 -0
- package/dist-server/service/twin-audit/twin-audit-event.js +10 -0
- package/dist-server/service/twin-audit/twin-audit-event.js.map +1 -1
- package/dist-server/service/twin-control/twin-control-mutation.d.ts +15 -2
- package/dist-server/service/twin-control/twin-control-mutation.js +85 -34
- package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
- package/dist-server/service/twin-instance/twin-instance.d.ts +1 -1
- package/dist-server/service/twin-instance/twin-instance.js +2 -2
- package/dist-server/service/twin-instance/twin-instance.js.map +1 -1
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js +2 -1
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
- package/dist-server/service/twin-model/twin-model-query.js +1 -1
- package/dist-server/service/twin-model/twin-model-query.js.map +1 -1
- package/package.json +3 -3
- package/server/engine/canonical-ingest.ts +78 -14
- package/server/engine/declared-stimulus.ts +66 -0
- package/server/engine/index.ts +2 -0
- package/server/engine/kpi-fold.ts +29 -1
- package/server/engine/kpi-query.ts +8 -8
- package/server/engine/live-feed-registry.ts +2 -2
- package/server/engine/local-declarations.ts +97 -19
- package/server/engine/measured-yield.ts +89 -0
- package/server/engine/operation-basis.ts +33 -2
- package/server/engine/property-effects.ts +17 -0
- package/server/engine/restart-policy.ts +55 -0
- package/server/engine/spec-coverage.ts +23 -3
- package/server/engine/twin-engine.ts +201 -42
- package/server/index.ts +1 -1
- package/server/service/reference/control-routing.ts +62 -0
- package/server/service/reference/index.ts +1 -0
- package/server/service/reference/reference-adapter.ts +78 -0
- package/server/service/reference/reference-live.ts +3 -3
- package/server/service/reference/reference-resolver.ts +46 -7
- package/server/service/twin-audit/command-audit.ts +34 -1
- package/server/service/twin-audit/twin-audit-event.ts +20 -0
- package/server/service/twin-control/twin-control-mutation.ts +75 -28
- package/server/service/twin-instance/twin-instance.ts +16 -10
- package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +2 -1
- package/server/service/twin-model/twin-model-query.ts +1 -1
- package/test/adopt-structure-live.test.ts +7 -7
- package/test/boot-resume.test.ts +3 -3
- package/test/control-capability.test.ts +103 -0
- package/test/declared-stimulus.test.ts +88 -0
- package/test/ingest-running-guard.test.ts +5 -5
- package/test/instance-cache-lifecycle.test.ts +1 -1
- package/test/kpi-baseline-db.test.ts +1 -1
- package/test/kpi-query-bench.test.ts +1 -1
- package/test/lineage-survives-restart.test.ts +3 -3
- package/test/live-feed-registry.test.ts +6 -6
- package/test/local-declarations.test.ts +108 -1
- package/test/measured-yield.test.ts +90 -0
- package/test/operation-basis.test.ts +28 -1
- package/test/operational-vocabulary.test.ts +108 -0
- package/test/operations-capability-db.test.ts +4 -4
- package/test/project-structure-db.test.ts +1 -1
- package/test/projection-reaches-screen.test.ts +1 -1
- package/test/property-effects.test.ts +28 -0
- package/test/restart-policy.test.ts +111 -0
- package/test/resync-origin-site.test.ts +7 -1
- package/test/source-outcome-audit.test.ts +104 -0
- package/test/structure-revision-db.test.ts +27 -27
- package/test/tenant-registry-db.test.ts +2 -2
- package/test/twin-model-item-db.test.ts +3 -3
- package/test/twin-model-tree-db.test.ts +7 -7
- package/test/twin-origin-resync.test.ts +4 -4
- package/test/yield-loop.test.ts +144 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* **라이브 문이 운영 사실을 안다** — 미러가 시뮬보다 가난하지 않게 (ADR-0029 어휘 넓히기, 2026-08-19).
|
|
3
|
+
*
|
|
4
|
+
* ── 무엇이 막혀 있었나 ──────────────────────────────────────────────────────
|
|
5
|
+
* 이 문(`ingestCanonicalRecords`)은 어휘를 셋만 알았다: EPCIS 품목 · 에너지 계량 · 설비 에너지. 그리고
|
|
6
|
+
* **모르는 것은 EPCIS 로 떨어졌다** — 작업 사실을 넣으면 `epc` 가 없어 거부됐고, 원본은 「이 작업이
|
|
7
|
+
* 끝났다」를 말할 길이 없었다. 시뮬 커널만 그것을 알고 미러는 영원히 몰랐다.
|
|
8
|
+
*
|
|
9
|
+
* ── 이 시험이 지키는 것 ─────────────────────────────────────────────────────
|
|
10
|
+
* ① 네 어휘가 **한 문**에서 갈린다(판정은 커널의 것이고 여기서 다시 짐작하지 않는다).
|
|
11
|
+
* ② 운영 사실이 자기 봉투로 나온다 — EPCIS 로 감싸지지 않는다.
|
|
12
|
+
* ③ 섞여 와도 갈린다(실 피드는 한 배치에 여러 어휘가 섞인다).
|
|
13
|
+
* ④ 거부는 이유와 함께 남는다 — 피드 결선이 그 이유를 사람에게 말한다.
|
|
14
|
+
*/
|
|
15
|
+
import { test } from 'node:test'
|
|
16
|
+
import assert from 'node:assert/strict'
|
|
17
|
+
import { readFileSync } from 'node:fs'
|
|
18
|
+
import { fileURLToPath } from 'node:url'
|
|
19
|
+
|
|
20
|
+
import { ingestCanonicalRecords } from '../server/engine/canonical-ingest.ts'
|
|
21
|
+
|
|
22
|
+
const AT = '2026-08-19T09:00:00.000Z'
|
|
23
|
+
|
|
24
|
+
test('운영 사실이 자기 봉투로 나온다 — EPCIS 로 감싸지지 않는다', () => {
|
|
25
|
+
const { accepted, rejected } = ingestCanonicalRecords(
|
|
26
|
+
[{ taskId: 'tk-1', kind: 'putaway', status: 'completed' } as any],
|
|
27
|
+
'domain-1',
|
|
28
|
+
AT
|
|
29
|
+
)
|
|
30
|
+
assert.deepEqual(rejected, [])
|
|
31
|
+
assert.equal(accepted.length, 1)
|
|
32
|
+
assert.equal(accepted[0].eventType, 'task.status')
|
|
33
|
+
assert.equal(accepted[0].eventTime, AT, '시각을 주지 않으면 이 문이 정한 기본 시각을 쓴다')
|
|
34
|
+
assert.equal(accepted[0].tenantId, 'domain-1')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('네 어휘가 한 배치에서 갈린다 — 실 피드는 섞여 온다', () => {
|
|
38
|
+
const { accepted, rejected } = ingestCanonicalRecords(
|
|
39
|
+
[
|
|
40
|
+
{ epc: 'urn:epc:id:sgtin:1.1.1', action: 'ADD', bizStep: 'urn:epcglobal:cbv:bizstep:receiving', readPoint: 'dock-1' },
|
|
41
|
+
{ meterId: 'm-1', kW: 120 },
|
|
42
|
+
{ equipmentId: 'pv-1', generatedKW: 40 },
|
|
43
|
+
{ moverId: 'fk-1', kind: 'forklift', status: 'busy' }, // vocabulary-guard: allow 저널 와이어 필드
|
|
44
|
+
{ orderId: 'or-1', kind: 'outbound', status: 'picking', requested: 10, fulfilled: 3 }
|
|
45
|
+
] as any,
|
|
46
|
+
'domain-1',
|
|
47
|
+
AT
|
|
48
|
+
)
|
|
49
|
+
assert.deepEqual(rejected, [], '어느 어휘도 남의 길로 떨어지지 않는다')
|
|
50
|
+
assert.deepEqual(accepted.map(e => e.eventType).sort(), [
|
|
51
|
+
'energy.equipment', 'energy.measured', 'epcis.ObjectEvent', 'equipment.status', 'order.status'
|
|
52
|
+
].sort())
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('거부는 이유와 함께 남는다 — 유효한 것은 함께 통과한다', () => {
|
|
56
|
+
const { accepted, rejected } = ingestCanonicalRecords(
|
|
57
|
+
[
|
|
58
|
+
{ taskId: 'tk-1', kind: 'pick', status: 'completed' },
|
|
59
|
+
{ taskId: 'tk-2', kind: 'pick', status: 'FINISHED' } // 커널이 접을 수 없는 낱말
|
|
60
|
+
] as any,
|
|
61
|
+
'domain-1',
|
|
62
|
+
AT
|
|
63
|
+
)
|
|
64
|
+
assert.equal(accepted.length, 1)
|
|
65
|
+
assert.equal(rejected.length, 1)
|
|
66
|
+
assert.ok(rejected[0].errors.join(' ').length > 0)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('피드 결선이 거부를 조용히 버리지 않는다 — 이유를 사람에게 말한다', () => {
|
|
70
|
+
const src = readFileSync(fileURLToPath(new URL('../server/service/reference/reference-live.ts', import.meta.url)), 'utf-8')
|
|
71
|
+
assert.match(src, /record\(s\) rejected/, '거부 수와 이유를 남긴다')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
/*
|
|
75
|
+
* ── 담김도 이 문을 지난다 (2026-08-19) ──────────────────────────────────────
|
|
76
|
+
* 실 앱에서 한 바퀴 돌리자 「옮기지 못했다」 로그가 `epcis.AggregationEvent` 를 짚었다 — 관측 리듀서는 그
|
|
77
|
+
* 사실로 자식 물품에 `parent` 를 붙이는데(3D 가 「이 상자가 어느 팔레트에 실렸나」를 묻는 자리) 문에 그
|
|
78
|
+
* 어휘가 없었다.
|
|
79
|
+
*/
|
|
80
|
+
test('담김 레코드가 자기 봉투로 나온다 — 개체 관측과 한 배치에 섞여 와도 갈린다', () => {
|
|
81
|
+
const { accepted, rejected } = ingestCanonicalRecords(
|
|
82
|
+
[
|
|
83
|
+
{ epc: 'urn:epc:id:sgtin:1.1.1', action: 'ADD', bizStep: 'urn:epcglobal:cbv:bizstep:receiving', readPoint: 'dock-1' },
|
|
84
|
+
{ parentID: 'urn:epc:id:sscc:0614141.0000000001', childEPCs: ['urn:epc:id:sgtin:1.1.1'], action: 'ADD', bizStep: 'urn:epcglobal:cbv:bizstep:packing', readPoint: 'packing' }
|
|
85
|
+
] as any,
|
|
86
|
+
'domain-1',
|
|
87
|
+
AT
|
|
88
|
+
)
|
|
89
|
+
assert.deepEqual(rejected, [])
|
|
90
|
+
assert.deepEqual(accepted.map(e => e.eventType), ['epcis.ObjectEvent', 'epcis.AggregationEvent'])
|
|
91
|
+
assert.equal((accepted[1].data as any).parentID, 'urn:epc:id:sscc:0614141.0000000001')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('자리를 구조째 실으면 거부한다 — 예전에는 "[object Object]" 로 굳어 저널에 쌓였다', () => {
|
|
95
|
+
const { accepted, rejected } = ingestCanonicalRecords(
|
|
96
|
+
[{ epc: 'urn:epc:id:sgtin:1.1.1', action: 'ADD', bizStep: 'urn:epcglobal:cbv:bizstep:receiving', readPoint: { id: 'dock-1' } }] as any,
|
|
97
|
+
'domain-1',
|
|
98
|
+
AT
|
|
99
|
+
)
|
|
100
|
+
assert.equal(accepted.length, 0)
|
|
101
|
+
assert.match(rejected[0].errors.join(' '), /readPoint/)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('이 문이 어휘가 넷임을 스스로 말한다 — 다음 사람이 세 어휘로 읽지 않게', () => {
|
|
105
|
+
const src = readFileSync(fileURLToPath(new URL('../server/engine/canonical-ingest.ts', import.meta.url)), 'utf-8')
|
|
106
|
+
assert.match(src, /어휘가 넷이다/)
|
|
107
|
+
assert.match(src, /isOperationalRecord/, '판정은 커널의 것을 부른다(여기서 다시 짐작하지 않는다)')
|
|
108
|
+
})
|
|
@@ -102,8 +102,8 @@ before(async () => {
|
|
|
102
102
|
q = new TwinJournalQuery()
|
|
103
103
|
ctx = { state: { domain: { id: domainId } } }
|
|
104
104
|
|
|
105
|
-
await TwinEngine.ingestMaster(domainId, mesMaster())
|
|
106
|
-
await TwinEngine.ingestMaster(domainId, wmsMaster())
|
|
105
|
+
await TwinEngine.ingestMaster(domainId, mesMaster(), 'reset')
|
|
106
|
+
await TwinEngine.ingestMaster(domainId, wmsMaster(), 'reset')
|
|
107
107
|
})
|
|
108
108
|
|
|
109
109
|
after(async () => {
|
|
@@ -122,7 +122,7 @@ test('실행 중이 아니면 그 사실을 알린다 — "없는 것" 과 구
|
|
|
122
122
|
})
|
|
123
123
|
|
|
124
124
|
test('돌고 있으면 구간의 양을 종류로 갈라 낸다 — 표준 등식이 성립한다', async () => {
|
|
125
|
-
await TwinEngine.startFromRegistry(domainId, MES, '
|
|
125
|
+
await TwinEngine.startFromRegistry(domainId, MES, 'reset')
|
|
126
126
|
const rows = await ask(MES)
|
|
127
127
|
assert.equal(rows[0].reason, undefined)
|
|
128
128
|
|
|
@@ -138,7 +138,7 @@ test('돌고 있으면 구간의 양을 종류로 갈라 낸다 — 표준 등
|
|
|
138
138
|
})
|
|
139
139
|
|
|
140
140
|
test('공정을 선언하지 않은 트윈은 판정 대상이 아니다 — 0 으로 채우면 없는 병목이 생긴다', async () => {
|
|
141
|
-
await TwinEngine.startFromRegistry(domainId, WMS, '
|
|
141
|
+
await TwinEngine.startFromRegistry(domainId, WMS, 'reset')
|
|
142
142
|
const rows = await q.twinOperationsCapability(WMS, undefined, 24, WINDOW.fromTime, WINDOW.toTime, WINDOW.fromTime, ctx)
|
|
143
143
|
assert.equal(rows[0].reason, 'no-operations')
|
|
144
144
|
assert.equal(rows[0].report, undefined)
|
|
@@ -184,7 +184,7 @@ before(async () => {
|
|
|
184
184
|
q = new TwinModelItemQuery()
|
|
185
185
|
ctx = { state: { domain: { id: domainId } } }
|
|
186
186
|
|
|
187
|
-
await TwinEngine.ingestMaster(domainId, master())
|
|
187
|
+
await TwinEngine.ingestMaster(domainId, master(), 'reset')
|
|
188
188
|
})
|
|
189
189
|
|
|
190
190
|
after(async () => {
|
|
@@ -94,3 +94,31 @@ test('공정 시간이 등록돼 있고, 그 값을 실어 보내는 자리를
|
|
|
94
94
|
assert.match(layer, /isoDurationOf/, '커널이 읽는 표기로 바꾼다')
|
|
95
95
|
assert.match(hostSrc('engine/twin-engine.ts'), /declareDurations\(declared\)/, '기동·구조전환 때 커널에 싣는다')
|
|
96
96
|
})
|
|
97
|
+
|
|
98
|
+
/*
|
|
99
|
+
* 모수도 같은 배선을 지난다 (2026-08-19).
|
|
100
|
+
*
|
|
101
|
+
* 이 시험이 소스를 보는 이유: 모수를 읽는 순간은 **작업이 완료될 때**라서 실 앱에서 관측하려면 라인이
|
|
102
|
+
* 한 바퀴 돌아야 한다(복원된 25분 작업이 쌓인 트윈에서는 그 관측이 오래 걸린다 — 실제로 그랬다).
|
|
103
|
+
* 값이 커널까지 가는 **경로**는 여기서 지키고, 커널이 그 값을 쓰는지는 커널 시험이 값으로 지킨다.
|
|
104
|
+
*/
|
|
105
|
+
test('수율·셋업이 커널 창구까지 배선돼 있다 — 등록만 하고 나르지 않으면 화면이 거짓을 말한다', () => {
|
|
106
|
+
const layer = hostSrc('engine/local-declarations.ts')
|
|
107
|
+
assert.match(layer, /OPERATION_PROPERTY\.yield/, '선언 겹이 수율을 안다')
|
|
108
|
+
assert.match(layer, /OPERATION_PROPERTY\.setupDuration/, '선언 겹이 셋업을 안다')
|
|
109
|
+
/* 퍼센트 → 비율 환산은 **한 곳**이어야 한다(두 곳이면 100 이 두 번 걸린다). */
|
|
110
|
+
const divisions = layer.match(/pct \/ 100/g) ?? []
|
|
111
|
+
assert.equal(divisions.length, 1, `퍼센트 환산은 한 자리에서만 (발견 ${divisions.length})`)
|
|
112
|
+
assert.match(hostSrc('engine/twin-engine.ts'), /declareParameters\(params\)/, '기동·구조전환 때 커널에 싣는다')
|
|
113
|
+
/* 커널이 그 창구를 갖지 않은 버전이면 **말한다** — 조용히 상수로 돌지 않게. */
|
|
114
|
+
assert.match(hostSrc('engine/twin-engine.ts'), /declareParameters missing/, '버전 어긋남을 경고로 드러낸다')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('공정 모수가 등록부에 있고 읽는 자리를 가리킨다', () => {
|
|
118
|
+
for (const id of ['operation.yield', 'operation.setupDuration']) {
|
|
119
|
+
const e = PROPERTY_EFFECTS.find(x => x.id === id)!
|
|
120
|
+
assert.ok(e, `${id} 가 등록부에 있어야 한다`)
|
|
121
|
+
assert.deepEqual(e.axes, ['operations'])
|
|
122
|
+
assert.match(e.readBy, /declareParameters/, '어느 창구로 가는지 말한다')
|
|
123
|
+
}
|
|
124
|
+
})
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* **재기동 정책** — 개명(ADR-0029 §2)과 그 규율 둘.
|
|
3
|
+
*
|
|
4
|
+
* ── 무엇을 바꿨나 ───────────────────────────────────────────────────────────
|
|
5
|
+
* `realityMode`(`mirror`·`sim-world`·`sim-experiment`) → `restartPolicy`(`resync`·`resume`·`reset`).
|
|
6
|
+
* 「현실이 어디서 오나」는 ADR-0029 가 **원본의 종류**로 옮긴 물음이고, 코드가 이 축으로 실제로 가른 것은
|
|
7
|
+
* **재기동 거동 하나**였다 — 그것은 원본의 성질이 아니라 트윈과 자기 과거의 관계다. 그래서 축은 남고
|
|
8
|
+
* 이름이 바뀌었다.
|
|
9
|
+
*
|
|
10
|
+
* ── 지키는 규율 둘 (계획서 §6) ──────────────────────────────────────────────
|
|
11
|
+
* ① **모르는 값은 던진다.** 예전에는 미선언이 조용히 `sim-experiment`(=저널 초기화)로 떨어졌다 — 값이
|
|
12
|
+
* 하나 어긋나는 순간 이력을 지우는 길이다. 기본값을 없앴다.
|
|
13
|
+
* ② **옛 어휘를 번역하지 않는다.** 번역을 두면 옛 값이 살아남고 다시 저장된다. 저장된 값은 한 번
|
|
14
|
+
* 옮기고(데이터 이관), 코드는 새 어휘만 안다 — 다만 무엇으로 옮겨야 하는지는 오류 문장이 말한다.
|
|
15
|
+
*/
|
|
16
|
+
import { test } from 'node:test'
|
|
17
|
+
import assert from 'node:assert/strict'
|
|
18
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
19
|
+
import { fileURLToPath } from 'node:url'
|
|
20
|
+
import { join } from 'node:path'
|
|
21
|
+
|
|
22
|
+
import { readRestartPolicy, isRestartPolicy, RESTART_POLICIES } from '../server/engine/restart-policy.ts'
|
|
23
|
+
|
|
24
|
+
test('셋뿐이다 — 그리고 그 셋을 읽는다', () => {
|
|
25
|
+
assert.deepEqual([...RESTART_POLICIES], ['resync', 'resume', 'reset'])
|
|
26
|
+
for (const p of RESTART_POLICIES) assert.equal(readRestartPolicy(p, 'test'), p)
|
|
27
|
+
assert.equal(readRestartPolicy(' resume ', 'test'), 'resume', '공백은 다듬는다')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('미선언은 던진다 — 기본값으로 메우면 저널이 지워지는 길이 열린다', () => {
|
|
31
|
+
for (const raw of [undefined, null, '', ' ', 42, {}]) {
|
|
32
|
+
assert.throws(() => readRestartPolicy(raw, 'twin "x"'), /twin "x": restart policy/)
|
|
33
|
+
}
|
|
34
|
+
/* 무엇을 해야 하는지 문장이 말한다 — 코드를 그대로 던지지 않는다. */
|
|
35
|
+
try {
|
|
36
|
+
readRestartPolicy(undefined, 'twin "x"')
|
|
37
|
+
assert.fail('던져야 한다')
|
|
38
|
+
} catch (e: any) {
|
|
39
|
+
assert.match(e.message, /resync \| resume \| reset/)
|
|
40
|
+
assert.match(e.message, /no default/)
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('옛 어휘도 던진다 — 다만 무엇으로 옮길지 말한다(번역해 주지 않는다)', () => {
|
|
45
|
+
const cases: [string, string][] = [
|
|
46
|
+
['mirror', 'resync'],
|
|
47
|
+
['sim-world', 'resume'],
|
|
48
|
+
['sim-experiment', 'reset']
|
|
49
|
+
]
|
|
50
|
+
for (const [old, next] of cases) {
|
|
51
|
+
try {
|
|
52
|
+
readRestartPolicy(old, 'twin "x"')
|
|
53
|
+
assert.fail(`${old} 은 던져야 한다`)
|
|
54
|
+
} catch (e: any) {
|
|
55
|
+
assert.match(e.message, /old vocabulary/)
|
|
56
|
+
assert.match(e.message, new RegExp(`"${next}"`), '옮길 값을 말한다')
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('묻는 자리도 있다 — 화면·검사는 던지지 않고 판별한다', () => {
|
|
62
|
+
assert.equal(isRestartPolicy('resume'), true)
|
|
63
|
+
assert.equal(isRestartPolicy('sim-world'), false)
|
|
64
|
+
assert.equal(isRestartPolicy(undefined), false)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
/*
|
|
68
|
+
* ── 옛 어휘가 코드로 되돌아오지 않게 (계획서 §6 의 검사) ────────────────────
|
|
69
|
+
* ADR-0034 의 `local-declarations.test.ts` 가 `site*` 에 대해 하는 것과 같은 방식이다. 개명은 저장값·타입·
|
|
70
|
+
* 화면에 걸쳐 있어서, 한 곳만 되돌아와도 그 자리가 조용히 다르게 재기동한다.
|
|
71
|
+
*/
|
|
72
|
+
const OLD_TOKENS = [/\brealityMode\b/, /\bRealityMode\b/, /'sim-world'/, /'sim-experiment'/, /DEFAULT_REALITY_MODE/]
|
|
73
|
+
|
|
74
|
+
function sources(root: string): { file: string; src: string }[] {
|
|
75
|
+
const base = fileURLToPath(new URL(root, import.meta.url))
|
|
76
|
+
const out: { file: string; src: string }[] = []
|
|
77
|
+
const walk = (dir: string) => {
|
|
78
|
+
for (const name of readdirSync(dir)) {
|
|
79
|
+
if (name === 'node_modules' || name.startsWith('dist')) continue
|
|
80
|
+
const path = join(dir, name)
|
|
81
|
+
if (statSync(path).isDirectory()) { walk(path); continue }
|
|
82
|
+
if (name.endsWith('.ts')) out.push({ file: path.slice(base.length + 1), src: readFileSync(path, 'utf-8') })
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
walk(base)
|
|
86
|
+
return out
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
test('옛 어휘가 코드에 없다 — 이력을 설명하는 주석만 예외다', () => {
|
|
90
|
+
const offenders: string[] = []
|
|
91
|
+
for (const { file, src } of [...sources('../server'), ...sources('../test')]) {
|
|
92
|
+
/* 이력 설명(「예전에는 …」)과 이관 안내(restart-policy.ts)는 남아야 한다 — 그것이 개명의 근거다. */
|
|
93
|
+
if (file.endsWith('restart-policy.ts') || file.endsWith('restart-policy.test.ts')) continue
|
|
94
|
+
for (const line of src.split('\n')) {
|
|
95
|
+
const isComment = /^\s*(\*|\/\/|\/\*)/.test(line)
|
|
96
|
+
if (isComment) continue
|
|
97
|
+
if (OLD_TOKENS.some(re => re.test(line))) offenders.push(`${file}: ${line.trim().slice(0, 80)}`)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
assert.deepEqual(offenders, [], `옛 어휘가 코드로 돌아왔다:\n${offenders.join('\n')}`)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('기본값 상수가 없다 — 미선언을 우리가 고르지 않는다', () => {
|
|
104
|
+
const engine = readFileSync(fileURLToPath(new URL('../server/engine/twin-engine.ts', import.meta.url)), 'utf-8')
|
|
105
|
+
const code = engine
|
|
106
|
+
.split('\n')
|
|
107
|
+
.filter(line => !/^\s*(\*|\/\/|\/\*)/.test(line))
|
|
108
|
+
.join('\n')
|
|
109
|
+
assert.equal(/DEFAULT_RESTART_POLICY/.test(code), false, '기본값 상수가 되살아나면 미선언이 다시 조용해진다')
|
|
110
|
+
assert.match(code, /readRestartPolicy\(/, '읽는 자리는 엄격한 리더를 지난다')
|
|
111
|
+
})
|
|
@@ -73,5 +73,11 @@ test('프로비저닝이 고른 현장을 origin 에 남기고, 다시 읽기가
|
|
|
73
73
|
.join('\n')
|
|
74
74
|
assert.match(code, /kind: 'template', templateId, params: effective, \.\.\.\(spaceId \? \{ spaceId \} : \{\}\)/, '템플릿 트윈이 현장을 기억한다')
|
|
75
75
|
assert.match(code, /const keepSpace = origin\.spaceId \|\| inst\.spaceId/, '기억이 없으면 지금 있는 현장을 지킨다')
|
|
76
|
-
|
|
76
|
+
/* 인자 사이에 재기동 정책이 끼었다(ADR-0029 §2) — 현장을 넘기는 인자는 그대로여야 한다. */
|
|
77
|
+
assert.match(code, /keepSpace \? \{ spaceId: keepSpace \} : undefined/, '다시 읽기가 그 현장으로 넣는다')
|
|
78
|
+
assert.match(
|
|
79
|
+
code,
|
|
80
|
+
/readRestartPolicy\(inst\.restartPolicy, `resync of twin/,
|
|
81
|
+
'다시 읽기는 그 트윈의 정책을 그대로 지킨다(구조만 새로 그린다)'
|
|
82
|
+
)
|
|
77
83
|
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* **허락과 일어남 사이를 같은 행에 닫는다** — ADR-0029 §1 결정 C (2026-08-19).
|
|
3
|
+
*
|
|
4
|
+
* ── 무엇이 열려 있었나 ──────────────────────────────────────────────────────
|
|
5
|
+
* 명령이 원본으로 가면 감사는 「허락했다」까지만 증명한다: 어댑터가 조용히 실패하면 원장에는 승인만 남는다.
|
|
6
|
+
* 그리고 구동 문(`controlTwinSource`)은 감사를 **아예 남기지 않았다** — 자극을 바꾸고 배속을 올리는 것은
|
|
7
|
+
* 그 트윈의 사실을 바꾸는 행위인데, 나중에 「무엇이 이 공장을 흔들었나」에 답할 수 없었다.
|
|
8
|
+
*
|
|
9
|
+
* ── 세 값을 구별한다 ────────────────────────────────────────────────────────
|
|
10
|
+
* · 칸이 **없음** — 원본에 묻지 않았다(커널이 받은 명령).
|
|
11
|
+
* · `unknown` — 물었는데 답이 없다. **성공으로 적지 않는다.**
|
|
12
|
+
* · `applied`·`refused` — 원본이 답했다(둘 다 사실이다).
|
|
13
|
+
*
|
|
14
|
+
* 셋을 뭉개면 둘 중 하나가 된다: 일어난 일을 없던 일로 만들거나, 없던 일을 일어난 일로 만든다.
|
|
15
|
+
*/
|
|
16
|
+
import { test } from 'node:test'
|
|
17
|
+
import assert from 'node:assert/strict'
|
|
18
|
+
import { readFileSync } from 'node:fs'
|
|
19
|
+
import { fileURLToPath } from 'node:url'
|
|
20
|
+
|
|
21
|
+
import { buildCommandAudit, sourceOutcomeOf } from '../server/service/twin-audit/command-audit.ts'
|
|
22
|
+
|
|
23
|
+
const at = new Date('2026-08-19T05:00:00.000Z')
|
|
24
|
+
|
|
25
|
+
test('묻지 않았으면 칸을 만들지 않는다 — 커널이 받은 명령이 그렇다', () => {
|
|
26
|
+
const row = buildCommandAudit({ command: { type: 'order.hold', origin: 'user' }, ack: { accepted: true }, at })
|
|
27
|
+
assert.equal('sourceOutcome' in row, false, '없음은 「모른다」가 아니라 「묻지 않았다」다')
|
|
28
|
+
assert.equal('sourceCode' in row, false)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('물었는데 답이 없으면 unknown — 성공으로 적지 않는다', () => {
|
|
32
|
+
const row = buildCommandAudit({
|
|
33
|
+
command: { type: 'source.stimulus', origin: 'user' },
|
|
34
|
+
ack: { accepted: true },
|
|
35
|
+
source: { answered: false },
|
|
36
|
+
at
|
|
37
|
+
})
|
|
38
|
+
assert.equal(row.sourceOutcome, 'unknown')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('답이 있으면 그대로 적는다 — 거절도 사실이다', () => {
|
|
42
|
+
const applied = buildCommandAudit({ command: {}, ack: { accepted: true }, source: { answered: true, ok: true }, at })
|
|
43
|
+
assert.equal(applied.sourceOutcome, 'applied')
|
|
44
|
+
const refused = buildCommandAudit({
|
|
45
|
+
command: {},
|
|
46
|
+
ack: { accepted: true },
|
|
47
|
+
source: { answered: true, ok: false, code: 'not-declared' },
|
|
48
|
+
at
|
|
49
|
+
})
|
|
50
|
+
assert.equal(refused.sourceOutcome, 'refused')
|
|
51
|
+
assert.equal(refused.sourceCode, 'not-declared', '원본이 준 사유를 그대로 적는다')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('사유가 없으면 사유 칸도 없다 — 지어내지 않는다', () => {
|
|
55
|
+
const row = buildCommandAudit({ command: {}, ack: { accepted: true }, source: { answered: true, ok: true }, at })
|
|
56
|
+
assert.equal('sourceCode' in row, false)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('판정 함수가 세 값만 낸다 — ok 가 참/거짓이 아니면 모른다', () => {
|
|
60
|
+
assert.equal(sourceOutcomeOf({ answered: true, ok: true }), 'applied')
|
|
61
|
+
assert.equal(sourceOutcomeOf({ answered: true, ok: false }), 'refused')
|
|
62
|
+
assert.equal(sourceOutcomeOf({ answered: false }), 'unknown')
|
|
63
|
+
assert.equal(sourceOutcomeOf({ answered: true }), 'unknown', '답했다고 하면서 값이 없으면 모르는 것이다')
|
|
64
|
+
assert.equal(sourceOutcomeOf({ answered: true, ok: undefined as any }), 'unknown')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('신원은 여전히 서버만 채운다 — 원본의 답이 그 규율을 흔들지 않는다', () => {
|
|
68
|
+
const row = buildCommandAudit({
|
|
69
|
+
command: { type: 'source.pause', origin: 'user', actorId: 'forged', actorEmail: 'forged@x' },
|
|
70
|
+
ack: { accepted: true },
|
|
71
|
+
source: { answered: true, ok: true },
|
|
72
|
+
at,
|
|
73
|
+
actor: { id: 'u1', email: 'real@x' }
|
|
74
|
+
})
|
|
75
|
+
assert.equal(row.actorId, 'u1')
|
|
76
|
+
assert.equal(row.actorEmail, 'real@x')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
/*
|
|
80
|
+
* ── 구동 문이 실제로 남기는지 (소스로 지킨다) ───────────────────────────────
|
|
81
|
+
* 순수 함수만 시험하면 「기록을 만들 수 있다」까지만 지킨다. 남기지 않으면 그 함수는 아무 일도 하지 않는다.
|
|
82
|
+
*/
|
|
83
|
+
test('구동 문이 게이트 뒤에서 원본의 답까지 남긴다', () => {
|
|
84
|
+
const src = readFileSync(fileURLToPath(new URL('../server/service/twin-control/twin-control-mutation.ts', import.meta.url)), 'utf-8')
|
|
85
|
+
const code = src
|
|
86
|
+
.split('\n')
|
|
87
|
+
.filter(line => !/^\s*(\*|\/\/|\/\*)/.test(line))
|
|
88
|
+
.join('\n')
|
|
89
|
+
/* 게이트는 남는다(결정 C 의 절반). */
|
|
90
|
+
const gates = code.match(/@privilege\(category: "twin", privilege: "mutation"/g) ?? []
|
|
91
|
+
assert.ok(gates.length >= 2, `구동 문에도 게이트가 있어야 한다(발견 ${gates.length})`)
|
|
92
|
+
/* 원본의 답을 같은 행에 닫는다. */
|
|
93
|
+
assert.match(code, /source: \{ answered: typeof result\?\.ok === 'boolean', ok: result\?\.ok, code: result\?\.code \}/)
|
|
94
|
+
assert.match(code, /type: `source\.\$\{action\}`/, '커맨드 채널과 같은 어휘로 적는다')
|
|
95
|
+
/* 기록 실패를 삼키지 않는다. */
|
|
96
|
+
assert.match(code, /source control audit not recorded/)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('엔티티가 그 두 칸을 든다 — 비어 있음이 뜻을 갖는다', () => {
|
|
100
|
+
const src = readFileSync(fileURLToPath(new URL('../server/service/twin-audit/twin-audit-event.ts', import.meta.url)), 'utf-8')
|
|
101
|
+
assert.match(src, /sourceOutcome\?: string/)
|
|
102
|
+
assert.match(src, /sourceCode\?: string/)
|
|
103
|
+
assert.match(src, /the source was not asked/, '비어 있음의 뜻을 필드 설명이 말한다')
|
|
104
|
+
})
|
|
@@ -95,9 +95,9 @@ beforeEach(async () => {
|
|
|
95
95
|
|
|
96
96
|
test('같은 구조로 다시 프로비저닝하면 리비전이 늘지 않는다 — 부팅마다 늘면 이력이 잘게 쪼개진다', async () => {
|
|
97
97
|
const model = boardWith(2, 1)
|
|
98
|
-
await TwinEngine.provision(domainId, INSTANCE, 'wms', model)
|
|
99
|
-
await TwinEngine.provision(domainId, INSTANCE, 'wms', model)
|
|
100
|
-
await TwinEngine.provision(domainId, INSTANCE, 'wms', JSON.parse(JSON.stringify(model)))
|
|
98
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', model, 'reset')
|
|
99
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', model, 'reset')
|
|
100
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', JSON.parse(JSON.stringify(model)), 'reset')
|
|
101
101
|
|
|
102
102
|
const revs = await ds.getRepository(TwinStructure).find({ where: { domain: { id: domainId }, instanceId: INSTANCE } })
|
|
103
103
|
assert.equal(revs.length, 1)
|
|
@@ -105,12 +105,12 @@ test('같은 구조로 다시 프로비저닝하면 리비전이 늘지 않는
|
|
|
105
105
|
})
|
|
106
106
|
|
|
107
107
|
test('구조가 바뀌면 새 리비전이 생기고 **저널은 살아남는다**', async () => {
|
|
108
|
-
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1))
|
|
108
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1), 'reset')
|
|
109
109
|
const first = realEvents(boardWith(2, 1), 300)
|
|
110
110
|
assert.ok(first.length > 0, '이벤트가 있어야 "살아남는지" 를 물을 수 있다')
|
|
111
111
|
await TwinEngine.persistBatch(domainId, INSTANCE, first, 0)
|
|
112
112
|
|
|
113
|
-
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(6, 3))
|
|
113
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(6, 3), 'reset')
|
|
114
114
|
|
|
115
115
|
const kept = await ds.getRepository(TwinEvent).count({ where: { domain: { id: domainId }, instanceId: INSTANCE } })
|
|
116
116
|
assert.equal(kept, first.length, '구조가 바뀌었다고 이력을 지우면 안 된다 — 그것이 이 작업의 전부다')
|
|
@@ -121,9 +121,9 @@ test('구조가 바뀌면 새 리비전이 생기고 **저널은 살아남는다
|
|
|
121
121
|
})
|
|
122
122
|
|
|
123
123
|
test('이벤트가 자기 구조 번호를 달고 저장된다 — 안 달면 나중에 어느 공장인지 알 길이 없다', async () => {
|
|
124
|
-
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1))
|
|
124
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1), 'reset')
|
|
125
125
|
await TwinEngine.persistBatch(domainId, INSTANCE, realEvents(boardWith(2, 1), 200), 0)
|
|
126
|
-
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(6, 3))
|
|
126
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(6, 3), 'reset')
|
|
127
127
|
const before = await ds.getRepository(TwinEvent).count({ where: { domain: { id: domainId }, instanceId: INSTANCE } })
|
|
128
128
|
await TwinEngine.persistBatch(domainId, INSTANCE, realEvents(boardWith(6, 3), 200), before)
|
|
129
129
|
|
|
@@ -135,11 +135,11 @@ test('이벤트가 자기 구조 번호를 달고 저장된다 — 안 달면
|
|
|
135
135
|
})
|
|
136
136
|
|
|
137
137
|
test('★ 재생이 경계를 넘는다 — 앞선 사실은 남고, 새 구조의 설비가 들어온다', async () => {
|
|
138
|
-
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1))
|
|
138
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1), 'reset')
|
|
139
139
|
const first = realEvents(boardWith(2, 1), 300)
|
|
140
140
|
await TwinEngine.persistBatch(domainId, INSTANCE, first, 0)
|
|
141
141
|
|
|
142
|
-
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(6, 3))
|
|
142
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(6, 3), 'reset')
|
|
143
143
|
await TwinEngine.persistBatch(domainId, INSTANCE, realEvents(boardWith(6, 3), 300), first.length)
|
|
144
144
|
|
|
145
145
|
const state = await TwinEngine.recover(domainId, INSTANCE)
|
|
@@ -153,20 +153,20 @@ test('구조 번호가 없는 옛 행은 **가장 오래된 구조**로 접는
|
|
|
153
153
|
* 컬럼이 생기기 전에 쓰인 행은 `structureRev` 가 비어 있다. 그것을 현재 보드로 접으면 그때 없던
|
|
154
154
|
* 설비에서 일이 있었던 것처럼 보인다 — 이 규칙이 그 행들을 제자리에 붙인다.
|
|
155
155
|
*/
|
|
156
|
-
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1))
|
|
156
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1), 'reset')
|
|
157
157
|
const legacy = realEvents(boardWith(2, 1), 300)
|
|
158
158
|
await TwinEngine.persistBatch(domainId, INSTANCE, legacy, 0)
|
|
159
159
|
/* 컬럼이 없던 시절을 흉내 낸다 — 번호를 지운다. */
|
|
160
160
|
await ds.query(`UPDATE "${ds.getMetadata(TwinEvent).tableName}" SET structure_rev = NULL`)
|
|
161
161
|
|
|
162
|
-
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(6, 3))
|
|
162
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(6, 3), 'reset')
|
|
163
163
|
const state = await TwinEngine.recover(domainId, INSTANCE)
|
|
164
164
|
assert.ok(state.items.length > 0, '번호 없는 옛 행도 읽혀야 한다 — 조용히 버리지 않는다')
|
|
165
165
|
assert.equal(state.equipment.length, 3, '마지막 구조가 자원의 진실이다')
|
|
166
166
|
})
|
|
167
167
|
|
|
168
168
|
test('리비전이 하나뿐이면 예전과 똑같이 접는다 — 구조 전환이 결과를 바꾸지 않는다', async () => {
|
|
169
|
-
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(3, 2))
|
|
169
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(3, 2), 'reset')
|
|
170
170
|
const events = realEvents(boardWith(3, 2), 300)
|
|
171
171
|
await TwinEngine.persistBatch(domainId, INSTANCE, events, 0)
|
|
172
172
|
|
|
@@ -177,7 +177,7 @@ test('리비전이 하나뿐이면 예전과 똑같이 접는다 — 구조 전
|
|
|
177
177
|
})
|
|
178
178
|
|
|
179
179
|
test('꺼진 트윈의 이력도 읽을 수 있다 — 저널은 트윈이 꺼져 있을 때 가장 필요하다', async () => {
|
|
180
|
-
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1))
|
|
180
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1), 'reset')
|
|
181
181
|
assert.equal(TwinEngine.owns(domainId, INSTANCE), false, '떠 있지 않다')
|
|
182
182
|
assert.equal(await TwinEngine.ownsRegistered(domainId, INSTANCE), true, '그래도 이 테넌트 것이다')
|
|
183
183
|
assert.equal(await TwinEngine.ownsRegistered(domainId, 'someone-elses-twin'), false)
|
|
@@ -189,11 +189,11 @@ test('시간여행은 **그때의 공장**을 보여준다 — 과거에 최신
|
|
|
189
189
|
* 그런데 그 규칙을 과거 조회에도 적용하면, 그 시점에 존재하지도 않던 설비가 화면에 선다 —
|
|
190
190
|
* 이 작업이 막으려던 바로 그 거짓말이다. 규칙이 "지금" 에만 걸리는지 못박는다.
|
|
191
191
|
*/
|
|
192
|
-
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1))
|
|
192
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1), 'reset')
|
|
193
193
|
const first = realEvents(boardWith(2, 1), 300)
|
|
194
194
|
await TwinEngine.persistBatch(domainId, INSTANCE, first, 0)
|
|
195
195
|
|
|
196
|
-
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(6, 3))
|
|
196
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(6, 3), 'reset')
|
|
197
197
|
|
|
198
198
|
const now = await TwinEngine.recover(domainId, INSTANCE)
|
|
199
199
|
assert.equal(now.equipment.length, 3, '"지금" 은 늘린 설비를 보여준다')
|
|
@@ -204,10 +204,10 @@ test('시간여행은 **그때의 공장**을 보여준다 — 과거에 최신
|
|
|
204
204
|
})
|
|
205
205
|
|
|
206
206
|
test('구조 이력이 **무엇이 달라졌는지** 알린다 — 보드 두 개를 눈으로 비교할 수는 없다', async () => {
|
|
207
|
-
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1))
|
|
207
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1), 'reset')
|
|
208
208
|
const first = realEvents(boardWith(2, 1), 200)
|
|
209
209
|
await TwinEngine.persistBatch(domainId, INSTANCE, first, 0)
|
|
210
|
-
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(6, 3), '도장 능력 보강')
|
|
210
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(6, 3), 'reset', '도장 능력 보강')
|
|
211
211
|
await TwinEngine.persistBatch(domainId, INSTANCE, realEvents(boardWith(6, 3), 100), first.length)
|
|
212
212
|
|
|
213
213
|
const history = await TwinEngine.structureHistory(domainId, INSTANCE)
|
|
@@ -232,11 +232,11 @@ test('구조 이력이 **무엇이 달라졌는지** 알린다 — 보드 두
|
|
|
232
232
|
})
|
|
233
233
|
|
|
234
234
|
test('번호 없는 옛 행도 가장 오래된 리비전의 몫으로 센다 — 재생과 같은 규칙', async () => {
|
|
235
|
-
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1))
|
|
235
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1), 'reset')
|
|
236
236
|
const legacy = realEvents(boardWith(2, 1), 200)
|
|
237
237
|
await TwinEngine.persistBatch(domainId, INSTANCE, legacy, 0)
|
|
238
238
|
await ds.query(`UPDATE "${ds.getMetadata(TwinEvent).tableName}" SET structure_rev = NULL`)
|
|
239
|
-
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(6, 3))
|
|
239
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(6, 3), 'reset')
|
|
240
240
|
|
|
241
241
|
const history = await TwinEngine.structureHistory(domainId, INSTANCE)
|
|
242
242
|
assert.equal(history[0].events, legacy.length, '재생이 rev 1 로 접는 행들을 세는 쪽만 빠뜨리면 두 화면이 다른 말을 한다')
|
|
@@ -250,11 +250,11 @@ test('지문 표기가 낡았을 뿐이면 리비전을 만들지 않는다 —
|
|
|
250
250
|
* 그런 일이 없다.
|
|
251
251
|
*/
|
|
252
252
|
const model = boardWith(2, 1)
|
|
253
|
-
await TwinEngine.provision(domainId, INSTANCE, 'wms', model)
|
|
253
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', model, 'reset')
|
|
254
254
|
const table = ds.getMetadata(TwinStructure).tableName
|
|
255
255
|
await ds.query(`UPDATE "${table}" SET signature = 'old-style-signature-from-a-previous-algorithm'`)
|
|
256
256
|
|
|
257
|
-
await TwinEngine.provision(domainId, INSTANCE, 'wms', model)
|
|
257
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', model, 'reset')
|
|
258
258
|
|
|
259
259
|
const revs = await ds.getRepository(TwinStructure).find({ where: { domain: { id: domainId }, instanceId: INSTANCE } })
|
|
260
260
|
assert.equal(revs.length, 1, '공장은 그대로인데 리비전이 늘면 이력이 거짓으로 부푼다')
|
|
@@ -269,11 +269,11 @@ test('지문 표기가 낡았을 뿐이면 리비전을 만들지 않는다 —
|
|
|
269
269
|
* 행과 겹치는 문제였다. 그 겹침은 오류를 내지 않고 조용히 재생 순서를 뒤섞는다.
|
|
270
270
|
*/
|
|
271
271
|
test('sim-world 로 다시 띄워도 저널이 살아남는다 — 선언이 지키는 약속', async () => {
|
|
272
|
-
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1))
|
|
272
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1), 'reset')
|
|
273
273
|
const first = realEvents(boardWith(2, 1), 300)
|
|
274
274
|
await TwinEngine.persistBatch(domainId, INSTANCE, first, 0)
|
|
275
275
|
|
|
276
|
-
const rt = await TwinEngine.bootDeclared(domainId, INSTANCE, '
|
|
276
|
+
const rt = await TwinEngine.bootDeclared(domainId, INSTANCE, 'resume')
|
|
277
277
|
try {
|
|
278
278
|
const kept = await ds.getRepository(TwinEvent).count({ where: { domain: { id: domainId }, instanceId: INSTANCE } })
|
|
279
279
|
assert.equal(kept, first.length, 'sim-world 는 이어지는 현실이다 — 지우면 선언이 거짓이 된다')
|
|
@@ -283,11 +283,11 @@ test('sim-world 로 다시 띄워도 저널이 살아남는다 — 선언이 지
|
|
|
283
283
|
})
|
|
284
284
|
|
|
285
285
|
test('★ 재기동 후 새 이벤트는 마지막 번호 뒤에 온다 — 겹치면 재생 순서가 뒤섞인다', async () => {
|
|
286
|
-
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1))
|
|
286
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1), 'reset')
|
|
287
287
|
const first = realEvents(boardWith(2, 1), 300)
|
|
288
288
|
await TwinEngine.persistBatch(domainId, INSTANCE, first, 0)
|
|
289
289
|
|
|
290
|
-
const rt = await TwinEngine.bootDeclared(domainId, INSTANCE, '
|
|
290
|
+
const rt = await TwinEngine.bootDeclared(domainId, INSTANCE, 'resume')
|
|
291
291
|
try {
|
|
292
292
|
const kernel: any = rt.kernel
|
|
293
293
|
assert.ok(kernel, '커널이 떠야 이어 세기를 물을 수 있다')
|
|
@@ -301,8 +301,8 @@ test('★ 재기동 후 새 이벤트는 마지막 번호 뒤에 온다 — 겹
|
|
|
301
301
|
})
|
|
302
302
|
|
|
303
303
|
test('저널이 비어 있으면 이어 셀 것이 없다 — 새로 시작하는 트윈에 영향 없음', async () => {
|
|
304
|
-
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1))
|
|
305
|
-
const rt = await TwinEngine.bootDeclared(domainId, INSTANCE, '
|
|
304
|
+
await TwinEngine.provision(domainId, INSTANCE, 'wms', boardWith(2, 1), 'reset')
|
|
305
|
+
const rt = await TwinEngine.bootDeclared(domainId, INSTANCE, 'resume')
|
|
306
306
|
try {
|
|
307
307
|
assert.equal((rt.kernel as any).getSnapshot().revision, 0)
|
|
308
308
|
} finally {
|
|
@@ -61,8 +61,8 @@ before(async () => {
|
|
|
61
61
|
beta = b.id
|
|
62
62
|
|
|
63
63
|
/* 두 테넌트가 **같은 id** 로 각자 프로비저닝한다 — DB 유일성이 `[domain, instanceId]` 라 허용된다. */
|
|
64
|
-
await TwinEngine.provision(alpha, SHARED_ID, 'wms', model())
|
|
65
|
-
await TwinEngine.provision(beta, SHARED_ID, 'wms', model())
|
|
64
|
+
await TwinEngine.provision(alpha, SHARED_ID, 'wms', model(), 'reset')
|
|
65
|
+
await TwinEngine.provision(beta, SHARED_ID, 'wms', model(), 'reset')
|
|
66
66
|
})
|
|
67
67
|
|
|
68
68
|
/**
|
|
@@ -85,7 +85,7 @@ before(async () => {
|
|
|
85
85
|
q = new TwinModelItemQuery()
|
|
86
86
|
ctx = { state: { domain: { id: domainId } } }
|
|
87
87
|
|
|
88
|
-
await TwinEngine.ingestMaster(domainId, master())
|
|
88
|
+
await TwinEngine.ingestMaster(domainId, master(), 'reset')
|
|
89
89
|
})
|
|
90
90
|
|
|
91
91
|
after(async () => {
|
|
@@ -213,7 +213,7 @@ test('지도의 선이 진단이 된다 — 이어진 것뿐 아니라 **끊긴
|
|
|
213
213
|
const bad = master()
|
|
214
214
|
bad.equipment.push({ id: 'ghost-1', kind: 'welder', homeLocationId: 'no-such-station' }) // 끊어진 참조
|
|
215
215
|
bad.equipment.push({ id: 'floater-1', kind: 'welder' }) // 기준 자리 없음
|
|
216
|
-
await TwinEngine.ingestMaster(domainId, bad)
|
|
216
|
+
await TwinEngine.ingestMaster(domainId, bad, 'reset')
|
|
217
217
|
|
|
218
218
|
const sum = await new TwinModelQuery().twinModelSummary(INSTANCE, ctx)
|
|
219
219
|
const home = sum.edges.find((e: any) => e.from === 'equipment' && e.to === 'locations' && e.via === 'twin.rel.home')
|
|
@@ -263,7 +263,7 @@ test('멈춘 트윈도 저널을 접어 답하고, 그 답은 다시 계산한
|
|
|
263
263
|
for (let i = 0; i < 200; i++) k.tick(1000)
|
|
264
264
|
|
|
265
265
|
const INST = 'journal-probe'
|
|
266
|
-
await TwinEngine.provision(domainId, INST, 'wms', model)
|
|
266
|
+
await TwinEngine.provision(domainId, INST, 'wms', model, 'reset')
|
|
267
267
|
await TwinEngine.persistBatch(domainId, INST, journal.all(), 0)
|
|
268
268
|
await TwinEngine.stop(domainId, INST).catch(() => {})
|
|
269
269
|
delete (TwinEngine as any).recovered[`${domainId}:${INST}`]
|