@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
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ★ **조치가 반대인 두 사실을 한 이름으로 부르면 그 이름은 정보가 아니라 오해다.**
|
|
3
|
+
*
|
|
4
|
+
* ── 무엇이 반대 방향을 가리켰나 (2026-08-24) ────────────────────────────────
|
|
5
|
+
* 2026-08-23 에 「원본에 닿지 못한다」를 트윈이 말할 수 있게 `onReadFailure` 를 만들었다. 그런데 그 문으로
|
|
6
|
+
* **다른 사실**이 함께 나가고 있었다.
|
|
7
|
+
*
|
|
8
|
+
* 원본에 닿지 못했다 접속 실패·시간 초과 → **기다리면 풀린다**
|
|
9
|
+
* 읽었는데 커서가 못 넘어간다 한 시각에 한 페이지보다 많은 행 → **기다려도 안 풀린다**
|
|
10
|
+
*
|
|
11
|
+
* 둘째는 **읽기가 성공한 실패**다. 원본은 답했고 그 답의 모양이 커서를 이겼다. 그런데 화면이
|
|
12
|
+
* 「원본에 닿지 못한다」고 말하면 사람은 원본을 의심하고 기다린다 — 실제로 필요한 조치는 페이지를
|
|
13
|
+
* 키우거나 같은 시각 안에서 순서를 정하는 것이고, 기다림으로는 영원히 풀리지 않는다.
|
|
14
|
+
*
|
|
15
|
+
* 게다가 그 문구가 스트림 이름을 꺼내는 규칙에 맞지 않아 **어느 표가 막혔는지도 남지 않았다.**
|
|
16
|
+
*
|
|
17
|
+
* ── 이 시험이 지키는 것 ─────────────────────────────────────────────────────
|
|
18
|
+
* ① 두 축이 **서로를 지우지 않는다** ② 두 축이 **각자 뷰로 나간다**(유입이 없는 갈래에서도)
|
|
19
|
+
* ③ 「몇 번·언제부터」를 정체 쪽도 든다 ④ 넘어가면 지운다
|
|
20
|
+
*/
|
|
21
|
+
import { test } from 'node:test'
|
|
22
|
+
import assert from 'node:assert/strict'
|
|
23
|
+
import { readFileSync } from 'node:fs'
|
|
24
|
+
import { join } from 'node:path'
|
|
25
|
+
import {
|
|
26
|
+
clearCursorStall,
|
|
27
|
+
clearReadFailure,
|
|
28
|
+
ingestHealth,
|
|
29
|
+
newIngestLedger,
|
|
30
|
+
recordCursorStall,
|
|
31
|
+
recordIngest,
|
|
32
|
+
recordReadFailure
|
|
33
|
+
} from '../server/engine/ingest-health.ts'
|
|
34
|
+
|
|
35
|
+
const T0 = Date.parse('2026-08-24T06:00:00.000Z')
|
|
36
|
+
|
|
37
|
+
test('정체는 읽기 실패와 **다른 칸**이다 — 서로를 지우지 않는다', () => {
|
|
38
|
+
/*
|
|
39
|
+
* ★ 한 칸을 쓰면 나중에 온 것이 앞의 것을 덮는다. 그러면 「닿지 못한다」와 「못 넘어간다」 중
|
|
40
|
+
* 마지막 것만 남고, 화면은 조치를 반대로 안내할 수 있다.
|
|
41
|
+
*/
|
|
42
|
+
const l = newIngestLedger()
|
|
43
|
+
recordReadFailure(l, 'connect ETIMEDOUT', T0, 'inventory')
|
|
44
|
+
recordCursorStall(l, 'one timestamp holds more rows than a page', T0 + 1000, 'trace')
|
|
45
|
+
assert.equal(l.readFailure?.reason, 'connect ETIMEDOUT', '읽기 실패가 남아 있다')
|
|
46
|
+
assert.equal(l.cursorStall?.reason, 'one timestamp holds more rows than a page')
|
|
47
|
+
assert.equal(l.readFailure?.stream, 'inventory')
|
|
48
|
+
assert.equal(l.cursorStall?.stream, 'trace', '어느 표가 막혔나가 조치의 절반이다')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('각자 지워진다 — 하나가 풀렸다고 다른 하나를 지우지 않는다', () => {
|
|
52
|
+
const l = newIngestLedger()
|
|
53
|
+
recordReadFailure(l, 'ETIMEDOUT', T0)
|
|
54
|
+
recordCursorStall(l, 'cursor cannot advance', T0)
|
|
55
|
+
clearReadFailure(l)
|
|
56
|
+
assert.equal(l.readFailure, undefined, '다시 닿았다')
|
|
57
|
+
assert.ok(l.cursorStall, '그렇다고 커서가 넘어간 것은 아니다')
|
|
58
|
+
clearCursorStall(l)
|
|
59
|
+
assert.equal(l.cursorStall, undefined, '넘어갔으면 지운다')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('「몇 번·언제부터」를 정체 쪽도 든다 — 한 번 밀린 것과 사흘째는 다르다', () => {
|
|
63
|
+
const l = newIngestLedger()
|
|
64
|
+
recordCursorStall(l, 'stuck at 2026-04-15T01:49', T0)
|
|
65
|
+
recordCursorStall(l, 'stuck at 2026-04-15T01:49', T0 + 600_000)
|
|
66
|
+
recordCursorStall(l, 'stuck at 2026-04-15T01:49', T0 + 1_200_000)
|
|
67
|
+
assert.equal(l.cursorStall?.consecutive, 3)
|
|
68
|
+
assert.equal(l.cursorStall?.sinceMs, T0, '처음 막힌 시각을 지킨다')
|
|
69
|
+
assert.equal(l.cursorStall?.lastAtMs, T0 + 1_200_000)
|
|
70
|
+
/* 넘어간 뒤 다시 막히면 **새 정체**다 — 옛 시작 시각을 물려받지 않는다. */
|
|
71
|
+
clearCursorStall(l)
|
|
72
|
+
recordCursorStall(l, 'stuck again', T0 + 3_600_000)
|
|
73
|
+
assert.equal(l.cursorStall?.consecutive, 1)
|
|
74
|
+
assert.equal(l.cursorStall?.sinceMs, T0 + 3_600_000)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('뷰로 나간다 — **유입이 없는 갈래에서도**', () => {
|
|
78
|
+
/*
|
|
79
|
+
* ★ 여기가 가장 조용해지기 쉬운 자리다. 커서가 막히면 유입이 0이므로 장부가 「빈 장부」 갈래를
|
|
80
|
+
* 지나고, 그 갈래에서 이 값을 떨어뜨리면 **정체가 실제로 일어나는 바로 그 상황에서** 침묵한다.
|
|
81
|
+
* `unhandled` 를 스냅샷이 떨어뜨려 정합성 검사가 영원히 조용했던 것과 같은 부류다.
|
|
82
|
+
*/
|
|
83
|
+
const l = newIngestLedger()
|
|
84
|
+
recordCursorStall(l, 'one timestamp holds more rows than a page', T0, 'trace')
|
|
85
|
+
const v = ingestHealth(l, 'attached', undefined, T0 + 60_000)
|
|
86
|
+
assert.ok(v.cursorStall, '유입이 없어도 뷰에 실린다')
|
|
87
|
+
assert.equal(v.cursorStall?.consecutive, 1)
|
|
88
|
+
assert.equal(v.cursorStall?.since, new Date(T0).toISOString())
|
|
89
|
+
assert.equal(v.cursorStall?.stream, 'trace')
|
|
90
|
+
assert.equal(v.readFailure, undefined, '닿지 못한 것은 아니다 — 섞이지 않는다')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('유입이 있는 갈래에서도 나간다 — 두 갈래를 다 채운다', () => {
|
|
94
|
+
/* 갈래가 둘인 함수에서 한쪽만 채우는 실수를 이 저장소가 이미 겪었다. */
|
|
95
|
+
const l = newIngestLedger()
|
|
96
|
+
recordIngest(l, 5, [], T0)
|
|
97
|
+
recordCursorStall(l, 'cursor cannot advance', T0 + 1000, 'lot')
|
|
98
|
+
const v = ingestHealth(l, 'attached', undefined, T0 + 2000)
|
|
99
|
+
assert.equal(v.total.offered, 5, '유입이 있는 갈래다 — 빈 비교로 통과하지 않는다')
|
|
100
|
+
assert.equal(v.cursorStall?.stream, 'lot')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('막히지 않았으면 그 칸을 만들지 않는다 — 빈 값이 「막혔다」로 읽히지 않게', () => {
|
|
104
|
+
const l = newIngestLedger()
|
|
105
|
+
recordIngest(l, 3, [], T0)
|
|
106
|
+
assert.equal(ingestHealth(l, 'attached', undefined, T0 + 1000).cursorStall, undefined)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('두 사실이 같은 문으로 나가지 않는다 — 배선을 소스로 확인한다', () => {
|
|
110
|
+
/*
|
|
111
|
+
* 장부만 시험하면 **호스트가 여전히 읽기 실패 문으로 보내도** 초록이다. 실제로 그랬던 것이 이 결함의
|
|
112
|
+
* 시작이었다.
|
|
113
|
+
*/
|
|
114
|
+
const src = readFileSync(join(import.meta.dirname, '..', 'server', 'service', 'reference', 'reference-live.ts'), 'utf8')
|
|
115
|
+
const code = src.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:])\/\/.*$/gm, '$1')
|
|
116
|
+
assert.match(code, /onCursorStall:/, '어댑터의 정체 통로를 잇는다')
|
|
117
|
+
assert.match(code, /recordIngestCursorStall/, '정체는 정체 장부로 간다')
|
|
118
|
+
/* 그리고 그 문구가 「닿지 못한다」와 달라야 한다 — 같은 말이면 화면이 여전히 반대로 안내한다. */
|
|
119
|
+
const at = code.indexOf('onCursorStall:')
|
|
120
|
+
const body = code.slice(at, at + 900)
|
|
121
|
+
assert.match(body, /cursor cannot advance/, '무엇이 막혔는지 말한다')
|
|
122
|
+
assert.match(body, /Waiting will not clear this/, '기다림이 답이 아니라고 말한다')
|
|
123
|
+
assert.doesNotMatch(body, /recordIngestReadFailure/, '읽기 실패 장부로 보내지 않는다')
|
|
124
|
+
/* 재부착의 자격(`status`)은 여기서도 건드리지 않는다 — 2026-08-23 에 되돌린 그 규율. */
|
|
125
|
+
assert.doesNotMatch(body, /status:/, '정체가 피드를 영구히 끄지 않는다')
|
|
126
|
+
})
|
package/test/item-ref.test.ts
CHANGED
|
@@ -8,12 +8,12 @@
|
|
|
8
8
|
* ── 그리고 고치는 방향을 한 번 더 고쳤다 ────────────────────────────────────
|
|
9
9
|
* 처음에는 「`@` 뒤는 개체다」를 규칙으로 넣었다. 그것은 **한 원본(chef)의 관행**이고, 커널 계약이 그
|
|
10
10
|
* 구분자를 정한 적이 없다 — 사용자가 「이 화면이 특정 레거시를 기준으로 해서는 안 된다」고 짚었다.
|
|
11
|
-
* 그래서 자르는 판단을 걷고, **그 트윈의 선언과
|
|
11
|
+
* 그래서 자르는 판단을 걷고, **그 트윈의 선언과 비교해** 답한다.
|
|
12
12
|
*
|
|
13
13
|
* 이 시험이 지키는 것:
|
|
14
14
|
* ① SGTIN 은 표준이 정한 자리에서 품목 참조를 꺼낸다.
|
|
15
15
|
* ② 그 밖의 모양은 **손대지 않는다**(구분자를 모른다).
|
|
16
|
-
* ③ 선언과
|
|
16
|
+
* ③ 선언과 비교할 때는 두 방향을 본다 — 관측이 선언을 확장한 경우, 화면이 짧게 줄인 경우.
|
|
17
17
|
* ④ 후보가 둘 이상이면 **답하지 않는다**(모르는 것이 틀린 답보다 낫다).
|
|
18
18
|
*/
|
|
19
19
|
import { test } from 'node:test'
|
|
@@ -64,7 +64,7 @@ test('화면이 짧게 줄인 경우 — 선언이 그것으로 끝난다', () =
|
|
|
64
64
|
assert.equal(resolveByDeclaration('product-M0000438', DECLARED), 'M0000438')
|
|
65
65
|
})
|
|
66
66
|
|
|
67
|
-
test('후보가 둘 이상이면 답하지 않는다 — 남의 레시피와
|
|
67
|
+
test('후보가 둘 이상이면 답하지 않는다 — 남의 레시피와 비교하지 않게', () => {
|
|
68
68
|
const ambiguous = new Map<string, string>([
|
|
69
69
|
['https://a.net/class/product-1', 'A1'],
|
|
70
70
|
['https://b.net/class/product-1', 'B1']
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ★ **재기동이 저널을 처음부터 다시 접지 않는다** — 미러가 저장된 재개점에서 이어 선다.
|
|
3
|
+
*
|
|
4
|
+
* ── 무엇이 오래 잘못돼 있었나 (2026-08-24 실측) ─────────────────────────────
|
|
5
|
+
* 재개점을 **읽는** 쪽은 다 있었다: `recover` 는 `fold` 가 있으면 꼬리만 접고, 없으면 저널을 0부터
|
|
6
|
+
* 접는다. 그런데 **쓰는 쪽이 없었다** — `persistSnapshot` 은 `{revision, state, structureRev}` 만
|
|
7
|
+
* 적었다. 실측으로 저장된 스냅샷 34건 전부 `fold` 가 비어 있었고, 저널은 2,960만 줄이었다.
|
|
8
|
+
*
|
|
9
|
+
* 그래서 두 가지가 함께 일어났다.
|
|
10
|
+
*
|
|
11
|
+
* ① 켤 때마다 처음부터 접었다 — 규모 기준(엔티티 10만·품목 100만)에서 느린 것이 아니라 못 하는 것이다
|
|
12
|
+
* ② 미러는 되찾은 상태를 **버렸다**(`startLive`). 「다음 계측이 정정한다」는 전제였는데, 원본이 커서
|
|
13
|
+
* 증분으로 말하는 현장에서는 그 정정이 오지 않는다 — 미러가 영구히 빈 채로 남고 화면은 조용했다
|
|
14
|
+
*
|
|
15
|
+
* ── 왜 커널 시험만으로 충분하지 않나 ────────────────────────────────────────
|
|
16
|
+
* 커널은 「재개점 + 꼬리 == 0부터 접기」를 증명한다(`observed-checkpoint.test.ts`). 그것이 참이어도
|
|
17
|
+
* 호스트가 재개점을 **적지 않거나 되세우지 않으면** 아무 일도 일어나지 않는다 — 이 저장소가 실제로
|
|
18
|
+
* 넉 달 동안 그 상태였다. 그래서 여기서 DB 를 물고 실제로 걷는다.
|
|
19
|
+
*
|
|
20
|
+
* ── 결정적인 단정 ───────────────────────────────────────────────────────────
|
|
21
|
+
* 재기동 전에 **저널을 지운다.** 그래도 상태가 돌아오면 그것은 저널을 접어 온 것이 **아니다** —
|
|
22
|
+
* 저장된 재개점에서 온 것이다. 그리고 그 상황은 가정이 아니라 설계된 거동이다: 보존 정책이 체크포인트
|
|
23
|
+
* 리비전 이하의 행을 지운다(「그 앞은 스냅샷이 대신한다」).
|
|
24
|
+
*/
|
|
25
|
+
import { test, before, after } from 'node:test'
|
|
26
|
+
import assert from 'node:assert/strict'
|
|
27
|
+
import { createRequire } from 'node:module'
|
|
28
|
+
import type { DataSource } from 'typeorm'
|
|
29
|
+
|
|
30
|
+
const req = createRequire(import.meta.url)
|
|
31
|
+
const { NamingStrategy, addDataSource, entities: shellEntities, Domain } = req('@things-factory/shell')
|
|
32
|
+
const { entities: authEntities } = req('@things-factory/auth-base')
|
|
33
|
+
const { entities: twinEntities } = req('../dist-server/service/index.js')
|
|
34
|
+
/* 체크포인트는 `cache-service` 표에 앉는다 — 그 엔티티를 빼면 저장이 조용히 실패한다(스냅샷도 씨앗도). */
|
|
35
|
+
const { entities: cacheEntities } = req('@things-factory/cache-service/dist-server/service/index.js')
|
|
36
|
+
const { TwinEngine } = req('../dist-server/engine/index.js')
|
|
37
|
+
const { TwinEvent } = req('../dist-server/service/twin-event/twin-event.js')
|
|
38
|
+
const { DataSource: TypeOrmDataSource } = req('typeorm')
|
|
39
|
+
|
|
40
|
+
let ds: DataSource
|
|
41
|
+
let domainId: string
|
|
42
|
+
|
|
43
|
+
const INSTANCE = 'resume-probe'
|
|
44
|
+
|
|
45
|
+
/** 미러 하나 — 원본이 사실을 말해 주는 트윈이므로 시나리오도 레시피도 필요 없다. */
|
|
46
|
+
const master = (): any => ({
|
|
47
|
+
source: INSTANCE,
|
|
48
|
+
system: 'wms',
|
|
49
|
+
siteName: '재개점 창고',
|
|
50
|
+
origin: { kind: 'reference' }, // 현실 출처 선언 — 이것이 미러로 뜨는 근거다
|
|
51
|
+
space: { width: 40, depth: 20, unit: 'm' },
|
|
52
|
+
locations: [
|
|
53
|
+
{ id: 'dock', type: 'dock', role: 'location', capacity: 100 },
|
|
54
|
+
{ id: 'bin-1', type: 'storage', role: 'location', capacity: 100 }
|
|
55
|
+
],
|
|
56
|
+
equipment: [{ id: 'fk-1', kind: 'forklift', homeLocationId: 'dock' }]
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
/** 원본이 말한 사실 — 오더와 작업. 미러는 이것을 접어 상태를 만든다. */
|
|
60
|
+
function facts(n: number): any[] {
|
|
61
|
+
const out: any[] = []
|
|
62
|
+
for (let i = 1; i <= n; i++) {
|
|
63
|
+
out.push({
|
|
64
|
+
eventId: `o-${i}`,
|
|
65
|
+
eventType: 'order.status',
|
|
66
|
+
eventTime: '2026-08-24T01:00:00.000Z',
|
|
67
|
+
tenantId: domainId,
|
|
68
|
+
data: { orderId: `WO-${i}`, kind: 'workorder', status: 'in-progress', requested: 10, fulfilled: 3 }
|
|
69
|
+
})
|
|
70
|
+
out.push({
|
|
71
|
+
eventId: `t-${i}`,
|
|
72
|
+
eventType: 'task.status',
|
|
73
|
+
eventTime: '2026-08-24T01:00:01.000Z',
|
|
74
|
+
tenantId: domainId,
|
|
75
|
+
data: {
|
|
76
|
+
taskId: `TK-${i}`,
|
|
77
|
+
kind: 'putaway',
|
|
78
|
+
status: 'in-progress',
|
|
79
|
+
orderId: `WO-${i}`,
|
|
80
|
+
fromNode: 'dock',
|
|
81
|
+
toNode: 'bin-1',
|
|
82
|
+
intent: 'transport',
|
|
83
|
+
durationMs: 60000,
|
|
84
|
+
remainingMs: 30000
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
return out
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const observed = (s: any): number => (s?.items?.length ?? 0) + (s?.orders?.length ?? 0) + (s?.tasks?.length ?? 0)
|
|
92
|
+
|
|
93
|
+
before(async () => {
|
|
94
|
+
ds = new TypeOrmDataSource({
|
|
95
|
+
type: 'sqlite',
|
|
96
|
+
database: ':memory:',
|
|
97
|
+
synchronize: true,
|
|
98
|
+
dropSchema: true,
|
|
99
|
+
namingStrategy: new NamingStrategy(),
|
|
100
|
+
entities: [...shellEntities, ...authEntities, ...twinEntities, ...cacheEntities],
|
|
101
|
+
logging: false
|
|
102
|
+
})
|
|
103
|
+
await ds.initialize()
|
|
104
|
+
addDataSource('default', ds)
|
|
105
|
+
const d = await ds.getRepository(Domain).save({ name: 'resume-test', subdomain: 'resume-test' } as any)
|
|
106
|
+
domainId = d.id
|
|
107
|
+
await TwinEngine.ingestMaster(domainId, master(), 'reset')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
after(async () => {
|
|
111
|
+
await TwinEngine.stop(domainId, INSTANCE).catch(() => {})
|
|
112
|
+
await ds?.destroy()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('체크포인트가 **이어 접을 씨앗을 함께 적는다** — 이 줄이 없어서 넉 달 동안 처음부터 접었다', async () => {
|
|
116
|
+
const rt = await TwinEngine.bootDeclared(domainId, INSTANCE, 'resync')
|
|
117
|
+
assert.equal(rt.mode, 'live', '미러로 떠야 이 검사가 뜻이 있다')
|
|
118
|
+
|
|
119
|
+
const applied = TwinEngine.ingestLive(domainId, INSTANCE, facts(5))
|
|
120
|
+
assert.equal(applied, 10, '원본이 말한 사실이 커널에 들어갔다')
|
|
121
|
+
const before = TwinEngine.snapshot(domainId, INSTANCE)
|
|
122
|
+
assert.ok(observed(before) > 0, `상태에 사실이 있어야 이 검사가 뜻이 있다 (실제 ${observed(before)})`)
|
|
123
|
+
|
|
124
|
+
await TwinEngine.persistSnapshot(domainId, INSTANCE)
|
|
125
|
+
const stored = await TwinEngine.loadSnapshot(domainId, INSTANCE)
|
|
126
|
+
assert.ok(stored, '스냅샷이 저장됐다')
|
|
127
|
+
assert.ok(stored.fold?.reducer, '★ 재개점이 함께 적혀야 한다 — 없으면 다음 기동이 저널을 0부터 접는다')
|
|
128
|
+
assert.ok(stored.fold?.oee, '가동 누적기도 함께 — 상태만 되돌리면 가동률이 부팅 이후로만 잡힌다')
|
|
129
|
+
/* 씨앗이 실제로 사실을 들었는지 — 빈 껍데기를 적어 두고 초록이 되지 않게. */
|
|
130
|
+
assert.ok((stored.fold.reducer as any).orders?.length, '씨앗에 오더가 담겨 있다')
|
|
131
|
+
assert.ok((stored.fold.reducer as any).tasks?.length, '씨앗에 작업이 담겨 있다')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test('★ 저널을 지워도 미러가 상태를 들고 다시 선다 — 그러므로 저널을 접은 것이 아니다', async () => {
|
|
135
|
+
/*
|
|
136
|
+
* 이 단정이 이 축의 전부다. 저널이 없는데 상태가 돌아오면 그 상태는 **저장된 재개점**에서 온 것이다.
|
|
137
|
+
* 그리고 이 상황은 가정이 아니다 — 보존 정책이 체크포인트 리비전 이하의 행을 실제로 지운다.
|
|
138
|
+
*/
|
|
139
|
+
const before = observed(TwinEngine.snapshot(domainId, INSTANCE))
|
|
140
|
+
assert.ok(before > 0, '멈추기 전에 상태가 있어야 이 검사가 뜻이 있다')
|
|
141
|
+
|
|
142
|
+
await TwinEngine.stop(domainId, INSTANCE)
|
|
143
|
+
/* 이 인스턴스의 저널만 — 정확한 소유 표식으로 지운다. */
|
|
144
|
+
await ds.getRepository(TwinEvent).delete({ domain: { id: domainId } as any, instanceId: INSTANCE } as any)
|
|
145
|
+
assert.equal(
|
|
146
|
+
await ds.getRepository(TwinEvent).countBy({ domain: { id: domainId } as any, instanceId: INSTANCE } as any),
|
|
147
|
+
0,
|
|
148
|
+
'저널이 비었다 — 이제 접을 것이 없다'
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
await TwinEngine.bootDeclared(domainId, INSTANCE, 'resync')
|
|
152
|
+
const after = TwinEngine.snapshot(domainId, INSTANCE)
|
|
153
|
+
assert.equal(observed(after), before, '★ 사실이 그대로 돌아왔다 — 저널 없이')
|
|
154
|
+
assert.equal(after.orders.length, 5, '오더 다섯이 그대로다')
|
|
155
|
+
assert.equal(after.tasks.length, 5, '작업 다섯이 그대로다')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test('되세운 뒤 들어온 사실이 앞의 것을 덮지 않는다 — 더해진다', async () => {
|
|
159
|
+
/*
|
|
160
|
+
* 미러는 첫 봉투가 올 때 리듀서를 만든다. 되돌리기가 그보다 먼저 서지 않으면 첫 봉투가 **빈 리듀서**를
|
|
161
|
+
* 만들고 되세운 것을 덮는다 — 오류 없이 상태가 사라진다.
|
|
162
|
+
*/
|
|
163
|
+
const before = observed(TwinEngine.snapshot(domainId, INSTANCE))
|
|
164
|
+
TwinEngine.ingestLive(domainId, INSTANCE, [
|
|
165
|
+
{
|
|
166
|
+
eventId: 'o-new',
|
|
167
|
+
eventType: 'order.status',
|
|
168
|
+
eventTime: '2026-08-24T02:00:00.000Z',
|
|
169
|
+
tenantId: domainId,
|
|
170
|
+
data: { orderId: 'WO-NEW', kind: 'workorder', status: 'in-progress', requested: 1, fulfilled: 0 }
|
|
171
|
+
} as any
|
|
172
|
+
])
|
|
173
|
+
const after = TwinEngine.snapshot(domainId, INSTANCE)
|
|
174
|
+
assert.equal(observed(after), before + 1, '앞의 것이 남고 새 것이 더해진다')
|
|
175
|
+
assert.ok(after.orders.some((o: any) => o.id === 'WO-1'), '되세운 오더가 남아 있다')
|
|
176
|
+
assert.ok(after.orders.some((o: any) => o.id === 'WO-NEW'), '새 오더도 있다')
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('씨앗이 없으면 지어내지 않는다 — 전과 같이 빈 채로 시작한다', async () => {
|
|
180
|
+
/*
|
|
181
|
+
* 처음 뜨는 트윈에는 씨앗이 없다. 여기서 무엇이든 심으면 없던 재고가 생긴다 — 그것은 이 작업이
|
|
182
|
+
* 고치려던 것보다 나쁘다.
|
|
183
|
+
*/
|
|
184
|
+
const FRESH = 'resume-probe-fresh'
|
|
185
|
+
await TwinEngine.ingestMaster(domainId, { ...master(), source: FRESH }, 'reset')
|
|
186
|
+
try {
|
|
187
|
+
await TwinEngine.bootDeclared(domainId, FRESH, 'resync')
|
|
188
|
+
assert.equal(observed(TwinEngine.snapshot(domainId, FRESH)), 0, '아무것도 듣지 못했으므로 비어 있다')
|
|
189
|
+
} finally {
|
|
190
|
+
await TwinEngine.stop(domainId, FRESH).catch(() => {})
|
|
191
|
+
}
|
|
192
|
+
})
|
|
@@ -87,7 +87,18 @@ test('체크포인트는 두 수를 나란히 적는다 — 그 사실 자체는
|
|
|
87
87
|
*/
|
|
88
88
|
const at = src.indexOf('static async persistSnapshot')
|
|
89
89
|
assert.ok(at > 0, 'persistSnapshot 이 있어야 한다')
|
|
90
|
-
|
|
90
|
+
/*
|
|
91
|
+
* 창을 넓혀 왔다 — 이 함수 앞쪽에 「빈 상태로 좋은 스냅샷을 덮지 않는다」 가드가 들어오고(§
|
|
92
|
+
* `checkpoint-refuses-empty`), 뒤쪽에 「이어 접을 씨앗을 함께 적는다」가 들어왔다(§
|
|
93
|
+
* `mirror-resumes-from-checkpoint`). 검사하는 **사실은 그대로**다.
|
|
94
|
+
*
|
|
95
|
+
* 그리고 **줄 모양이 아니라 두 자리를 검사한다**: 예전에는 `{ revision, state` 라는 한 줄 모양을
|
|
96
|
+
* 봤는데, 저장 인자가 여러 줄로 나뉘자 사실이 그대로인데도 빨강이 됐다. 서식이 검사를 깨면 다음
|
|
97
|
+
* 사람이 검사를 지우게 된다 — 그래서 이름 둘이 함께 실리는지만 본다.
|
|
98
|
+
*/
|
|
99
|
+
const body = src.slice(at, at + 5000)
|
|
91
100
|
assert.match(body, /inst\.revision \?\? state\.revision/, '바깥은 저널 줄 번호를 먼저 쓴다')
|
|
92
|
-
|
|
101
|
+
const call = body.slice(body.indexOf('setInCache'))
|
|
102
|
+
assert.match(call, /revision/, '저널 줄 번호를 싣는다')
|
|
103
|
+
assert.match(call, /state/, '커널이 접은 상태를 함께 싣는다 — 두 자리를 하나로 합치지 않는다')
|
|
93
104
|
})
|
|
@@ -26,6 +26,7 @@ const req = createRequire(import.meta.url)
|
|
|
26
26
|
const { standardsForKind } = req('../dist-server/service/twin-model/standard-coverage.js')
|
|
27
27
|
const { iec61850Coverage } = req('../dist-server/service/twin-model/iec61850-coverage.js')
|
|
28
28
|
const { epcisCoverage } = req('../dist-server/service/twin-model/epcis-coverage.js')
|
|
29
|
+
const { isa95Coverage } = req('../dist-server/service/twin-model/isa95-coverage.js')
|
|
29
30
|
import { readFileSync } from 'node:fs'
|
|
30
31
|
import { fileURLToPath } from 'node:url'
|
|
31
32
|
|
|
@@ -104,3 +105,46 @@ test('계측은 EPCIS 센서 필드로 오지 않는다 — 그 사실을 표가
|
|
|
104
105
|
assert.equal(sensor.behavior, 'none')
|
|
105
106
|
assert.ok(sensor.note, '에너지가 다른 층으로 들어온다는 사실을 근거로 적는다')
|
|
106
107
|
})
|
|
108
|
+
|
|
109
|
+
test('★ 「어디서 보이나」가 화면 등급과 어긋나지 않는다 — 표가 없는 화면으로 사람을 보내지 않게', () => {
|
|
110
|
+
/*
|
|
111
|
+
* ── 무엇이 틀려 있었나 (2026-08-24 실측) ──────────────────────────────────
|
|
112
|
+
* `shownOn` 은 「그 사실이 어느 축에 **담겨 있나**」가 아니라 「**어디서 보이나**」다. 화면이 그것을
|
|
113
|
+
* 「해당 축 항목에 표시됩니다」로 읽어 내므로, `surface: 'none'` 인 줄에 갈 곳이 적혀 있으면 표가
|
|
114
|
+
* **거짓을 말한다** — 사람을 그 개념이 보이지 않는 화면으로 보낸다.
|
|
115
|
+
*
|
|
116
|
+
* 같은 날 넷이 그랬다. 셋은 새로 넣으면서(`TestResult`·`OperationsEvent`), 하나는 오래(`errorDeclaration`
|
|
117
|
+
* — 그 줄의 `note` 는 「화면에 보이는 곳은 아직 없습니다」라고 말하는데 배지는 반대를 말했다).
|
|
118
|
+
*
|
|
119
|
+
* 반대 방향도 본다: 화면에 보이는데 축도 갈 곳도 주석도 없으면 그 줄은 **「진짜 빈칸」으로 읽힌다**
|
|
120
|
+
* (`shownOn` 주석이 정한 규칙이다). `MaterialSublot` 이 그랬다 — 물품 이름으로 보이는데 갈 곳이 비어
|
|
121
|
+
* 있었다.
|
|
122
|
+
*
|
|
123
|
+
* 사람이 눈으로 보고 잡았다. 그래서 기계가 잡게 한다.
|
|
124
|
+
*/
|
|
125
|
+
const AXES = [
|
|
126
|
+
'items', 'locations', 'equipment', 'persons', 'assets', 'tasks', 'orders', 'operations', 'recipes',
|
|
127
|
+
'testSpecifications', 'materialDefinitions', 'materialClasses', 'personnelClasses', 'equipmentClasses',
|
|
128
|
+
'assetClasses', 'productionSpec', 'demandWindows', 'meters'
|
|
129
|
+
]
|
|
130
|
+
const tables: [string, { concepts: any[] }][] = [
|
|
131
|
+
['ISA-95', isa95Coverage(AXES)],
|
|
132
|
+
['EPCIS', epcisCoverage(AXES)],
|
|
133
|
+
['IEC 61850', iec61850Coverage(AXES)]
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
const lies: string[] = []
|
|
137
|
+
const mute: string[] = []
|
|
138
|
+
for (const [name, table] of tables) {
|
|
139
|
+
for (const c of table.concepts) {
|
|
140
|
+
if (c.surface === 'none' && c.shownOn?.length) lies.push(`${name}/${c.std} → ${c.shownOn.join(',')}`)
|
|
141
|
+
if (c.surface !== 'none' && !c.axis && !c.shownOn?.length && !c.note) mute.push(`${name}/${c.std}`)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
assert.deepEqual(lies, [], `화면이 없는데 갈 곳을 적은 줄 — 없는 화면으로 사람을 보낸다:\n${lies.join('\n')}`)
|
|
145
|
+
assert.deepEqual(mute, [], `화면에 보이는데 갈 곳도 주석도 없는 줄 — 「진짜 빈칸」으로 읽힌다:\n${mute.join('\n')}`)
|
|
146
|
+
|
|
147
|
+
/* 빈 비교로 통과하지 않게 — 표가 실제로 여러 줄을 들고 있다. */
|
|
148
|
+
const total = tables.reduce((n, [, t]) => n + t.concepts.length, 0)
|
|
149
|
+
assert.ok(total > 30, `표 셋에 줄이 실제로 있어야 한다 (실제 ${total})`)
|
|
150
|
+
})
|
|
@@ -6,16 +6,16 @@
|
|
|
6
6
|
* 수를 줄여 맞는 척하게 만들지 않고, 무엇으로 이루어진 수인지 함께 낸다.
|
|
7
7
|
*
|
|
8
8
|
* 이 시험이 지키는 것:
|
|
9
|
-
* ① 상태별로
|
|
9
|
+
* ① 상태별로 구분해 세고, **많은 것부터** 낸다(카드가 한 줄로 보이므로 순서가 뜻을 만든다).
|
|
10
10
|
* ② 낱말은 커널의 것 그대로 — 여기서 묶거나 고쳐 부르지 않는다.
|
|
11
|
-
* ③ 상태를 말하지 않는 축은 **필드를 만들지 않는다**(빈 표는 「상태 없음」과 「못 읽음」을
|
|
11
|
+
* ③ 상태를 말하지 않는 축은 **필드를 만들지 않는다**(빈 표는 「상태 없음」과 「못 읽음」을 구분하지 못한다).
|
|
12
12
|
*/
|
|
13
13
|
import { test } from 'node:test'
|
|
14
14
|
import assert from 'node:assert/strict'
|
|
15
15
|
|
|
16
16
|
import { statusTally } from '../server/service/twin-model/status-tally.ts'
|
|
17
17
|
|
|
18
|
-
test('상태별로
|
|
18
|
+
test('상태별로 구분해 세고 많은 것부터 낸다', () => {
|
|
19
19
|
const r = statusTally([
|
|
20
20
|
{ id: 'a', status: 'completed' },
|
|
21
21
|
{ id: 'b', status: 'in-progress' },
|
|
@@ -62,8 +62,50 @@ test('집합 이벤트는 parentID 를, 수량 이벤트는 epcClass 를 품목
|
|
|
62
62
|
assert.equal(epcOf({ data: { quantityList: [{ epcClass: 'urn:epc:idpat:sgtin:0614141.107346.*', quantity: 40 }] } }), 'urn:epc:idpat:sgtin:0614141.107346.*')
|
|
63
63
|
})
|
|
64
64
|
|
|
65
|
-
test('운영 델타의 오더는
|
|
66
|
-
|
|
65
|
+
test('★ 운영 델타의 오더는 `orderId` 다 — 이 시험이 결함을 고정하고 있었다', () => {
|
|
66
|
+
/*
|
|
67
|
+
* ── 무엇이 잘못돼 있었나 (2026-08-24 실측) ────────────────────────────────
|
|
68
|
+
* 이 시험은 `data: { order: 'SO-10021' }` 를 단정했다. **커널은 그런 모양을 낸 적이 없다** —
|
|
69
|
+
* 오더는 `orderId` 다(`OrderStatusDelta.orderId` · `TaskStatusDelta.orderId`). 픽스처가 실제 델타
|
|
70
|
+
* 대신 **스스로 지어낸 모양**을 썼고, 추출기도 같은 이름을 찾고 있었으니 둘이 나란히 초록이었다.
|
|
71
|
+
*
|
|
72
|
+
* 그동안 실 저널에서:
|
|
73
|
+
* order.status 29,403,565 행 — order_id 100% 비어 있음
|
|
74
|
+
* task.status 191,175 행 — 100% 비어 있음
|
|
75
|
+
*
|
|
76
|
+
* 색인 `ix_twin_event_4`(「이 오더가 어디까지 갔나」)가 자기 용도로 못 쓰였고, 화면은 오더를 눌러도
|
|
77
|
+
* 「연결된 이벤트가 없습니다」를 냈다.
|
|
78
|
+
*
|
|
79
|
+
* **교훈**: 승격 키의 픽스처는 **커널이 실제로 내는 델타**여야 한다. 지어낸 모양으로 검사하면
|
|
80
|
+
* 추출기와 시험이 함께 틀린 채로 영원히 초록이다.
|
|
81
|
+
*/
|
|
82
|
+
assert.equal(orderOf({ eventType: 'order.status', data: { orderId: 'WO-260410-0133', status: 'in-progress' } }), 'WO-260410-0133')
|
|
83
|
+
assert.equal(orderOf({ eventType: 'task.status', data: { taskId: 'TK-1', orderId: 'WO-1' } }), 'WO-1')
|
|
84
|
+
/* 오더를 스스로 말하면 그 말이 이긴다 — 거래(PO/SO)와 오더는 같은 것이 아닐 수 있다. */
|
|
85
|
+
assert.equal(
|
|
86
|
+
orderOf({ data: { orderId: 'WO-1', bizTransactionList: [{ bizTransaction: 'urn:epc:id:gdti:0614141.402.2' }] } }),
|
|
87
|
+
'WO-1'
|
|
88
|
+
)
|
|
89
|
+
/* 오더를 말하지 않는 EPCIS 는 거래로 내려간다(전과 같다). */
|
|
90
|
+
assert.equal(orderOf({ data: { bizTransactionList: [{ bizTransaction: 'PO-9' }] } }), 'PO-9')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('★ 승격 키는 **커널이 실제로 내는 델타 모양**으로 검사한다', () => {
|
|
94
|
+
/*
|
|
95
|
+
* 위 결함의 부류는 「추출기가 찾는 이름을 발신자가 쓰지 않는다」이고, 지어낸 픽스처로는 절대 잡히지
|
|
96
|
+
* 않는다. 그래서 실 저널에서 뜬 payload 모양을 그대로 놓고 단정한다 — 세 축이 실제로 채워지는지.
|
|
97
|
+
*/
|
|
98
|
+
const orderDelta = { eventType: 'order.status', data: { orderId: 'order-1', kind: 'workorder', status: 'in-progress' } }
|
|
99
|
+
const taskDelta = {
|
|
100
|
+
eventType: 'task.status',
|
|
101
|
+
data: { taskId: 'task-1', orderId: 'order-1', kind: 'cut', status: 'in-progress', resourceRef: 'cutter-1', fromNode: 'st-a', toNode: 'st-b' }
|
|
102
|
+
}
|
|
103
|
+
const energyEq = { eventType: 'energy.equipment', data: { equipmentId: 'pv-roof', at: '2026-08-17T10:45:20.664Z', generatedKW: 426 } }
|
|
104
|
+
|
|
105
|
+
assert.equal(twinEventKeys(orderDelta).orderId, 'order-1', '오더 사건이 오더 축을 채운다')
|
|
106
|
+
assert.equal(twinEventKeys(taskDelta).orderId, 'order-1', '작업도 자기 오더를 채운다 — 오더의 이력에 작업이 실린다')
|
|
107
|
+
assert.equal(twinEventKeys(taskDelta).moverId, 'cutter-1', '작업이 쓴 자원이 설비 축에 실린다(`resourceRef`)')
|
|
108
|
+
assert.equal(twinEventKeys(energyEq).moverId, 'pv-roof', '에너지 사건의 설비가 설비 축에 실린다(`equipmentId`)')
|
|
67
109
|
})
|
|
68
110
|
|
|
69
111
|
test('없는 값은 빈 문자열이 아니라 undefined — 결측과 빈값을 섞지 않는다', () => {
|
|
@@ -81,6 +123,10 @@ test('설비·무버 축 — 운영 델타의 moverId 를 뽑는다', () => {
|
|
|
81
123
|
assert.equal(equipmentIdOf(ev), 'fork-3')
|
|
82
124
|
const k = twinEventKeys(ev)
|
|
83
125
|
assert.equal(k.moverId, 'fork-3')
|
|
126
|
+
/* 개명 세대가 섞인다 — 읽는 쪽이 흡수한다(`moverId` 옛 채널 · `resourceRef` 작업 · `equipmentId` 에너지). */
|
|
127
|
+
assert.equal(equipmentIdOf({ data: { resourceRef: 'cutter-1' } }), 'cutter-1')
|
|
128
|
+
assert.equal(equipmentIdOf({ data: { equipmentId: 'pv-roof' } }), 'pv-roof')
|
|
129
|
+
assert.equal(equipmentIdOf({ data: { moverId: 'fork-1', resourceRef: 'x' } }), 'fork-1', '앞의 이름이 이긴다')
|
|
84
130
|
/* 운영 델타는 readPoint/bizLocation 이 없다 — 평범한 location 을 위치로 받는다.
|
|
85
131
|
* 빠뜨리면 "이 설비가 어디서 무엇을 했나" 가 위치 축에서 통째로 사라진다. */
|
|
86
132
|
assert.equal(k.locationId, 'rack-1')
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ★ **원본에 있는데 세우지 않은 것** — 그 사실이 스쳐 지나가지 않게.
|
|
3
|
+
*
|
|
4
|
+
* ── 무엇이 조용했나 (2026-08-24) ────────────────────────────────────────────
|
|
5
|
+
* 커넥터가 원본의 사실 일부를 **일부러 받지 않는 일이 정상이다**(입자가 안 맞는다 · 받으면 상태가
|
|
6
|
+
* 거짓이 된다 · 커널 어휘로 옮길 수 없다). 그때 경고를 내는데, 그 경고의 유일한 소비처가 **프로비저닝
|
|
7
|
+
* 마법사**였다 — 트윈을 만드는 동안만 뜨는 화면이다.
|
|
8
|
+
*
|
|
9
|
+
* 그래서 「지시되지 않은 생산계획 8건을 세우지 않았다」가 **한 번 스쳐 지나갈 뿐**이었고, 나중에
|
|
10
|
+
* 사용자가 「원본보다 계획이 적다」를 물으면 답이 어디에도 없었다.
|
|
11
|
+
*
|
|
12
|
+
* 그 사실은 한 번의 사건이 아니라 **상태의 성질**이다 — 세우지 않은 것은 **지금도** 트윈에 없다.
|
|
13
|
+
*
|
|
14
|
+
* ── `unhandled` 의 형제다 ──────────────────────────────────────────────────
|
|
15
|
+
* `unhandled` 리듀서가 **받았는데** 담을 자리를 몰랐다
|
|
16
|
+
* 여기 커넥터가 **보지만 일부러 보내지 않았다** — 이유와 함께
|
|
17
|
+
*
|
|
18
|
+
* ── 그래서 문에서 조회까지 걷는다 ───────────────────────────────────────────
|
|
19
|
+
* 오늘 「계약에 자리는 있고 그 자리로 가는 길이 없다」를 여섯 번 만났다. 장부만 검사하면 어댑터가
|
|
20
|
+
* 부를 문이 없는 것을 못 잡는다 — 그것이 그 결함들이 살아남은 방식이다.
|
|
21
|
+
*/
|
|
22
|
+
import { test } from 'node:test'
|
|
23
|
+
import assert from 'node:assert/strict'
|
|
24
|
+
import { readFileSync } from 'node:fs'
|
|
25
|
+
import { createRequire } from 'node:module'
|
|
26
|
+
|
|
27
|
+
const req = createRequire(import.meta.url)
|
|
28
|
+
const { newIngestLedger, recordWithheld, ingestHealth } = req('../dist-server/engine/ingest-health.js')
|
|
29
|
+
|
|
30
|
+
const REASON = '아직 지시되지 않은 생산계획을 세우지 않았다 — 커널의 created 는 「할당해도 되는 오더」를 뜻한다'
|
|
31
|
+
|
|
32
|
+
test('★ 세우지 않은 것이 조회에 나온다 — 유입이 한 건도 없어도', () => {
|
|
33
|
+
/*
|
|
34
|
+
* 이 갈래가 중요하다: 원본이 말하는데 **전부** 안 받는 경우가 있고, 그때 유입은 0 이다.
|
|
35
|
+
* 유입 있는 갈래에서만 내면 바로 그 상황에서 조용해진다.
|
|
36
|
+
*/
|
|
37
|
+
const l = newIngestLedger()
|
|
38
|
+
recordWithheld(l, REASON, 8, Date.parse('2026-08-24T11:19:16.000Z'))
|
|
39
|
+
const view: any = ingestHealth(l)
|
|
40
|
+
assert.equal(view.withheld?.length, 1)
|
|
41
|
+
assert.equal(view.withheld[0].reason, REASON)
|
|
42
|
+
assert.equal(view.withheld[0].count, 8)
|
|
43
|
+
assert.equal(view.withheld[0].lastAt, '2026-08-24T11:19:16.000Z', '언제 그랬는지 함께 — 「지금도 그런가」를 사람이 판정한다')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('주기마다 불러도 부풀지 않는다 — 누적이 아니라 **지금 세우지 않은 수**다', () => {
|
|
47
|
+
/* 8건이 매 주기 다시 보고되면 8 이어야 한다. 더해서 800 이 되면 거짓이다. */
|
|
48
|
+
const l = newIngestLedger()
|
|
49
|
+
for (let i = 0; i < 100; i++) recordWithheld(l, REASON, 8, 1000 + i)
|
|
50
|
+
assert.equal(l.withheld.length, 1)
|
|
51
|
+
assert.equal(l.withheld[0].count, 8)
|
|
52
|
+
assert.equal(l.withheld[0].lastAtMs, 1099, '마지막 시각으로 움직인다')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('수가 달라지면 마지막 수로 덮는다 — 낡은 수가 남지 않게', () => {
|
|
56
|
+
const l = newIngestLedger()
|
|
57
|
+
recordWithheld(l, REASON, 8, 1000)
|
|
58
|
+
recordWithheld(l, REASON, 3, 2000)
|
|
59
|
+
assert.equal(l.withheld[0].count, 3)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('이유가 여럿이면 각자 한 줄 — 다른 사실을 한 줄로 뭉치지 않는다', () => {
|
|
63
|
+
const l = newIngestLedger()
|
|
64
|
+
recordWithheld(l, REASON, 8, 1000)
|
|
65
|
+
recordWithheld(l, '사람이 올린 문서라 자재와 이어지지 않는다', 46, 1000)
|
|
66
|
+
assert.equal((ingestHealth(l) as any).withheld.length, 2)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('말하지 않은 것은 만들지 않는다 — 빈 목록을 「세우지 않은 것이 있다」로 읽히게 하지 않는다', () => {
|
|
70
|
+
const l = newIngestLedger()
|
|
71
|
+
assert.equal((ingestHealth(l) as any).withheld, undefined, '키 자체가 없다')
|
|
72
|
+
recordWithheld(l, REASON, 0, 1000)
|
|
73
|
+
assert.equal(l.withheld, undefined, '0 은 적지 않는다')
|
|
74
|
+
recordWithheld(l, '', 5, 1000)
|
|
75
|
+
assert.equal(l.withheld, undefined, '이유 없는 것은 적지 않는다')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('★ 어댑터가 부를 문이 있고, 그 문이 장부에 닿는다', () => {
|
|
79
|
+
/*
|
|
80
|
+
* 이 단정이 오늘 여섯 번 만난 부류를 막는다. 장부만 검사하면 **어댑터가 부를 통로가 없는 것**을
|
|
81
|
+
* 못 잡고, 그러면 보내는 쪽에는 「알렸다」로 보이고 화면에는 「없다」로 보인다.
|
|
82
|
+
*/
|
|
83
|
+
const adapter = readFileSync(new URL('../server/service/reference/reference-adapter.ts', import.meta.url), 'utf-8')
|
|
84
|
+
assert.match(adapter, /onWithheld\?: \(info: \{ reason: string; count: number \}\) => void/, '계약에 통로가 있다')
|
|
85
|
+
|
|
86
|
+
const live = readFileSync(new URL('../server/service/reference/reference-live.ts', import.meta.url), 'utf-8')
|
|
87
|
+
const wired = live.slice(live.indexOf('onWithheld'), live.indexOf('onWithheld') + 260)
|
|
88
|
+
assert.match(wired, /recordIngestWithheld/, '그 통로가 장부로 이어진다')
|
|
89
|
+
|
|
90
|
+
const engine = readFileSync(new URL('../server/engine/twin-engine.ts', import.meta.url), 'utf-8')
|
|
91
|
+
const fn = engine.slice(engine.indexOf('static recordIngestWithheld'))
|
|
92
|
+
assert.match(fn.slice(0, 700), /recordWithheld\(ledger/, '엔진이 장부에 적는다')
|
|
93
|
+
/* 실패로 적지 않는다 — 어댑터의 옳은 판단이고, 오류로 적으면 화면이 원본을 의심한다. */
|
|
94
|
+
assert.doesNotMatch(fn.slice(0, 700), /lastError|status:/, '오류로 적지 않는다')
|
|
95
|
+
})
|