@things-factory/headless-twin 10.0.13 → 10.0.15
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.js +30 -0
- package/dist-server/engine/canonical-ingest.js.map +1 -1
- package/dist-server/engine/ingest-health.d.ts +76 -0
- package/dist-server/engine/ingest-health.js +93 -0
- package/dist-server/engine/ingest-health.js.map +1 -1
- package/dist-server/engine/twin-engine.d.ts +41 -0
- package/dist-server/engine/twin-engine.js +199 -6
- package/dist-server/engine/twin-engine.js.map +1 -1
- package/dist-server/service/reference/reference-adapter.d.ts +51 -0
- package/dist-server/service/reference/reference-adapter.js.map +1 -1
- package/dist-server/service/reference/reference-assessment.d.ts +11 -1
- package/dist-server/service/reference/reference-assessment.js +22 -2
- package/dist-server/service/reference/reference-assessment.js.map +1 -1
- package/dist-server/service/reference/reference-live.js +30 -0
- package/dist-server/service/reference/reference-live.js.map +1 -1
- package/dist-server/service/reference/reference-master.d.ts +12 -2
- package/dist-server/service/reference/reference-master.js.map +1 -1
- package/dist-server/service/twin-event/twin-event-keys.d.ts +21 -1
- package/dist-server/service/twin-event/twin-event-keys.js +35 -3
- package/dist-server/service/twin-event/twin-event-keys.js.map +1 -1
- package/dist-server/service/twin-forecast/twin-forecast-query.js +24 -1
- package/dist-server/service/twin-forecast/twin-forecast-query.js.map +1 -1
- package/dist-server/service/twin-model/epcis-coverage.js +32 -1
- package/dist-server/service/twin-model/epcis-coverage.js.map +1 -1
- package/dist-server/service/twin-model/isa95-coverage.d.ts +10 -0
- package/dist-server/service/twin-model/isa95-coverage.js +85 -5
- package/dist-server/service/twin-model/isa95-coverage.js.map +1 -1
- package/dist-server/service/twin-model/item-ref.d.ts +3 -3
- package/dist-server/service/twin-model/item-ref.js +4 -4
- package/dist-server/service/twin-model/item-ref.js.map +1 -1
- package/dist-server/service/twin-model/twin-model-query.js +71 -3
- package/dist-server/service/twin-model/twin-model-query.js.map +1 -1
- package/package.json +3 -3
- package/server/engine/canonical-ingest.ts +30 -0
- package/server/engine/ingest-health.ts +143 -0
- package/server/engine/twin-engine.ts +219 -6
- package/server/service/reference/reference-adapter.ts +45 -0
- package/server/service/reference/reference-assessment.ts +39 -4
- package/server/service/reference/reference-live.ts +32 -0
- package/server/service/reference/reference-master.ts +12 -2
- package/server/service/twin-event/twin-event-keys.ts +35 -3
- package/server/service/twin-forecast/twin-forecast-query.ts +27 -1
- package/server/service/twin-model/epcis-coverage.ts +32 -1
- package/server/service/twin-model/isa95-coverage.ts +95 -5
- package/server/service/twin-model/item-ref.ts +4 -4
- package/server/service/twin-model/twin-model-query.ts +71 -3
- package/test/checkpoint-refuses-empty.test.ts +103 -0
- package/test/cursor-stall-not-read-failure.test.ts +126 -0
- package/test/item-ref.test.ts +3 -3
- package/test/mirror-resumes-from-checkpoint.test.ts +192 -0
- package/test/revision-axis.test.ts +13 -2
- package/test/standard-coverage.test.ts +44 -0
- package/test/status-tally.test.ts +3 -3
- package/test/twin-event-keys.test.ts +48 -2
- package/test/withheld-door.test.ts +95 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* 좌표·representations 는 트윈 공간층(보완/추가). 실 시스템이 좌표를 안 주면 layout 없이 위상만 인제스트.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import type { DomainSystem, EquipmentLevel, WorkCalendarEntry } from '@operato/twin-kernel'
|
|
10
|
+
import type { DomainSystem, EquipmentLevel, TestSpecificationCriterion, WorkCalendarEntry } from '@operato/twin-kernel'
|
|
11
11
|
|
|
12
12
|
export interface RefLocation {
|
|
13
13
|
id: string
|
|
@@ -127,6 +127,16 @@ export interface RefTestSpecification {
|
|
|
127
127
|
/** 표시명 — i18n 키일 수 있다. 읽는 어휘를 하나로 맞추려고 인제스트가 `name` 으로 옮긴다. */
|
|
128
128
|
description?: string
|
|
129
129
|
version?: string
|
|
130
|
+
/**
|
|
131
|
+
* 판정 기준들 — 커널 계약의 `TestSpecification.criteria` 를 그대로 나른다.
|
|
132
|
+
*
|
|
133
|
+
* **경계에서 어휘를 갈지 않는다.** 커널 타입을 그대로 쓰므로 `limit.{minimum,maximum,uom}` ·
|
|
134
|
+
* `expression` · `evaluatedPropertyId` 가 원본에서 커널까지 한 이름으로 간다.
|
|
135
|
+
*
|
|
136
|
+
* 선언하지 않아도 명세는 성립한다(이름만으로 「무엇으로 검증했나」에 답한다). 선언하면 **판정에
|
|
137
|
+
* 근거가 있는지**를 물을 수 있게 된다 — 커널의 `criterionSaysNothing` 이 그것을 센다.
|
|
138
|
+
*/
|
|
139
|
+
criteria?: TestSpecificationCriterion[]
|
|
130
140
|
}
|
|
131
141
|
|
|
132
142
|
export interface RefEquipment {
|
|
@@ -350,7 +360,7 @@ export type MasterOrigin =
|
|
|
350
360
|
* `source` 는 **레퍼런스 등록 이름**이고 그 참조가 보고하는 사이트 전부에 같다. 그래서 어느 사이트에서
|
|
351
361
|
* 온 트윈인지는 `siteId` 가 있어야 안다 — 없으면 다시 읽기가 사이트를 찾지 못한다(2026-08-18 실측:
|
|
352
362
|
* 사이트가 여러 개인 참조에서 온 트윈은 재동기가 전부 거절됐다. 참조 이름을 사이트별 마스터의
|
|
353
|
-
* `source`(=인스턴스 id)와
|
|
363
|
+
* `source`(=인스턴스 id)와 비교하고 있었다 — 서로 다른 것을 비교한 것이다).
|
|
354
364
|
*/
|
|
355
365
|
| { kind: 'reference'; source: string; siteId?: string; spaceId?: string }
|
|
356
366
|
/**
|
|
@@ -65,10 +65,30 @@ export function epcOf(envelope: any): string | undefined {
|
|
|
65
65
|
return d.epcList?.[0] ?? d.parentID ?? d.quantityList?.[0]?.epcClass ?? undefined
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
/**
|
|
68
|
+
/**
|
|
69
|
+
* 오더 식별자 — 운영 델타의 `orderId` 우선, EPCIS 는 `bizTransactionList`.
|
|
70
|
+
*
|
|
71
|
+
* ── 실측 (2026-08-24) — 이 컬럼이 **전부 비어 있었다** ──────────────────────
|
|
72
|
+
* 여기가 찾던 이름이 `d.order` 였다. 그런데 커널이 내는 이름은 **`orderId`** 다
|
|
73
|
+
* (`OrderStatusDelta.orderId` · `TaskStatusDelta.orderId`). `d.order` 를 내는 코드는 커널에 **한 곳도
|
|
74
|
+
* 없다** — 죽은 가지였다. 그래서 운영 델타는 이 컬럼을 한 번도 채우지 못했다.
|
|
75
|
+
*
|
|
76
|
+
* order.status 29,403,565 행 — order_id 비어 있음 29,403,565 (100%)
|
|
77
|
+
* task.status 191,175 행 — 비어 있음 191,175 (100%)
|
|
78
|
+
*
|
|
79
|
+
* 그 결과 색인 `ix_twin_event_4` 가 **자기 주석이 적어 둔 용도**(「이 오더가 어디까지 갔나」)로 쓸 수
|
|
80
|
+
* 없었다. 화면은 오더를 눌러도 「연결된 이벤트가 없습니다」를 냈고, 그 답은 질의 결과로는 정직했다 —
|
|
81
|
+
* 시점을 어디로 옮겨도 0 이었다.
|
|
82
|
+
*
|
|
83
|
+
* 순서가 중요하다: 운영 델타가 먼저다. EPCIS 의 `bizTransactionList` 는 **거래**(PO/SO)이고 오더와
|
|
84
|
+
* 같은 것이 아닐 수 있으므로, 오더를 스스로 말하는 사건은 그 말을 그대로 쓴다.
|
|
85
|
+
*
|
|
86
|
+
* **과거 행은 채워지지 않는다**(사용자 결정 2026-08-24, `moverId` 때와 같은 방식). 앞으로 들어오는
|
|
87
|
+
* 사건부터 조회된다.
|
|
88
|
+
*/
|
|
69
89
|
export function orderOf(envelope: any): string | undefined {
|
|
70
90
|
const d = envelope?.data ?? envelope ?? {}
|
|
71
|
-
return d.bizTransactionList?.[0]?.bizTransaction ??
|
|
91
|
+
return d.orderId ?? d.bizTransactionList?.[0]?.bizTransaction ?? undefined
|
|
72
92
|
}
|
|
73
93
|
|
|
74
94
|
/**
|
|
@@ -89,7 +109,19 @@ export function locationOf(envelope: any): string | undefined {
|
|
|
89
109
|
*/
|
|
90
110
|
export function equipmentIdOf(envelope: any): string | undefined {
|
|
91
111
|
const d = envelope?.data ?? envelope ?? {}
|
|
92
|
-
|
|
112
|
+
/*
|
|
113
|
+
* ── 실측 (2026-08-24) — 두 갈래를 놓치고 있었다 ────────────────────────────
|
|
114
|
+
* 위 주석이 `task.status` 를 출처로 **적어 두었는데** 이 함수는 `d.moverId` 만 봤다. 작업이 자원을
|
|
115
|
+
* 가리키는 이름은 `resourceRef` 다(`TaskStatusDelta.resourceRef`) — 그래서 작업 191,175 행 전부
|
|
116
|
+
* 이 컬럼이 비었고, 「이 지게차가 오늘 무엇을 했나」에서 **작업이 통째로 빠졌다.**
|
|
117
|
+
*
|
|
118
|
+
* 그리고 에너지 사건은 `equipmentId` 를 쓴다(어휘가 `movers`→`equipment` 로 개명된 뒤에 생긴
|
|
119
|
+
* 채널이다). 실측 `energy.equipment` 31,079 행 전부 비어 있었다 — 「이 설비가 얼마를 먹었나」를
|
|
120
|
+
* 설비 축으로 물을 수 없었다.
|
|
121
|
+
*
|
|
122
|
+
* 셋을 함께 본다. 개명 세대가 섞여 있는 것은 저널의 성질이고, 읽는 쪽이 그것을 흡수한다.
|
|
123
|
+
*/
|
|
124
|
+
return d.moverId ?? d.resourceRef ?? d.equipmentId ?? undefined
|
|
93
125
|
}
|
|
94
126
|
|
|
95
127
|
/**
|
|
@@ -331,7 +331,33 @@ export class TwinForecastQuery {
|
|
|
331
331
|
calibrated: !!cal,
|
|
332
332
|
/* 학습해 뒀지만 모델이 바뀌어 적용하지 않았다 — 사용자가 다시 배울 수 있게 알린다. */
|
|
333
333
|
...(stale ? { calibrationStale: true } : {}),
|
|
334
|
-
...(specCoverage ? { specCoverage } : {})
|
|
334
|
+
...(specCoverage ? { specCoverage } : {}),
|
|
335
|
+
/*
|
|
336
|
+
* **계보에 구멍이 몇 칸 생겼나** — 개체를 잇지 못한 채 넘어간 공정 수.
|
|
337
|
+
*
|
|
338
|
+
* ── 왜 이 값을 예측 응답에 싣나 (2026-08-24) ──────────────────────────────
|
|
339
|
+
* 미러의 오더는 확보분을 갖지 않는다(원본이 「어느 개체가 잡혀 있나」를 말하지 않는 것이 그
|
|
340
|
+
* 시스템의 정상이다). 커널은 그 자리에서 **던지지 않고 넘긴다** — 단계를 넘는 것은 공정의
|
|
341
|
+
* 진행이고 개체 번호가 필요 없다. 대신 넘긴 횟수를 센다.
|
|
342
|
+
*
|
|
343
|
+
* 그런데 그 카운터는 **fork 안에** 있고 fork 는 버려진다. 여기서 싣지 않으면 아무에게도 닿지
|
|
344
|
+
* 않는다 — 리듀서가 오래 세어 온 값을 스냅샷이 떨어뜨려 그것을 읽도록 쓰인 검사가 영원히
|
|
345
|
+
* 조용했던 것과 **같은 실수**다. 세어 놓고 내보내지 않으면 안 센 것과 같다.
|
|
346
|
+
*
|
|
347
|
+
* 왜 화면이 이것을 알아야 하나: 계보는 **회수 범위**를 정하는 사슬이다. 구멍이 있는데 온전한
|
|
348
|
+
* 것처럼 보이면 회수가 조용히 좁아진다. 식품에서 그것은 되돌릴 수 없다.
|
|
349
|
+
*
|
|
350
|
+
* 두 곳에서 읽어 **큰 값**을 낸다 — 분포(회차 R개의 최대)와 궤적 fork 는 **둘 다 실제로 실행됐다**.
|
|
351
|
+
* 한쪽만 보면 다른 쪽이 겪은 구멍을 잃는다.
|
|
352
|
+
*/
|
|
353
|
+
...(() => {
|
|
354
|
+
const gaps = Math.max(
|
|
355
|
+
(distribution as any)?.stepsWithoutMaterial ?? 0,
|
|
356
|
+
(fc as any)?.getSnapshot?.()?.stepsWithoutMaterial ?? 0
|
|
357
|
+
)
|
|
358
|
+
/* 0 은 싣지 않는다 — 「구멍이 없다」와 「이 축을 모른다」를 화면이 구별할 수 있게. */
|
|
359
|
+
return gaps > 0 ? { stepsWithoutMaterial: gaps } : {}
|
|
360
|
+
})()
|
|
335
361
|
}
|
|
336
362
|
}
|
|
337
363
|
|
|
@@ -86,6 +86,20 @@ const FIELDS: Isa95Concept[] = [
|
|
|
86
86
|
surface: 'full',
|
|
87
87
|
note: 'twin.epcis.note.identity'
|
|
88
88
|
},
|
|
89
|
+
/*
|
|
90
|
+
* ── 2026-08-24 주장 근거가 달라졌다 ────────────────────────────────────────
|
|
91
|
+
* 이 줄은 오래 `full` 이었지만 근거가 **우리 선언**이었다. 이제 **정본 원문 대조**다:
|
|
92
|
+
* CBV Standard Release 2.0(Ratified Jun 2022) §7.2.3 의 처분 38개를 전수 확인했다.
|
|
93
|
+
*
|
|
94
|
+
* 그 대조가 실제로 셋을 바꿨다.
|
|
95
|
+
* · `expired` — 있는 낱말인데 우리가 쓰지 않고 있었다(기한 경과를 처분으로 말할 수 있게 됐다)
|
|
96
|
+
* · `conformant` / `non_conformant` — 검사 판정을 처분으로 남기는 낱말. 함께 `inspecting` bizStep
|
|
97
|
+
* · `non_sellable_expired` 는 표준이 **폐기**하고 `expired` 로 대체한 것을 확인
|
|
98
|
+
*
|
|
99
|
+
* ★ 그리고 **검증 방법에 함정이 있다**: `ref.gs1.org/cbv/…` 로 URN 을 조회하면 **지어낸 값에도
|
|
100
|
+
* 똑같은 응답**이 온다. 그것으로 확인했다고 여기면 없는 낱말을 발행한다(한 번 그렇게 했다).
|
|
101
|
+
* 확인은 정본 문서로만 한다.
|
|
102
|
+
*/
|
|
89
103
|
{
|
|
90
104
|
std: 'bizStep / disposition (CBV)',
|
|
91
105
|
part: 'fields',
|
|
@@ -129,11 +143,15 @@ const FIELDS: Isa95Concept[] = [
|
|
|
129
143
|
note: 'twin.epcis.note.ilmd'
|
|
130
144
|
},
|
|
131
145
|
{
|
|
146
|
+
/*
|
|
147
|
+
* 2026-08-24 — **`shownOn` 을 걷었다.** 같은 줄의 `note` 가 「화면에 보이는 곳은 아직 없습니다」라고
|
|
148
|
+
* 말하는데 배지는 「물품에서 표시됩니다」라고 말하고 있었다 — 한 줄이 서로 반대를 말했다.
|
|
149
|
+
* `surface: 'none'` 이면 갈 곳을 적지 않는다(§`shownOn` 규칙).
|
|
150
|
+
*/
|
|
132
151
|
std: 'errorDeclaration',
|
|
133
152
|
part: 'fields',
|
|
134
153
|
label: 'twin.epcis.errorDeclaration',
|
|
135
154
|
axis: null,
|
|
136
|
-
shownOn: ['items'],
|
|
137
155
|
structure: 'full',
|
|
138
156
|
behavior: 'partial',
|
|
139
157
|
surface: 'none',
|
|
@@ -160,6 +178,19 @@ const FIELDS: Isa95Concept[] = [
|
|
|
160
178
|
surface: 'none',
|
|
161
179
|
note: 'twin.epcis.note.persistentDisposition'
|
|
162
180
|
},
|
|
181
|
+
/*
|
|
182
|
+
* ── 2026-08-24 계측이 어디로 들어오는지 확정됐다 ──────────────────────────
|
|
183
|
+
* 「계측은 EPCIS 센서 필드로 오지 않는다」는 사실이 이제 **자리를 갖는다**: 자리의 상시 관측은
|
|
184
|
+
* ISA-95 `OperationsEvent` 로 들어오고(커널 `location.measured` 채널), 에너지는 그 위의 누적기가
|
|
185
|
+
* 받는다. ISA-95 표의 `OperationsEvent` 줄이 그 판정을 든다.
|
|
186
|
+
*
|
|
187
|
+
* 왜 EPCIS 가 아닌가: `SensorElement` 는 **개체에 붙는** 관측이고, 냉장실의 온도는 그 안의 물건
|
|
188
|
+
* 수백 개와 관계되며 그 수백 개는 시간에 따라 바뀐다. 개체마다 붙이면 같은 사실이 수백 벌이 되고,
|
|
189
|
+
* 물건이 떠나면 그 방의 온도 이력이 함께 사라진다. 주인은 **자리**다.
|
|
190
|
+
*
|
|
191
|
+
* 그래서 이 줄은 `behavior: 'none'` 으로 남는다 — 결손이 아니라 **다른 층으로 들어온다는 사실**이다.
|
|
192
|
+
* 그 구별을 표가 말하지 않으면 「센서를 못 받는 트윈」으로 읽힌다.
|
|
193
|
+
*/
|
|
163
194
|
{
|
|
164
195
|
std: 'sensorElementList',
|
|
165
196
|
part: 'fields',
|
|
@@ -54,6 +54,16 @@ export interface Isa95Concept {
|
|
|
54
54
|
* 라고만 말하면 **표가 스스로 모순된 말을 한다**(다 됐다면서 없다고 한다).
|
|
55
55
|
*
|
|
56
56
|
* 그래서 축일 수 없는 것은 갈 곳을 가리킨다. 여기가 비어 있고 축도 없으면 그것은 **진짜 빈칸**이다.
|
|
57
|
+
*
|
|
58
|
+
* ── ★ `surface` 와 짝을 맞춘다 (2026-08-24 실측으로 붙임) ─────────────────
|
|
59
|
+
* 이 칸은 「그 사실이 어느 축에 **담겨 있나**」가 아니라 「**어디서 보이나**」다. 화면이 그것을
|
|
60
|
+
* 「해당 축 항목에 표시됩니다」로 읽어 내므로, 둘이 어긋나면 표가 거짓을 말한다.
|
|
61
|
+
*
|
|
62
|
+
* `surface: 'none'` 인데 여기가 차 있다 → **거짓**. 없는 화면으로 사람을 보낸다
|
|
63
|
+
* `surface` 가 있는데 여기가 비어 있다 → **과소**. 「진짜 빈칸」으로 읽힌다(위 규칙)
|
|
64
|
+
*
|
|
65
|
+
* 같은 날 셋을 그렇게 틀리게 넣었다: 새 개념 둘(`TestResult`·`OperationsEvent`)에 화면이 없는데
|
|
66
|
+
* 갈 곳을 적었고, `MaterialSublot` 은 물품 이름으로 보이는데 갈 곳을 비워 두었다.
|
|
57
67
|
*/
|
|
58
68
|
shownOn?: string[]
|
|
59
69
|
}
|
|
@@ -102,9 +112,39 @@ const PART2: Isa95Concept[] = [
|
|
|
102
112
|
{ std: 'PhysicalAsset', part: '2', label: 'twin.isa95.PhysicalAsset', axis: 'assets', structure: 'full', behavior: 'full', surface: 'full' },
|
|
103
113
|
{ std: 'MaterialClass', part: '2', label: 'twin.isa95.MaterialClass', axis: 'materialClasses', structure: 'full', behavior: 'partial', surface: 'full' },
|
|
104
114
|
{ std: 'MaterialDefinition', part: '2', label: 'twin.isa95.MaterialDefinition', axis: 'materialDefinitions', structure: 'full', behavior: 'full', surface: 'full' },
|
|
105
|
-
/*
|
|
115
|
+
/*
|
|
116
|
+
* 로트·서브로트는 개체 축이 아니라 **관측**으로 존재한다(EPCIS 개체) — 구조는 저널이 갖는다.
|
|
117
|
+
*
|
|
118
|
+
* ── 2026-08-24 로트가 시험 결과를 들게 됐다 (커널 0.7.57) ─────────────────
|
|
119
|
+
* `ItemState.testResults` 로 「이 로트를 쓸 수 있나」의 **근거**가 로트에 실린다. 그전에는 판정
|
|
120
|
+
* (`disposition`)만 있었고 「왜 그렇게 판정했나」를 되짚을 수 없었다.
|
|
121
|
+
*
|
|
122
|
+
* **구조를 `full` 로 올리지 않는다.** `MaterialLotType` 원문(B2MML-Material.xsd)이 드는 것 중
|
|
123
|
+
* 아직 없는 것이 있다: `MaterialLotProperty`(시험이 아닌 로트 속성 — 원산지·포장 형태) ·
|
|
124
|
+
* `TestSpecificationID`(로트별 기준 참조 — 품목 정의가 대신 답하므로 두지 않았다) · `Status` ·
|
|
125
|
+
* `HierarchyScope` · `Version`. 채운 것만 세는 것이 이 표의 규율이다.
|
|
126
|
+
*/
|
|
106
127
|
{ std: 'MaterialLot', part: '2', label: 'twin.isa95.MaterialLot', axis: null, structure: 'partial', behavior: 'full', surface: 'partial', note: 'twin.isa95.note.lot' },
|
|
107
|
-
|
|
128
|
+
/*
|
|
129
|
+
* ── 2026-08-24 판정 정정: `none` 이 아니었다 ──────────────────────────────
|
|
130
|
+
* 이 줄은 셋 다 `none` 이었는데 **표가 코드보다 뒤처져 있었다.** 커널은 서브로트를 든다:
|
|
131
|
+
*
|
|
132
|
+
* `ItemState.subLotId` 표준 `MaterialSubLot.ID` (계약이 그렇게 적고 있다)
|
|
133
|
+
* `subLotIdOf(class, location)` 비직렬 로트가 자리마다 나뉠 때 그 부분의 이름을 만든다
|
|
134
|
+
* `itemKeyOf(item)` **개체의 정체성이 이 값이다** — `subLotId ?? epc`
|
|
135
|
+
*
|
|
136
|
+
* 기능이 `full` 인 근거: 이 축이 없으면 같은 로트를 rack-1 에 100개·rack-2 에 60개 관측했을 때
|
|
137
|
+
* **뒤에 온 관측이 앞을 덮어 100개가 조용히 사라진다**(합계 160 → 60). 실제로 그 결함을 이 축으로
|
|
138
|
+
* 고쳤다. 즉 장식이 아니라 정체성을 지탱한다.
|
|
139
|
+
*
|
|
140
|
+
* 구조가 `partial` 인 이유: 서브로트는 로트와 같은 모양이고(위 참조) 그 로트가 `partial` 이다.
|
|
141
|
+
* 화면이 `partial` 인 이유: 값이 물품의 **이름으로** 나온다(`twin-item` 의 line) — 「이 로트가 자리마다
|
|
142
|
+
* 나뉘어 있다」를 말하는 자리는 아직 없다.
|
|
143
|
+
*
|
|
144
|
+
* 이 부류(코드에 있는데 표가 `none`)는 자동으로 잡히지 않는다 — 표의 가드는 반대 방향만 본다
|
|
145
|
+
* (축이 사라지면 구조를 내린다). 그래서 축을 늘릴 때 이 표를 함께 보는 것이 규율이다.
|
|
146
|
+
*/
|
|
147
|
+
{ std: 'MaterialSublot', part: '2', label: 'twin.isa95.MaterialSublot', axis: null, shownOn: ['items'], structure: 'partial', behavior: 'full', surface: 'partial' },
|
|
108
148
|
{ std: 'ProcessSegment', part: '2', label: 'twin.isa95.ProcessSegment', axis: 'operations', structure: 'full', behavior: 'full', surface: 'full' },
|
|
109
149
|
/*
|
|
110
150
|
* 속성은 자원마다 붙는 확장이라 **자기 축이 아니다** — 그래서 `axis` 는 없지만 구조는 완전하다:
|
|
@@ -114,7 +154,30 @@ const PART2: Isa95Concept[] = [
|
|
|
114
154
|
* 기능은 **부분**이다: 호스트의 주행 추정기가 `speed` 를 읽어 이동 시간을 만드는 것이 유일한 소비처다
|
|
115
155
|
* (다른 속성은 아직 아무도 읽지 않는다). 그 사실을 `full` 로 올리면 표가 거짓이 된다.
|
|
116
156
|
*/
|
|
117
|
-
{ std: 'Property', part: '2', label: 'twin.isa95.Property', axis: null, structure: 'full', behavior: 'partial', surface: 'full', note: 'twin.isa95.note.property' }
|
|
157
|
+
{ std: 'Property', part: '2', label: 'twin.isa95.Property', axis: null, structure: 'full', behavior: 'partial', surface: 'full', note: 'twin.isa95.note.property' },
|
|
158
|
+
/*
|
|
159
|
+
* **자리의 상시 관측** — 2026-08-24 신설(커널 0.7.56). 표에 이 개념이 아예 없었다.
|
|
160
|
+
*
|
|
161
|
+
* 왜 이 축이 필요한가: 트윈의 판정 대상은 물건의 상태이고, 물건의 상태는 **조건 없이 정해지지
|
|
162
|
+
* 않는다.** 「이 로트가 냉장실에 있었다」까지만 아는 트윈은 그 로트가 괜찮았는지 말할 수 없다. 그리고
|
|
163
|
+
* **이 조인은 트윈만 할 수 있다** — 계측 시스템은 물건의 자리 이력을 모르고, 물류 시스템은 조건
|
|
164
|
+
* 이력을 모른다.
|
|
165
|
+
*
|
|
166
|
+
* 표준 앵커: `OperationsEventType`(B2MML-OperationsEvent.xsd, ISA-95.00.02-2018) +
|
|
167
|
+
* `OperationsRecordTemplateType`. 우리가 든 것 — `EffectiveTimestamp` · `EffectiveEndDate` ·
|
|
168
|
+
* `RecordTimestamp` · `HierarchyScope`(자리) · `Source` · 값은 `ValueType` 으로 **좁혔다**
|
|
169
|
+
* (단위 없는 물리량은 판정의 재료가 못 된다).
|
|
170
|
+
*
|
|
171
|
+
* 붙는 자리가 `HierarchyScope.EquipmentID` 인 것이 중요하다 — ISA-95 에서 장소 계층이 곧 설비
|
|
172
|
+
* 계층이고 냉장실은 `StorageZone` 수준의 설비다. `OperationalLocationType` 에는 이 축이 없다.
|
|
173
|
+
*
|
|
174
|
+
* 구조가 `partial`: 봉투 전체(`ID`·`Description`·사건 분류·`OperationsRecord` 구조)가 아니라 관측
|
|
175
|
+
* 하나를 담는 데 필요한 칸만 든다.
|
|
176
|
+
* 기능이 `full`: 사건 채널(`location.measured`)이 상태를 만들고, 선언된 기준으로 판정해
|
|
177
|
+
* `observation-out-of-limit` 신호를 세운다.
|
|
178
|
+
* 화면이 `none`: 클라이언트에 이 축을 그리는 곳이 없다(실측 0곳).
|
|
179
|
+
*/
|
|
180
|
+
{ std: 'OperationsEvent', part: '2', label: 'twin.isa95.OperationsEvent', axis: null, structure: 'partial', behavior: 'full', surface: 'none' }
|
|
118
181
|
]
|
|
119
182
|
|
|
120
183
|
/*
|
|
@@ -149,10 +212,37 @@ const PART4: Isa95Concept[] = [
|
|
|
149
212
|
* 그것을 본다 — 만료·불합격이면 그 자격은 성립하지 않는다(실측: 만료된 용접사는 용접 작업 5건 중 한
|
|
150
213
|
* 번도 배정되지 않았다).
|
|
151
214
|
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
215
|
+
* ── 2026-08-24 위 문장 하나가 낡았다 ─────────────────────────────────────
|
|
216
|
+
* 「**사람만 판정한다**」고 적혀 있었다. 지금은 아니다 — `capabilityOfResource` 가 설비·사람·자산
|
|
217
|
+
* **셋 모두**에 `requiredTests` 를 걸고, 그 답이 배정의 가용성 필터로 들어간다. 그리고 판정 대상이
|
|
218
|
+
* 자원 밖으로 넓어졌다: **자리의 상시 관측**(`OperationsEvent` 줄)과 **로트의 시험 결과**
|
|
219
|
+
* (`TestResult` 줄)도 같은 기준으로 판정한다.
|
|
220
|
+
*
|
|
221
|
+
* 남은 규율은 하나다: **결과가 없으면 판정하지 않는다**(선언한 것만 제약이 된다 — 결손이 아니라 규율).
|
|
222
|
+
*
|
|
223
|
+
* 그리고 기준의 **숫자 한계는 우리가 더한 것**이다. 표준 `TestSpecificationCriteria.Expression` 은
|
|
224
|
+
* 자유 문장(`TextType`)이고, B2MML 일곱 파일에 `Minimum`·`Maximum`·`Tolerance` 가 하나도 없다
|
|
225
|
+
* (원문 대조). 그것 없이는 판정할 수 없으므로 `limit: {minimum, maximum, uom}` 을 더했고, 발명한
|
|
226
|
+
* 자리임을 계약에 적었다. 커널은 `Expression` 을 **읽지 않는다** — 문법이 정의되지 않았으므로
|
|
227
|
+
* 파싱하면 그 순간 방언이다.
|
|
154
228
|
*/
|
|
155
229
|
{ std: 'TestSpecification', part: '4', label: 'twin.isa95.TestSpecification', axis: 'testSpecifications', structure: 'full', behavior: 'full', surface: 'full', note: 'twin.isa95.note.test' },
|
|
230
|
+
/*
|
|
231
|
+
* **시험 결과** — 2026-08-24 신설(커널 0.7.57). 기준만 있고 결과의 자리가 표에 없었다.
|
|
232
|
+
*
|
|
233
|
+
* 표준 `TestResultType`(B2MML-OperationsTest.xsd)에서 우리가 든 것:
|
|
234
|
+
* `EvaluationDate`(at) · `Expiration`(expiresAt) · `TestableObjectID` · `PropertyMeasurement`
|
|
235
|
+
* · `EvaluatedCriterionResult` → **`result: 'pass'|'fail'` 로 좁혔다**(표준은 열거하지 않는다)
|
|
236
|
+
* 아직 없는 것: `ID` · `Description` · `HierarchyScope` · `OperationsTestRequirementID` ·
|
|
237
|
+
* `TestResultChild`. 그래서 구조는 `partial` 이다.
|
|
238
|
+
*
|
|
239
|
+
* 기능이 `partial` 인 이유를 정확히 적는다 — **자원에서는 결정을 바꾸고 로트에서는 아직 바꾸지
|
|
240
|
+
* 않는다.** 만료·불합격 자원에는 작업이 배정되지 않지만, 불합격 로트가 할당에서 빠지거나 주의 신호를
|
|
241
|
+
* 세우지는 않는다(들고·판정하고·되짚을 수는 있다). 그 자리를 `full` 로 적으면 표가 거짓이 된다.
|
|
242
|
+
*
|
|
243
|
+
* 화면은 `none` 이다 — 클라이언트에 이 축을 그리는 곳이 없다(실측: `operato-twin/client` 에 0곳).
|
|
244
|
+
*/
|
|
245
|
+
{ std: 'TestResult', part: '4', label: 'twin.isa95.TestResult', axis: null, structure: 'partial', behavior: 'partial', surface: 'none' },
|
|
156
246
|
{ std: 'WorkMaster', part: '4', label: 'twin.isa95.WorkMaster', axis: 'recipes', structure: 'full', behavior: 'full', surface: 'full' },
|
|
157
247
|
{ std: 'WorkDirective', part: '4', label: 'twin.isa95.WorkDirective', axis: null, structure: 'none', behavior: 'none', surface: 'none' }
|
|
158
248
|
]
|
|
@@ -26,18 +26,18 @@
|
|
|
26
26
|
* **어느 부호가 개체를 가리키는지 모른다.** 그것은 원본마다 다르고 커널 계약이 정한 적이 없다 — 한
|
|
27
27
|
* 원본(chef)이 쓰는 `@` 를 규칙으로 굳히면 이 화면이 그 레거시 위에 서게 된다. 자르는 판단은 그 트윈의
|
|
28
28
|
* **선언**이 하고(§`resolveByDeclaration`), 후보가 둘 이상이면 답하지 않는다. 짐작으로 이으면 남의
|
|
29
|
-
* 레시피와
|
|
29
|
+
* 레시피와 비교하게 되고, 그것이 「설계와 다르다」는 거짓 진단이 된다.
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
* 개체 식별자 → **품목 참조**. 대응 표의 값과
|
|
33
|
+
* 개체 식별자 → **품목 참조**. 대응 표의 값과 비교할 수 있는 모양으로 돌려준다.
|
|
34
34
|
*
|
|
35
35
|
* SGTIN 은 표준이 자리를 정해 두었으므로 그 자리에서 품목 참조를 꺼낸다. 그 밖의 모양은 **손대지
|
|
36
36
|
* 않는다** — 어느 부호가 개체를 가리키는지는 원본마다 다르고, 커널 계약이 그 구분자를 정한 적이 없다.
|
|
37
37
|
* 앞서 이 자리에 「`@` 뒤는 개체다」를 넣었는데, 그것은 한 원본(chef)의 모양을 규칙으로 굳힌 것이었다.
|
|
38
38
|
*
|
|
39
|
-
* 그래서 자르는 일은 여기서 하지 않고, **그 트윈의 선언과
|
|
40
|
-
* 선언이 답을 주지 못하면 `null` 이다 — 짐작으로 이으면 남의 레시피와
|
|
39
|
+
* 그래서 자르는 일은 여기서 하지 않고, **그 트윈의 선언과 비교해** 판정한다(§`resolveByDeclaration`).
|
|
40
|
+
* 선언이 답을 주지 못하면 `null` 이다 — 짐작으로 이으면 남의 레시피와 비교하게 되고, 그것이 「설계와
|
|
41
41
|
* 다르다」는 거짓 진단이 된다.
|
|
42
42
|
*/
|
|
43
43
|
export function itemRefOf(epc: unknown): string | null {
|
|
@@ -181,9 +181,27 @@ export class TwinModelQuery {
|
|
|
181
181
|
const inst = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } })
|
|
182
182
|
if (!inst) return null
|
|
183
183
|
|
|
184
|
-
/*
|
|
184
|
+
/*
|
|
185
|
+
* 어느 원본을 반영하나 — 없을 수 있다(템플릿으로 세운 트윈). 그 사실도 사실이다.
|
|
186
|
+
*
|
|
187
|
+
* ── **레퍼런스 열쇠는 인스턴스 이름이 아니다** (2026-08-24 실측) ──────────────
|
|
188
|
+
* 여기서 `source: instanceId` 로 찾고 있었다. 레퍼런스 하나가 인스턴스 하나를 낳던 시절에는
|
|
189
|
+
* 두 값이 같았지만, **레퍼런스 하나가 여러 트윈을 먹이는 순간 어긋난다.**
|
|
190
|
+
*
|
|
191
|
+
* 인스턴스 operato-chef-v1-pocheon
|
|
192
|
+
* origin { kind: 'reference', source: 'seunghwa' } ← 이것이 레퍼런스 열쇠다
|
|
193
|
+
* 레퍼런스 seunghwa
|
|
194
|
+
*
|
|
195
|
+
* 그래서 요약의 `source` 가 **언제나 `null`** 이었다. 레퍼런스로 세운 트윈인데 화면이 그 원본을
|
|
196
|
+
* 못 찾았고, 「마지막 동기화」도 흐름별 신선도도 그릴 자리가 아예 없었다 — 그런데 오류는 없었다
|
|
197
|
+
* (`ref ? … : null` 이 조용히 갈렸다).
|
|
198
|
+
*
|
|
199
|
+
* 인스턴스가 자기 원본을 말하고 있으므로 **그 말을 먼저 듣는다.** 옛 트윈은 두 값이 같으므로
|
|
200
|
+
* 뒤의 것으로 여전히 찾힌다 — 가산적이다.
|
|
201
|
+
*/
|
|
202
|
+
const refKey = (inst.origin as any)?.source || instanceId
|
|
185
203
|
const ref = await getRepository(TwinReference)
|
|
186
|
-
.findOne({ where: { domain: { id: domainId }, source:
|
|
204
|
+
.findOne({ where: { domain: { id: domainId }, source: refKey } })
|
|
187
205
|
.catch(() => null)
|
|
188
206
|
|
|
189
207
|
const model = inst.model ?? {}
|
|
@@ -523,11 +541,61 @@ export class TwinModelQuery {
|
|
|
523
541
|
recipeLabel: material?.label ?? material?.name ?? null
|
|
524
542
|
}
|
|
525
543
|
})(),
|
|
544
|
+
/*
|
|
545
|
+
* ── **식별자가 무엇에 근거하나** (2026-08-24) ──────────────────────────────
|
|
546
|
+
* 요약이 `production.companyPrefix` 를 그대로 보이는데 **그것이 발급받은 번호인지 말할 근거가
|
|
547
|
+
* 없었다.** 그래서 사용자가 긴 URI 를 보고 「GS1 을 강제하는 것인가」를 물었다 — 강제는 **이미
|
|
548
|
+
* 없고**(GS1 프리픽스가 없는 현장은 자기 도메인 아래에서 식별자를 만든다, CBV 2.0 §8.3.4)
|
|
549
|
+
* 없다는 사실이 화면에 없었던 것이 문제였다.
|
|
550
|
+
*
|
|
551
|
+
* ★ **이 축만은 무표를 쓰지 않는다.** GS1 형태의 식별자를 본 사람은 그것이 진짜 발급받은
|
|
552
|
+
* 번호라고 가정하므로, 표식이 없으면 그 가정이 곧 거짓 안심이 된다. 계약도 그렇게 적고 있다 —
|
|
553
|
+
* 「없으면 소비처는 판정하지 않는다. 기본값으로 `issued` 를 가정하면 그 화면이 곧 거짓이 된다.」
|
|
554
|
+
* 그래서 화면이 네 갈래 모두에 낱말을 붙일 수 있게 **그대로** 낸다
|
|
555
|
+
* (`claimed-issued`·`declared-namespace`·`gs1-shaped`·`kernel-constant`).
|
|
556
|
+
*
|
|
557
|
+
* 커널이 내는 것을 그대로 넘긴다 — 여기서 다시 판정하면 스냅샷과 요약이 다른 말을 한다.
|
|
558
|
+
* 트윈이 돌지 않거나 커널이 말하지 않으면 `null` 이고, 그때 화면은 **재지 않는다.**
|
|
559
|
+
*/
|
|
560
|
+
identityGrounding: (await stateOnce()).snap?.identityGrounding ?? null,
|
|
526
561
|
description: inst.description ?? null,
|
|
527
562
|
kind: inst.kind,
|
|
528
563
|
restartPolicy: inst.restartPolicy,
|
|
529
564
|
source: ref
|
|
530
|
-
? {
|
|
565
|
+
? {
|
|
566
|
+
referenceId: ref.source,
|
|
567
|
+
adapterType: ref.adapterType,
|
|
568
|
+
status: ref.status,
|
|
569
|
+
lastSyncedAt: ref.lastSyncedAt,
|
|
570
|
+
/*
|
|
571
|
+
* ── **원본의 어느 흐름이 어디까지 살아 있나** (2026-08-24) ──────────────
|
|
572
|
+
* 사용자가 「지금 데이터가 들어오는 것이 사실이냐」고 물었고, 화면이 답할 것이 「붙어 있다」
|
|
573
|
+
* 뿐이었다. 그런데 한 원본 안에서 흐름마다 신선도가 **크게 다르다**(실측: 재고 08-23 ·
|
|
574
|
+
* 계측 08-12 · 작업지시 05-10 · 공정·로트 04-15). 「붙어 있다」 하나로 뭉치면 넉 달 멈춘
|
|
575
|
+
* 흐름이 살아 있는 것으로 읽힌다.
|
|
576
|
+
*
|
|
577
|
+
* **계약에는 자리가 있다** — `LiveFeedCursor.since` 가 「그 흐름에서 마지막으로 본 시각
|
|
578
|
+
* (원본의 시각)」이고 `onCursor` 가 그것을 `TwinReference.liveCursor` 에 적는다. 없던 것은
|
|
579
|
+
* **화면으로 가는 문**이고, 그 문이 여기다.
|
|
580
|
+
*
|
|
581
|
+
* ★ **다만 지금은 비어 있다** — 실 저장값을 확인했다:
|
|
582
|
+
* `{"streams":{},"firstAttachedAt":"2026-03-25T…"}`. 즉 어댑터가 흐름별 커서를 적지 않는다.
|
|
583
|
+
* 그래서 이 문을 열어도 당장은 빈 목록이 나오고, **그것이 정직한 답이다**: 어댑터가 말하지
|
|
584
|
+
* 않는 것을 여기서 지어내지 않는다. 채우는 것은 어댑터의 몫이고 그쪽에 알렸다.
|
|
585
|
+
*
|
|
586
|
+
* 빈 목록과 「흐름이 없다」를 화면이 구별할 수 있어야 한다 — 이 값이 비면 그것은
|
|
587
|
+
* 「어댑터가 아직 말하지 않는다」이고 「원본에 흐름이 없다」가 아니다.
|
|
588
|
+
*
|
|
589
|
+
* `seen`(중복 걸러낸 행 id)은 내지 않는다 — 읽기 내부 상태이고 수천 개가 될 수 있다.
|
|
590
|
+
* 흐름 열쇠는 **어댑터가 정한다**(계약이 이름을 닫지 않는다). 그래서 이름을 번역하지 않고
|
|
591
|
+
* 그대로 낸다 — 화면이 모르는 이름을 만나면 그 이름을 보이는 것이 맞다.
|
|
592
|
+
*/
|
|
593
|
+
streams: Object.entries((ref.liveCursor as any)?.streams ?? {})
|
|
594
|
+
.map(([stream, c]: [string, any]) => ({ stream, since: c?.since ?? null }))
|
|
595
|
+
/* 시각을 말하지 않는 흐름도 낸다 — 「모른다」와 「없다」는 다르고, 빼면 그 흐름이 사라진다. */
|
|
596
|
+
.sort((a, b) => String(b.since ?? '').localeCompare(String(a.since ?? ''))),
|
|
597
|
+
firstAttachedAt: (ref.liveCursor as any)?.firstAttachedAt ?? null
|
|
598
|
+
}
|
|
531
599
|
: null,
|
|
532
600
|
/*
|
|
533
601
|
* **다시 읽을 수 있는가** — 이 트윈이 자기 원본을 기억하고 있나(`TwinInstance.origin`).
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ★ **아무것도 듣지 못한 것을 「비었다」로 적지 않는다** — 체크포인트가 좋은 스냅샷을 덮지 않게.
|
|
3
|
+
*
|
|
4
|
+
* ── 무엇이 손실을 영구화하고 있었나 (2026-08-24 실측) ────────────────────────
|
|
5
|
+
* 미러가 재기동하면 관측 축(재고·오더·작업)을 들고 오지 않는다(`startLive` 가 되찾은 상태를 버린다 —
|
|
6
|
+
* 「다음 계측이 정정한다」는 전제). 그런데 원본이 **커서 증분**으로 말하는 현장에서는 그 정정이 오지
|
|
7
|
+
* 않는다: 커서가 따라잡힌 뒤 원본이 변하지 않으면 미러는 영구히 빈 채다.
|
|
8
|
+
*
|
|
9
|
+
* 실측(포천, `operato-chef-v1-pocheon`):
|
|
10
|
+
*
|
|
11
|
+
* 저널을 접으면 items 741 · orders 2,856 · tasks 9,250 · nowTime 2026-04-15
|
|
12
|
+
* 라이브 미러 items 0 · orders 0 · tasks 0 · nowTime 2026-01-01
|
|
13
|
+
* 저장된 스냅샷 rev 221,884 · items 741 · orders 2,780 · tasks 6,353 (어제 것)
|
|
14
|
+
* 그런데 이 판이 같은 키에 **빈 상태**를 쓰고 있었다
|
|
15
|
+
*
|
|
16
|
+
* 그러면 되살릴 캐시가 사라진다. 재기동이 상태를 버리는 것은 저널에서 되살릴 수 있지만, **캐시를
|
|
17
|
+
* 지우는 것은 그 복구 수단을 함께 지운다.**
|
|
18
|
+
*
|
|
19
|
+
* ── 왜 리비전으로는 막을 수 없나 ────────────────────────────────────────────
|
|
20
|
+
* 저널 줄 번호는 **관측이 없어도 자란다**(다른 축·다른 트윈의 사건, 체크포인트 자체). 그래서
|
|
21
|
+
* 「리비전이 더 크면 진전」이라는 규칙은 빈 상태를 통과시킨다.
|
|
22
|
+
*
|
|
23
|
+
* 막는 기준은 **「들은 것이 있나」**다. 유입이 한 건도 없었다면 그 빈 상태는 원본이 「비었다」고
|
|
24
|
+
* 말한 것이 아니라 **우리가 아무것도 못 들은 것**이다 — 그 둘을 같은 값으로 적으면 「모름」이
|
|
25
|
+
* 「없음」이 된다. 이 저장소가 여러 번 거절한 모양이다.
|
|
26
|
+
*
|
|
27
|
+
* 원본이 실제로 「다 비었다」고 말한 경우는 막지 않는다 — 그때는 유입이 있었으므로 이 문을 지난다.
|
|
28
|
+
*
|
|
29
|
+
* ── 엔진 본체는 부를 수 없다 ────────────────────────────────────────────────
|
|
30
|
+
* DB 를 물고 있어 시험에서 부르지 못한다(§`snapshot-freshness` 와 같은 사정). 그래서 판정식만 떼어
|
|
31
|
+
* 시험하고, **그 식이 엔진에 실제로 있는지**를 소스로 함께 확인한다 — 식만 시험하면 엔진이 그것을
|
|
32
|
+
* 쓰지 않아도 초록이다.
|
|
33
|
+
*/
|
|
34
|
+
import { test } from 'node:test'
|
|
35
|
+
import assert from 'node:assert/strict'
|
|
36
|
+
import { readFileSync } from 'node:fs'
|
|
37
|
+
import { join } from 'node:path'
|
|
38
|
+
|
|
39
|
+
/** 엔진의 조건과 같은 식(`twin-engine.persistSnapshot`). */
|
|
40
|
+
const observed = (s: any): number => (s?.items?.length ?? 0) + (s?.orders?.length ?? 0) + (s?.tasks?.length ?? 0)
|
|
41
|
+
|
|
42
|
+
const refuses = (live: any, stored: any, ingestedTotal: number): boolean =>
|
|
43
|
+
!(ingestedTotal > 0) && observed(live) === 0 && observed(stored) > 0
|
|
44
|
+
|
|
45
|
+
const RICH = { items: [{ epc: 'a' }], orders: [{ id: 'o' }], tasks: [{ id: 't' }] }
|
|
46
|
+
const EMPTY = { items: [], orders: [], tasks: [] }
|
|
47
|
+
|
|
48
|
+
test('못 들었고 라이브가 비었는데 저장된 것에 사실이 있으면 — **거절한다**', () => {
|
|
49
|
+
/* ★ 이 시험이 막는 것. 통과시키면 되살릴 유일한 사본이 사라진다. */
|
|
50
|
+
assert.equal(refuses(EMPTY, RICH, 0), true)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('들은 것이 있으면 통과한다 — 원본이 「다 비었다」고 말한 것은 사실이다', () => {
|
|
54
|
+
/*
|
|
55
|
+
* 창고가 실제로 비는 것은 정상이다. 그때는 유입이 있었으므로 그 빈 상태가 **관측**이고,
|
|
56
|
+
* 여기서 막으면 스냅샷이 영구히 낡는다(고치려던 것의 반대 방향으로 틀린다).
|
|
57
|
+
*/
|
|
58
|
+
assert.equal(refuses(EMPTY, RICH, 1), false)
|
|
59
|
+
assert.equal(refuses(EMPTY, RICH, 24839), false)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('라이브에 사실이 있으면 통과한다 — 못 들었어도 심어진 상태는 지킬 값이다', () => {
|
|
63
|
+
assert.equal(refuses(RICH, RICH, 0), false)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('저장된 것이 비어 있으면 막을 것이 없다 — 첫 체크포인트를 가로막지 않는다', () => {
|
|
67
|
+
/* 처음 뜬 트윈은 저장된 것이 없다. 여기서 막으면 그 트윈은 영원히 스냅샷을 갖지 못한다. */
|
|
68
|
+
assert.equal(refuses(EMPTY, EMPTY, 0), false)
|
|
69
|
+
assert.equal(refuses(EMPTY, null, 0), false)
|
|
70
|
+
assert.equal(refuses(EMPTY, undefined, 0), false)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('축 하나만 남아도 사실이다 — 셋을 합쳐 센다', () => {
|
|
74
|
+
/*
|
|
75
|
+
* 오더만 남고 재고가 빈 상태는 흔하다(다 내보낸 창고). 축마다 따로 판정하면 그 상태가
|
|
76
|
+
* 「비었다」로 읽힌다.
|
|
77
|
+
*/
|
|
78
|
+
assert.equal(refuses(EMPTY, { items: [], orders: [{ id: 'o' }], tasks: [] }, 0), true)
|
|
79
|
+
assert.equal(refuses(EMPTY, { items: [], orders: [], tasks: [{ id: 't' }] }, 0), true)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('리비전은 판정에 쓰지 않는다 — 저널 번호는 관측이 없어도 자란다', () => {
|
|
83
|
+
/*
|
|
84
|
+
* ★ 이 단정이 **틀린 기준으로 돌아가는 것**을 막는다. 실측에서 저장된 스냅샷의 리비전은 221,884
|
|
85
|
+
* 였고 라이브는 그보다 큰 번호를 들고 있었다 — 리비전을 기준으로 삼으면 빈 상태가 통과한다.
|
|
86
|
+
*/
|
|
87
|
+
const live = { ...EMPTY, revision: 999999 }
|
|
88
|
+
const stored = { ...RICH, revision: 1 }
|
|
89
|
+
assert.equal(refuses(live, stored, 0), true, '번호가 커도 사실이 없으면 덮지 않는다')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('엔진이 실제로 이 판정을 한다 — 식만 시험하면 엔진이 안 써도 초록이다', () => {
|
|
93
|
+
const src = readFileSync(join(import.meta.dirname, '..', 'server', 'engine', 'twin-engine.ts'), 'utf8')
|
|
94
|
+
/* 주석을 걷어낸 코드만 — 「덮지 않는다」를 적어 둔 근거가 가드를 뒤집지 않게. */
|
|
95
|
+
const code = src.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:])\/\/.*$/gm, '$1')
|
|
96
|
+
const at = code.indexOf('persistSnapshot')
|
|
97
|
+
assert.ok(at > 0, 'persistSnapshot 이 있어야 한다')
|
|
98
|
+
const body = code.slice(at, at + 2000)
|
|
99
|
+
assert.match(body, /ingestedTotal/, '「들은 것이 있나」를 본다')
|
|
100
|
+
assert.match(body, /items\?\.length[\s\S]{0,80}orders\?\.length[\s\S]{0,80}tasks\?\.length/, '관측 축 셋을 합쳐 센다')
|
|
101
|
+
assert.match(body, /checkpoint refused/, '거절을 말한다 — 조용히 거절하지 않는다')
|
|
102
|
+
assert.doesNotMatch(body.slice(0, body.indexOf('checkpoint refused')), /revision >|revision </, '리비전 비교로 판정하지 않는다')
|
|
103
|
+
})
|