@things-factory/headless-twin 10.1.17 → 10.1.20
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/actuation/command-dispatcher.d.ts +2 -0
- package/dist-server/service/actuation/command-dispatcher.js +21 -2
- package/dist-server/service/actuation/command-dispatcher.js.map +1 -1
- package/dist-server/service/reference/actuation-routing.js +26 -1
- package/dist-server/service/reference/actuation-routing.js.map +1 -1
- package/dist-server/service/reference/actuation-verification.d.ts +22 -0
- package/dist-server/service/reference/actuation-verification.js +28 -0
- package/dist-server/service/reference/actuation-verification.js.map +1 -0
- package/dist-server/service/reference/connection-portability.d.ts +23 -1
- package/dist-server/service/reference/connection-portability.js +38 -4
- package/dist-server/service/reference/connection-portability.js.map +1 -1
- package/dist-server/service/reference/reference-adapter.d.ts +70 -41
- package/dist-server/service/reference/reference-adapter.js +59 -30
- package/dist-server/service/reference/reference-adapter.js.map +1 -1
- package/dist-server/service/reference/reference-resolver.js +16 -4
- package/dist-server/service/reference/reference-resolver.js.map +1 -1
- package/dist-server/service/reference/twin-reference.d.ts +35 -0
- package/dist-server/service/reference/twin-reference.js +15 -0
- package/dist-server/service/reference/twin-reference.js.map +1 -1
- package/package.json +7 -7
- 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 +1 -1
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
import { CreateDateColumn, Entity, Index, Column, RelationId, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'
|
|
2
2
|
import { ObjectType, Field, ID, Int } from 'type-graphql'
|
|
3
3
|
|
|
4
|
+
import type { RefusalCode, RefusalParams } from '@operato/ops-contract'
|
|
5
|
+
|
|
4
6
|
import { Domain, ScalarObject, encryptedJsonColumn } from '@things-factory/shell'
|
|
5
7
|
|
|
8
|
+
/** 조치 검증 상태 — 「아직 안 해 봤다」는 실패가 아니라 그 자체로 하나의 사실이다. */
|
|
9
|
+
export type ActuationState = 'unverified' | 'verified' | 'failed'
|
|
10
|
+
|
|
11
|
+
/** 마지막 거절 — 언어 중립 코드와 그 값들. 사람 문장은 담지 않는다. */
|
|
12
|
+
export interface ActuationFailure {
|
|
13
|
+
code: RefusalCode
|
|
14
|
+
/** 거절된 시각 (ISO 8601). */
|
|
15
|
+
at: string
|
|
16
|
+
params?: RefusalParams
|
|
17
|
+
}
|
|
18
|
+
|
|
6
19
|
/*
|
|
7
20
|
* TwinReference — 테넌트 스코프의 외부 소스 시스템 연결(실 또는 가상). 트윈 인스턴스가 인제스트되는 원천(Face2, ADR-0018).
|
|
8
21
|
* 전역 인메모리 레퍼런스 레지스트리를 대체하는 영속·도메인별·어댑터 기반 엔티티.
|
|
@@ -95,6 +108,40 @@ export class TwinReference {
|
|
|
95
108
|
@Field({ nullable: true, description: 'Last connection/sync error message, if any.' })
|
|
96
109
|
lastError?: string
|
|
97
110
|
|
|
111
|
+
/**
|
|
112
|
+
* **조치를 이 주소로 실제로 넘겨 봤나** (ADR-0046 ②).
|
|
113
|
+
*
|
|
114
|
+
* ── 왜 세 값인가 ───────────────────────────────────────────────────────────
|
|
115
|
+
* 설정이 맞는 것과 저쪽이 받는 것은 다르다. 주소도 비밀값도 다 적혀 있는데 저쪽 프로세스가 그 값을
|
|
116
|
+
* 잃은 일이 실제로 있었다(§`actuationReadiness`). 그것은 **한 번 넘겨 봐야** 알 수 있고, 그래서
|
|
117
|
+
* 「아직 안 해 봤다」와 「해 봤고 됐다」와 「해 봤고 안 됐다」가 다른 사실이다.
|
|
118
|
+
*
|
|
119
|
+
* unverified 아직 왕복이 없다 — 실패가 아니다. 새 연결과 설정이 바뀐 연결이 여기 선다
|
|
120
|
+
* verified `actuate` 가 받아들여졌다. 그 시각이 `actuationVerifiedAt`
|
|
121
|
+
* failed 거절됐다. 사유는 `actuationFailure` 에 **코드로**
|
|
122
|
+
*
|
|
123
|
+
* `unverified` 를 `failed` 로 접지 않는다 — 안 해 본 것을 실패로 그리면 사람이 고칠 것을 찾으러
|
|
124
|
+
* 가고, 고칠 것이 없다.
|
|
125
|
+
*/
|
|
126
|
+
@Column({ nullable: true })
|
|
127
|
+
@Field({ nullable: true, description: "Whether an actuation has actually reached this peer: 'unverified' | 'verified' | 'failed'. Null means the same as unverified; a connection that has never been tried is not a failed one." })
|
|
128
|
+
actuationState?: ActuationState
|
|
129
|
+
|
|
130
|
+
@Column({ nullable: true })
|
|
131
|
+
@Field({ nullable: true, description: 'When an actuation was last accepted by the peer (ISO 8601 string; portable across DB drivers). Null while unverified.' })
|
|
132
|
+
actuationVerifiedAt?: string
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 마지막 거절 — **코드와 값만 저장한다. 문장은 저장하지 않는다.**
|
|
136
|
+
*
|
|
137
|
+
* `lastError` 가 사람 문장을 저장해 두고 화면이 그것을 그대로 뿌렸다. 그러면 번역이 안 되고, 무엇이
|
|
138
|
+
* 몇 번 났는지 세지도 못하고, 커넥터가 문장을 고치는 날 옛 행과 새 행이 서로 다른 말을 한다.
|
|
139
|
+
* 코드는 `CommandAck.errorCode` 와 같은 낱말이다(ADR-0042 ③) — 표현층이 문장을 만든다.
|
|
140
|
+
*/
|
|
141
|
+
@Column({ type: 'simple-json', nullable: true })
|
|
142
|
+
@Field(type => ScalarObject, { nullable: true, description: 'Why the last actuation was refused: { code, at, params? }. Language-neutral codes only — the presentation layer builds the sentence. Null when the last attempt was accepted or none was made.' })
|
|
143
|
+
actuationFailure?: ActuationFailure
|
|
144
|
+
|
|
98
145
|
/**
|
|
99
146
|
* 마지막 동기에서 **못 옮긴 값의 수**. `null` = 세지 않았다(0 과 다르다).
|
|
100
147
|
*
|
|
@@ -17,6 +17,7 @@ import assert from 'node:assert/strict'
|
|
|
17
17
|
import { CMD, MES_COMMANDS, type CommandTypeSpec } from '@operato/ops-contract'
|
|
18
18
|
|
|
19
19
|
import {
|
|
20
|
+
SELF_APPROVAL,
|
|
20
21
|
approve,
|
|
21
22
|
dispatch,
|
|
22
23
|
linkApproval,
|
|
@@ -233,6 +234,17 @@ test('★ 두 어휘가 겹치지 않는다 — 겹치면 어느 쪽으로 읽
|
|
|
233
234
|
|
|
234
235
|
/* ── 낸 사람은 자기 것을 승인하지 못한다 ─────────────────────────────────── */
|
|
235
236
|
|
|
237
|
+
test('★ 거절 낱말은 워크리스트와 **같은 글자**여야 한다 — 상수를 보면 이름을 바꿔도 안 걸린다', () => {
|
|
238
|
+
/*
|
|
239
|
+
* 이 줄만 글자를 적는다. 아래 시험들은 `SELF_APPROVAL` 을 읽으므로 **이름을 바꾸면 양쪽이 함께
|
|
240
|
+
* 바뀌어 초록**이다(실제로 심어 보고 알았다). 두 문이 같은 말을 하는지는 **글자**로만 잴 수 있다.
|
|
241
|
+
*
|
|
242
|
+
* 반대쪽은 `worklist/server/controllers/activity-approval/own-request.ts` 다. 거기도 자기 쪽 글자를
|
|
243
|
+
* 못 박는다 — 한쪽이 움직이면 그쪽이 빨개지고, 이 주석이 왜 그런지 말해 준다 (ADR-0052 ③).
|
|
244
|
+
*/
|
|
245
|
+
assert.equal(SELF_APPROVAL, 'self-approval')
|
|
246
|
+
})
|
|
247
|
+
|
|
236
248
|
test('★ 낸 사람이 자기 조치를 승인하지 못한다 — 게이트가 시늉이 되는 자리', async () => {
|
|
237
249
|
/*
|
|
238
250
|
* 이 규칙이 왜 필요한지는 돌려 보고 알았다. 업무 목록은 결재선이 비어 있으면 스레드를 곧바로
|
|
@@ -242,7 +254,11 @@ test('★ 낸 사람이 자기 조치를 승인하지 못한다 — 게이트가
|
|
|
242
254
|
const store = makeStore()
|
|
243
255
|
await propose(store, DOMAIN, SPEC, seed())
|
|
244
256
|
|
|
245
|
-
|
|
257
|
+
/*
|
|
258
|
+
* **문장이 아니라 코드로 잡는다** (ADR-0052 ③). 워크리스트에도 같은 규칙이 서므로 두 문이 같은
|
|
259
|
+
* 낱말을 내야 하고, 문장을 못 박으면 문구를 고치는 날 규칙이 아니라 **문장이** 깨진다.
|
|
260
|
+
*/
|
|
261
|
+
await assert.rejects(approve(store, DOMAIN, 'cmd-1', { by: PROPOSER, at: AT }), (e: any) => e.code === SELF_APPROVAL)
|
|
246
262
|
assert.equal(store.rows.get('cmd-1')!.state, 'proposed')
|
|
247
263
|
})
|
|
248
264
|
|
|
@@ -253,7 +269,7 @@ test('★ 결재선을 지나온 판정에도 같은 규칙이 걸린다 — Mys
|
|
|
253
269
|
|
|
254
270
|
await assert.rejects(
|
|
255
271
|
settleByApproval(store, DOMAIN, 'cmd-1', { approvalRef: REF, approved: true, by: PROPOSER, at: AT }),
|
|
256
|
-
|
|
272
|
+
(e: any) => e.code === SELF_APPROVAL
|
|
257
273
|
)
|
|
258
274
|
assert.equal(store.rows.get('cmd-1')!.state, 'proposed')
|
|
259
275
|
})
|
|
@@ -101,16 +101,22 @@ test('★ 구현만 있고 선언이 없으면 — 등록이 말하고, 게이
|
|
|
101
101
|
assert.match(target.detail, /capabilities/, '게이트가 무엇을 넣어야 하는지 말하지 않았다')
|
|
102
102
|
})
|
|
103
103
|
|
|
104
|
-
test('★ 선언만 있고 구현이 없으면 —
|
|
104
|
+
test('★ 선언만 있고 구현이 없으면 — 등록도 게이트도 말한다', () => {
|
|
105
105
|
/*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
106
|
+
* 이 시험은 2026-09-16 까지 「등록은 조용하고, 게이트만 말한다」였다. 그때 주석이 그 침묵을 이렇게
|
|
107
|
+
* 적어 두었다 — 「등록 검사는 이 방향을 보지 않는다. 그래서 **게이트가 말하지 않으면 아무도 말하지
|
|
108
|
+
* 않는다**」. 그 문장이 곧 결함의 서술이었다: 조치를 넘기는 자리까지 가 본 사람만 그 사실을 듣고,
|
|
109
|
+
* 커넥터를 만든 사람은 선언했으니 된 줄 안다.
|
|
110
|
+
*
|
|
111
|
+
* ADR-0036 ③ 개정으로 등록이 양방향을 본다. 게이트의 문장은 그대로다 — 그 사람은 조치를 하겠다고
|
|
112
|
+
* 적어 두었으니 「관측만 냅니다」는 여전히 사실이 아니다.
|
|
109
113
|
*/
|
|
110
114
|
const before = warnings.length
|
|
111
115
|
const type = register({ capabilities: ['live', 'actuate'], openLiveFeed: () => () => {} })
|
|
112
116
|
|
|
113
|
-
|
|
117
|
+
const said = warnings.slice(before).join('\n')
|
|
118
|
+
assert.ok(said.includes('actuate'), '등록이 증명 없는 선언을 말하지 않았다')
|
|
119
|
+
assert.ok(said.includes('declares'), '등록이 어느 방향의 어긋남인지 말하지 않았다')
|
|
114
120
|
assert.deepEqual(capabilitiesOf(getAdapter(type)), ['live'], '능력 판정이 구현 없는 선언을 걸러 내지 않았다')
|
|
115
121
|
|
|
116
122
|
const target = askGate(type, 'plant-a') as any
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ★ **한 번 넘겨 봤나** — 설정이 맞는 것과 저쪽이 받는 것은 다르다 (ADR-0046 ②).
|
|
3
|
+
*
|
|
4
|
+
* 주소도 비밀값도 다 적혀 있는데 저쪽 프로세스가 그 값을 잃은 일이 있었다(§`actuationReadiness`).
|
|
5
|
+
* 그것은 넘겨 봐야만 알고, 그래서 「아직 안 해 봤다」와 「해 봤고 됐다」와 「해 봤고 안 됐다」가
|
|
6
|
+
* 서로 다른 사실이다. 이 시험은 그 셋이 안 뭉개지는지 본다.
|
|
7
|
+
*
|
|
8
|
+
* ── 저장되는 것은 코드다 ────────────────────────────────────────────────────
|
|
9
|
+
* `lastError` 가 사람 문장을 저장해 두고 화면이 그것을 그대로 뿌렸다. 번역도 집계도 안 되고,
|
|
10
|
+
* 커넥터가 문장을 고치는 날 옛 행과 새 행이 다른 말을 한다. 그래서 사유는 코드로만 앉는다.
|
|
11
|
+
*/
|
|
12
|
+
import { test } from 'node:test'
|
|
13
|
+
import assert from 'node:assert/strict'
|
|
14
|
+
import { readFileSync } from 'node:fs'
|
|
15
|
+
|
|
16
|
+
import { actuationOutcomePatch } from '../server/service/reference/actuation-verification.js'
|
|
17
|
+
|
|
18
|
+
const AT = '2026-09-16T04:05:06.000Z'
|
|
19
|
+
|
|
20
|
+
test('받아들여지면 verified 로 서고 그 시각이 남는다', () => {
|
|
21
|
+
const patch = actuationOutcomePatch({ ok: true, ref: 'JO-1' }, AT)
|
|
22
|
+
|
|
23
|
+
assert.equal(patch.actuationState, 'verified')
|
|
24
|
+
assert.equal(patch.actuationVerifiedAt, AT)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('★ 받아들여지면 지난 거절을 지운다 — 「됐는데 실패 사유가 있다」를 그리지 않게', () => {
|
|
28
|
+
assert.equal(actuationOutcomePatch({ ok: true }, AT).actuationFailure, null)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('★ 거절되면 사유가 코드로 앉는다 — 문장이 아니라', () => {
|
|
32
|
+
const patch = actuationOutcomePatch(
|
|
33
|
+
{ ok: false, error: '저쪽이 503 을 답했습니다', errorCode: 'peer-unavailable', errorParams: { status: 503 } },
|
|
34
|
+
AT
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
assert.equal(patch.actuationState, 'failed')
|
|
38
|
+
assert.deepEqual(patch.actuationFailure, { code: 'peer-unavailable', at: AT, params: { status: 503 } })
|
|
39
|
+
|
|
40
|
+
/* 사람 문장은 어디에도 안 실린다 — 그것이 이 칸을 만든 이유다. */
|
|
41
|
+
assert.ok(!JSON.stringify(patch).includes('503 을 답했습니다'))
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('★ 코드가 없으면 사유를 비운다 — 자리를 채우려고 문장을 옮기지 않는다', () => {
|
|
45
|
+
const patch = actuationOutcomePatch({ ok: false, error: '무언가 잘못됐습니다' }, AT)
|
|
46
|
+
|
|
47
|
+
assert.equal(patch.actuationState, 'failed', '코드가 없어도 실패는 실패다')
|
|
48
|
+
assert.equal(patch.actuationFailure, null, '모르는 것을 적었다')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('★ 거절은 성공 시각을 지우지 않는다 — 「한 번이라도 닿은 적 있나」가 남아야 한다', () => {
|
|
52
|
+
const patch = actuationOutcomePatch({ ok: false, errorCode: 'peer-unavailable' }, AT)
|
|
53
|
+
|
|
54
|
+
assert.ok(!('actuationVerifiedAt' in patch), '실패가 지난 성공의 시각을 덮었다')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('★ params 가 없으면 그 칸을 만들지 않는다 — 빈 객체와 「없다」는 다르다', () => {
|
|
58
|
+
const patch = actuationOutcomePatch({ ok: false, errorCode: 'not-declared' }, AT)
|
|
59
|
+
|
|
60
|
+
assert.deepEqual(patch.actuationFailure, { code: 'not-declared', at: AT })
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
/*
|
|
64
|
+
* 위 시험들은 **판단만** 본다. 판단이 옳아도 아무도 안 부르면 레퍼런스에는 아무것도 안 남는다 —
|
|
65
|
+
* 순수 시험은 부르는 사람을 못 본다.
|
|
66
|
+
*
|
|
67
|
+
* 값으로 재려면 DB 가 붙어야 해서, 여기서는 원문을 읽는다. 다만 **어느 함수 안에서 부르는지까지**
|
|
68
|
+
* 본다. 파일 전체에서 이름을 세면 **선언이 자기를 센다** — 실제로 호출을 지우고 돌려 봤는데 이
|
|
69
|
+
* 시험이 초록이었다. 세는 것은 주장이지 그 이웃이 아니다.
|
|
70
|
+
*/
|
|
71
|
+
const strip = (x: string) => x.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '')
|
|
72
|
+
const src = strip(readFileSync(new URL('../server/service/reference/actuation-routing.ts', import.meta.url), 'utf8'))
|
|
73
|
+
const bodyOf = (name: string) => {
|
|
74
|
+
const at = src.indexOf(`function ${name}(`)
|
|
75
|
+
assert.ok(at > 0, `${name} 이 없다`)
|
|
76
|
+
const next = src.slice(at + 1).search(/\nasync function |\nexport (async )?function /)
|
|
77
|
+
return next < 0 ? src.slice(at) : src.slice(at, at + 1 + next)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
test('★ routeActuation 이 실제로 그것을 부른다 — 판단만 맞고 아무도 안 부르면 남는 것이 없다', () => {
|
|
81
|
+
const route = bodyOf('routeActuation')
|
|
82
|
+
|
|
83
|
+
assert.match(route, /recordActuationOutcome\(/, '조치 경로에서 그 기록이 불리지 않는다')
|
|
84
|
+
/* 기록이 본업을 막지 않는다 — 실패해도 조치 결과는 그대로 돌아간다. */
|
|
85
|
+
assert.match(route, /\.catch\(/, '기록이 실패하면 조치까지 함께 넘어진다')
|
|
86
|
+
assert.match(route, /return result/, '조치 결과를 돌려주지 않는다')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('★ 기록하는 자리가 판단을 쓰고 그 값을 저장한다 — 자기 판단을 다시 짓지 않는다', () => {
|
|
90
|
+
const record = bodyOf('recordActuationOutcome')
|
|
91
|
+
|
|
92
|
+
assert.match(record, /actuationOutcomePatch\(/, '판단을 부르지 않는다 — 두 벌이 된다')
|
|
93
|
+
assert.match(record, /\.update\(/, '부른 결과를 저장하지 않는다')
|
|
94
|
+
})
|
|
@@ -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
|
})
|