@things-factory/headless-twin 10.1.17 → 10.1.18
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/service/reference/reference-adapter.d.ts +12 -4
- package/dist-server/service/reference/reference-adapter.js +59 -30
- package/dist-server/service/reference/reference-adapter.js.map +1 -1
- package/package.json +5 -5
- package/server/service/actuation/command-dispatcher.ts +25 -4
- package/server/service/reference/actuation-routing.ts +29 -2
- package/server/service/reference/actuation-verification.ts +43 -0
- package/server/service/reference/connection-portability.ts +49 -5
- package/server/service/reference/reference-adapter.ts +124 -67
- package/server/service/reference/reference-resolver.ts +17 -4
- package/server/service/reference/twin-reference.ts +47 -0
- package/test/actuation-approval-door.test.ts +18 -2
- package/test/actuation-seam.test.ts +11 -5
- package/test/actuation-verification-state.test.ts +94 -0
- package/test/connector-capability-declaration.test.ts +60 -3
- package/test/rename-twin.test.ts +9 -1
- package/test/save-intake-collision.test.ts +91 -0
- package/test/twin-event-keys.test.ts +11 -1
- package/tsconfig.tsbuildinfo +0 -1
|
@@ -122,12 +122,48 @@ test('선언과 구현이 맞으면 아무 말도 하지 않는다 — 소음이
|
|
|
122
122
|
assert.equal(warnings.length, before)
|
|
123
123
|
})
|
|
124
124
|
|
|
125
|
-
test('선언했는데
|
|
125
|
+
test('★ 선언했는데 증명이 없는 쪽도 말해 준다 — 걸러 내고 조용하면 아무도 못 듣는다', () => {
|
|
126
|
+
/*
|
|
127
|
+
* 2026-09-16 에 뒤집었다(ADR-0036 ③ 개정). 이 시험은 그전까지 「여기서 두 번 말하지 않는다」였다 —
|
|
128
|
+
* 판정이 걸러 내니 경고는 반대 방향만 보면 된다는 읽기였다.
|
|
129
|
+
*
|
|
130
|
+
* 그런데 걸러 낸 사실을 **아무도 듣지 못한다.** 커넥터를 만든 사람은 선언했으니 된 줄 알고, 화면은
|
|
131
|
+
* 못 한다고 말하고, 그 사이에 오류가 하나도 없다. 한 방향만 보는 검사는 **없는 것을 있다고**
|
|
132
|
+
* (선언만 믿을 때) 또는 **있는 것을 없다고**(증명 형태가 하나뿐일 때) 말하게 된다. 둘 다 조용하다.
|
|
133
|
+
*/
|
|
126
134
|
const before = warnings.length
|
|
127
135
|
const a = adapter({ capabilities: ['live'] })
|
|
128
136
|
registerAdapterType(a)
|
|
129
|
-
|
|
130
|
-
|
|
137
|
+
|
|
138
|
+
const said = warnings.slice(before).join('\n')
|
|
139
|
+
assert.ok(said.includes(a.type), '어느 커넥터인지 말한다')
|
|
140
|
+
assert.ok(said.includes('live'), '무엇이 증명되지 않았는지 말한다')
|
|
141
|
+
assert.ok(said.includes('openLiveFeed') && said.includes('handleInbound'), '무엇을 만들면 되는지 둘 다 말한다')
|
|
142
|
+
assert.deepEqual(capabilitiesOf(a), [], '판정은 그대로 걸러 낸다 — 경고는 그 사실을 들리게 할 뿐이다')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('★ 밀어 넣는 원본도 라이브다 — handleInbound 가 live 를 증명한다', () => {
|
|
146
|
+
/*
|
|
147
|
+
* `operato-plant` 은 outbox 가 웹훅으로 밀어 넣는 쪽이라 `openLiveFeed` 가 없다. 증명을 그 하나로
|
|
148
|
+
* 두었을 때 **실 생산 사실을 나르는 유일한 커넥터가 판정에서 live 가 아니었다.**
|
|
149
|
+
*
|
|
150
|
+
* 당김이냐 밀림이냐는 상대 시스템의 성질이지 우리 능력의 종류가 아니다.
|
|
151
|
+
*/
|
|
152
|
+
const pushed = adapter({ capabilities: ['live'], handleInbound: () => ({ records: [] }) })
|
|
153
|
+
const pulled = adapter({ capabilities: ['live'], openLiveFeed: () => () => {} })
|
|
154
|
+
|
|
155
|
+
assert.deepEqual(capabilitiesOf(pushed), ['live'], '밀어 넣는 쪽이 live 로 서지 않는다')
|
|
156
|
+
assert.deepEqual(capabilitiesOf(pulled), ['live'], '당겨 오는 쪽도 그대로')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('★ handleInbound 만 있고 선언을 잊어도 말해 준다 — 증명이 둘이면 경고도 둘을 봐야 한다', () => {
|
|
160
|
+
const before = warnings.length
|
|
161
|
+
const a = adapter({ handleInbound: () => ({ records: [] }) })
|
|
162
|
+
registerAdapterType(a)
|
|
163
|
+
|
|
164
|
+
const said = warnings.slice(before).join('\n')
|
|
165
|
+
assert.ok(said.includes('live'), 'handleInbound 를 live 의 증명으로 세지 않는다')
|
|
166
|
+
assert.ok(said.includes(a.type))
|
|
131
167
|
})
|
|
132
168
|
|
|
133
169
|
test('★ 등록을 막지 않는다 — 커넥터 하나 때문에 호스트가 못 뜨면 안 된다', () => {
|
|
@@ -136,3 +172,24 @@ test('★ 등록을 막지 않는다 — 커넥터 하나 때문에 호스트가
|
|
|
136
172
|
/* 등록은 되었고, 다만 그 능력은 선언되지 않았으므로 판정에서 빠진다 — 그 사실이 경고와 짝이다. */
|
|
137
173
|
assert.deepEqual(capabilitiesOf(a), [])
|
|
138
174
|
})
|
|
175
|
+
|
|
176
|
+
test('★ 어댑터 목록이 내보내는 능력은 판정값이다 — 선언 원본이 아니다', async () => {
|
|
177
|
+
/*
|
|
178
|
+
* 이것이 없어서 `capabilitiesOf` 의 판정값을 **읽는 자리가 하나도 없었다.** `listAdapters()` 가
|
|
179
|
+
* `a.capabilities` 를 그대로 냈고, 연결 화면은 그것을 읽었다. 선언을 믿지 않으려고 만든 문을
|
|
180
|
+
* 아무도 지나지 않은 셈이다 — 구현 없이 능력을 적기만 하면 화면에서 가능한 것으로 보였다.
|
|
181
|
+
*/
|
|
182
|
+
const { listAdapters } = await import('../server/service/reference/reference-adapter.js')
|
|
183
|
+
|
|
184
|
+
const lying = adapter({ capabilities: ['live', 'actuate'] })
|
|
185
|
+
const honest = adapter({ capabilities: ['live'], handleInbound: () => ({ records: [] }) })
|
|
186
|
+
registerAdapterType(lying)
|
|
187
|
+
registerAdapterType(honest)
|
|
188
|
+
|
|
189
|
+
const listed = listAdapters()
|
|
190
|
+
const byType = new Map(listed.map(one => [one.type, one.capabilities ?? []]))
|
|
191
|
+
|
|
192
|
+
assert.ok(listed.length > 0, '목록이 비어 있으면 이 시험이 아무것도 안 잰다')
|
|
193
|
+
assert.deepEqual(byType.get(lying.type), [], '증명 없는 선언이 목록에 그대로 나간다')
|
|
194
|
+
assert.deepEqual(byType.get(honest.type), ['live'], '증명된 능력은 그대로 나간다')
|
|
195
|
+
})
|
package/test/rename-twin.test.ts
CHANGED
|
@@ -28,7 +28,15 @@ const src = readFileSync(FILE, 'utf8')
|
|
|
28
28
|
const body = (() => {
|
|
29
29
|
const at = src.indexOf('async renameTwinInstance')
|
|
30
30
|
assert.ok(at > 0, '이름을 바꾸는 문이 있어야 한다')
|
|
31
|
-
|
|
31
|
+
/*
|
|
32
|
+
* **주석을 걷고 본다.** 이 조각은 다음 문(`deleteTwinInstance`)의 머리 주석까지 품는데, 그 주석은
|
|
33
|
+
* 삭제가 왜 `TwinEngine.remove` 를 부르는지 설명한다. 걷지 않으면 그 산문이 세어져, 이름 바꾸기가
|
|
34
|
+
* 런타임을 건드린다고 읽힌다 — 실제로 그렇게 빨개졌다. 세는 것은 코드지 그 옆의 글이 아니다.
|
|
35
|
+
*/
|
|
36
|
+
return src
|
|
37
|
+
.slice(at, src.indexOf('async deleteTwinInstance'))
|
|
38
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
39
|
+
.replace(/\/\/[^\n]*/g, '')
|
|
32
40
|
})()
|
|
33
41
|
|
|
34
42
|
test('★ 이름을 바꾸는 문이 있다 — 없어서 지우고 다시 세우는 것이 유일한 길이었다', () => {
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ★ **화면 저장도 받는 사이트 충돌을 거부한다** (ADR-0046 ②-1, 2026-09-16)
|
|
3
|
+
*
|
|
4
|
+
* 들여오기에는 충돌 거부가 있는데 화면 저장(`saveTwinReference`)에는 없었다. `intakeEndpoint::intakeSiteId`
|
|
5
|
+
* 가 같은 연결 둘을 만들면 받는 문은 둘 다 200 으로 받고 경고가 0 이다 — 잘못된 공장이 실제로 만든다.
|
|
6
|
+
* 이 검사는 저장의 문이 쓰는 판정(`refuseIntakeCollision`)을 값으로 못 박는다.
|
|
7
|
+
*/
|
|
8
|
+
import { test } from 'node:test'
|
|
9
|
+
import assert from 'node:assert/strict'
|
|
10
|
+
import { readFileSync } from 'node:fs'
|
|
11
|
+
|
|
12
|
+
import { intakeCollisions, refuseIntakeCollision } from '../server/service/reference/connection-portability.js'
|
|
13
|
+
|
|
14
|
+
const intake = { intakeEndpoint: 'https://plant.example/intake/', intakeSiteId: 'system' }
|
|
15
|
+
const existing = [
|
|
16
|
+
{ source: 'mes-line-a', siteName: 'Line A', connectionConfig: { baseUrl: 'https://plant.example', ...intake } }
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
test('★ 같은 받는 사이트를 가리키는 둘째 연결은 거부되고, 부딪힌 연결의 이름을 말한다', () => {
|
|
20
|
+
const r = refuseIntakeCollision('mes-line-b', { baseUrl: 'https://plant.example', ...intake }, existing)
|
|
21
|
+
assert.ok(r, '베낀 연결이 그대로 저장된다')
|
|
22
|
+
assert.equal(r.code, 'intake-site-taken')
|
|
23
|
+
assert.equal(r.params.site, 'system')
|
|
24
|
+
assert.equal(r.params.connection, 'Line A', '무엇과 부딪혔는지 말하지 않는다')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('주소 끝의 / 하나로 비껴가지 않는다', () => {
|
|
28
|
+
const r = refuseIntakeCollision('mes-line-b', { intakeEndpoint: 'https://plant.example/intake', intakeSiteId: 'system' }, existing)
|
|
29
|
+
assert.ok(r)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('자기 자신을 다시 저장하는 것은 충돌이 아니다', () => {
|
|
33
|
+
assert.equal(refuseIntakeCollision('mes-line-a', existing[0].connectionConfig, existing), null)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('사이트를 고치면 저장된다 — 한 글자 고친 사람은 그 자리에서 지난다', () => {
|
|
37
|
+
assert.equal(refuseIntakeCollision('mes-line-b', { ...intake, intakeSiteId: 'line-b' }, existing), null)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('조치 주소가 없는 연결은 부딪힐 것이 없다', () => {
|
|
41
|
+
assert.equal(refuseIntakeCollision('wms-x', { baseUrl: 'https://plant.example', siteId: 'system' }, existing), null)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('★ 저장의 문이 그 판정을 부르고, 거부하면 쓰기 전에 돌아간다', () => {
|
|
45
|
+
const src = readFileSync(new URL('../server/service/reference/reference-resolver.ts', import.meta.url), 'utf8')
|
|
46
|
+
const body = src.slice(src.indexOf('async saveTwinReference('), src.indexOf('async deleteTwinReference('))
|
|
47
|
+
const check = body.indexOf('refuseIntakeCollision(')
|
|
48
|
+
const write = body.indexOf('repo.save(')
|
|
49
|
+
assert.ok(check > 0, 'saveTwinReference 가 충돌 판정을 부르지 않는다')
|
|
50
|
+
assert.ok(check < write, '판정이 쓰기 뒤에 있다 — 이미 저장된 뒤의 거부는 거부가 아니다')
|
|
51
|
+
assert.match(body, /errorCode: taken\.code/)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
/*
|
|
55
|
+
* ★ **같은 충돌을 두 모양으로 건다** (ADR-0053 ②, 2026-09-16).
|
|
56
|
+
*
|
|
57
|
+
* 꾸러미는 `config`, `TwinReference` 행은 `connectionConfig` 다. 전에는 `config` 만 읽어서 행을 그대로
|
|
58
|
+
* 넘기면 `[]` 였다 — 오류도 경고도 없이 저장이 통과했다. 부르는 둘이 마침 옮겨서 부른 덕에 멀쩡했는데,
|
|
59
|
+
* 그건 계약이 아니라 우연이다(이 함수는 화면이 쓰라고 리졸버에서 다시 내보내진다).
|
|
60
|
+
*
|
|
61
|
+
* 옮기기가 다시 갈리는 날 **빨개지는 자리가 여기 하나**다. 그래서 「두 모양이 같은 답을 낸다」를
|
|
62
|
+
* 값으로 센다 — 결정의 내용이 그것이므로.
|
|
63
|
+
*/
|
|
64
|
+
const pair = [
|
|
65
|
+
{ source: 'mes-line-a', ...intake },
|
|
66
|
+
{ source: 'mes-line-b', ...intake }
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
test('★ DB 행 모양(connectionConfig)으로 걸어도 같은 충돌이 나온다 — 「없다」가 편한 답인 자리다', () => {
|
|
70
|
+
const rows = intakeCollisions(pair.map(({ source, ...cfg }) => ({ source, connectionConfig: cfg })))
|
|
71
|
+
|
|
72
|
+
assert.equal(rows.length, 1, '행 모양을 못 알아보고 조용히 넘어갔다')
|
|
73
|
+
assert.deepEqual(rows[0].sources, ['mes-line-a', 'mes-line-b'])
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('★ 두 모양이 같은 답을 낸다 — 옮기기가 갈리면 여기가 빨개진다', () => {
|
|
77
|
+
const asConfig = intakeCollisions(pair.map(({ source, ...cfg }) => ({ source, config: cfg })))
|
|
78
|
+
const asRow = intakeCollisions(pair.map(({ source, ...cfg }) => ({ source, connectionConfig: cfg })))
|
|
79
|
+
|
|
80
|
+
assert.deepEqual(asRow, asConfig)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('섞여 들어와도 한 충돌로 본다 — 들여오기는 실제로 행과 꾸러미를 함께 넘긴다', () => {
|
|
84
|
+
const mixed = intakeCollisions([
|
|
85
|
+
{ source: 'mes-line-a', connectionConfig: { ...intake } },
|
|
86
|
+
{ source: 'mes-line-b', config: { ...intake } }
|
|
87
|
+
])
|
|
88
|
+
|
|
89
|
+
assert.equal(mixed.length, 1, '섞인 목록에서 한쪽만 세었다')
|
|
90
|
+
assert.deepEqual(mixed[0].sources, ['mes-line-a', 'mes-line-b'])
|
|
91
|
+
})
|
|
@@ -170,7 +170,17 @@ test('상한을 넘는 값은 오류 없이 사라지지 않는다 — 잘리되
|
|
|
170
170
|
})
|
|
171
171
|
|
|
172
172
|
test('빈 봉투에도 터지지 않는다', () => {
|
|
173
|
-
|
|
173
|
+
/* `provenance` 도 이 목록에 있어야 한다 — 봉투가 비어도 「선언된 것인지」는 낼 칸이 있다. */
|
|
174
|
+
const empty = {
|
|
175
|
+
action: undefined,
|
|
176
|
+
bizStep: undefined,
|
|
177
|
+
epc: undefined,
|
|
178
|
+
orderId: undefined,
|
|
179
|
+
bizTransaction: undefined,
|
|
180
|
+
locationId: undefined,
|
|
181
|
+
moverId: undefined,
|
|
182
|
+
provenance: undefined
|
|
183
|
+
}
|
|
174
184
|
assert.deepEqual(twinEventKeys(undefined), empty)
|
|
175
185
|
assert.deepEqual(twinEventKeys({}), empty)
|
|
176
186
|
})
|