@things-factory/headless-twin 10.1.27 → 10.1.29
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/index.js +6 -0
- package/dist-server/index.js.map +1 -1
- package/dist-server/service/reference/actuation-verification.d.ts +22 -0
- package/dist-server/service/reference/actuation-verification.js +37 -0
- package/dist-server/service/reference/actuation-verification.js.map +1 -1
- package/dist-server/service/reference/reference-resolver.js +32 -2
- package/dist-server/service/reference/reference-resolver.js.map +1 -1
- package/dist-server/service/twin-model/external-resolver.d.ts +13 -3
- package/dist-server/service/twin-model/external-resolver.js +2 -2
- package/dist-server/service/twin-model/external-resolver.js.map +1 -1
- package/dist-server/service/twin-model/twin-lineage-query.js +14 -0
- package/dist-server/service/twin-model/twin-lineage-query.js.map +1 -1
- package/dist-server/service/twin-model/twin-model-item-query.js +41 -12
- package/dist-server/service/twin-model/twin-model-item-query.js.map +1 -1
- package/dist-server/service/twin-model/twin-model-query.js +14 -0
- package/dist-server/service/twin-model/twin-model-query.js.map +1 -1
- package/dist-server/service/twin-space/area-resolver.d.ts +6 -0
- package/dist-server/service/twin-space/area-resolver.js +68 -0
- package/dist-server/service/twin-space/area-resolver.js.map +1 -0
- package/package.json +8 -8
- package/server/index.ts +6 -0
- package/server/service/reference/actuation-verification.ts +42 -0
- package/server/service/reference/reference-resolver.ts +33 -2
- package/server/service/twin-model/external-resolver.ts +15 -3
- package/server/service/twin-model/twin-lineage-query.ts +13 -0
- package/server/service/twin-model/twin-model-item-query.ts +62 -12
- package/server/service/twin-model/twin-model-query.ts +14 -0
- package/server/service/twin-space/area-resolver.ts +75 -0
- package/test/actuation-verification-state.test.ts +46 -1
- package/test/area-resolver-db.test.ts +110 -0
- package/test/axis-declaration-passthrough-db.test.ts +152 -0
- package/test/external-resolver.test.ts +11 -5
- package/test/lineage-survives-restart.test.ts +15 -0
- package/test/twin-model-item-db.test.ts +18 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -70,11 +70,22 @@ export interface ExternalIncoming {
|
|
|
70
70
|
* 둘 다 선택이다. 이름만 대 줄 수 있는 제품과 들어오는 관계만 낼 수 있는 제품이 다 있다 — 하나만
|
|
71
71
|
* 꽂아도 그만큼은 답해진다.
|
|
72
72
|
*/
|
|
73
|
+
/** What the relation being resolved belongs to. Required — a resolver that answers without it answers for the wrong twin. */
|
|
74
|
+
export interface ExternalResolveContext {
|
|
75
|
+
instanceId: string
|
|
76
|
+
}
|
|
77
|
+
|
|
73
78
|
export interface ExternalResolver {
|
|
74
79
|
/** 계약의 `target.entity` 와 **같은 낱말**. 다르면 아무 관계에도 안 붙는다. */
|
|
75
80
|
entity: string
|
|
76
|
-
/**
|
|
77
|
-
|
|
81
|
+
/**
|
|
82
|
+
* 이 id 들이 무엇인가 — **어느 트윈의 관계인지와 함께** 묻는다.
|
|
83
|
+
*
|
|
84
|
+
* 인스턴스를 넘기는 이유(2026-09-18): 밖의 id 는 도메인에서 유일하지 않다. 구역 id(`areaId`)는
|
|
85
|
+
* 공간 안에서만 유일하므로, 도메인만으로 찾으면 다른 공간의 같은 id 가 답이 되어 **남의 공간 이름**이
|
|
86
|
+
* 이 자리의 부모로 보인다. 부르는 쪽은 인스턴스를 알고 있다.
|
|
87
|
+
*/
|
|
88
|
+
resolve?(domainId: string, ids: readonly string[], context: ExternalResolveContext): Promise<ExternalRef[]>
|
|
78
89
|
/** 이 대상을 가리키는 것들. */
|
|
79
90
|
incoming?(domainId: string, instanceId: string, axis: string, itemId: string): Promise<ExternalIncoming[]>
|
|
80
91
|
}
|
|
@@ -120,12 +131,13 @@ export async function resolveExternal(
|
|
|
120
131
|
entity: string,
|
|
121
132
|
domainId: string,
|
|
122
133
|
ids: readonly string[],
|
|
134
|
+
context: ExternalResolveContext,
|
|
123
135
|
warn?: (msg: string) => void
|
|
124
136
|
): Promise<{ refs?: ExternalRef[]; unresolved?: 'no-resolver' | 'failed' }> {
|
|
125
137
|
const r = resolvers.get(entity)
|
|
126
138
|
if (!r?.resolve) return { unresolved: 'no-resolver' }
|
|
127
139
|
try {
|
|
128
|
-
return { refs: await r.resolve(domainId, ids) }
|
|
140
|
+
return { refs: await r.resolve(domainId, ids, context) }
|
|
129
141
|
} catch (e: any) {
|
|
130
142
|
warn?.(`[external-resolver] '${entity}' 해소가 실패했다 — ${String(e?.message ?? e)}`)
|
|
131
143
|
return { unresolved: 'failed' }
|
|
@@ -209,6 +209,19 @@ export class TwinLineageQuery {
|
|
|
209
209
|
}
|
|
210
210
|
} else if (!recipe) {
|
|
211
211
|
conformance = { comparable: false, reason: 'no-recipe-for-material' }
|
|
212
|
+
} else if (!(madeBy.get(epc) ?? []).length) {
|
|
213
|
+
/*
|
|
214
|
+
* ── **안 본 것을 「다르다」고 하지 않는다** (2026-09-18 · :3000 화면에서 확인) ─────────
|
|
215
|
+
*
|
|
216
|
+
* 이 개체를 만든 변환이 저널에 하나도 없으면 투입을 하나도 관측하지 못한 것이다. 그런데
|
|
217
|
+
* 아래 셈은 그대로 돌아 관측을 전부 0 으로 적었고, 레시피가 요구한 자재 23 줄이 전부
|
|
218
|
+
* 「안 맞는다」가 됐다 — 화면은 「레시피와 다릅니다」를 확신에 찬 얼굴로 그렸다. 실제로는
|
|
219
|
+
* 원본이 변환을 아직 안 보냈을 뿐이다(`mes-miratek` 의 카메라 로트가 그 경우다).
|
|
220
|
+
*
|
|
221
|
+
* 이 파일의 규율이 이미 그것을 말한다 — 짐작으로 이으면 「설계와 다르다」는 거짓 진단이
|
|
222
|
+
* 되고, 그건 아무 말도 안 하는 것보다 나쁘다. 관측이 **없는** 것도 같은 부류다.
|
|
223
|
+
*/
|
|
224
|
+
conformance = { comparable: false, reason: 'made-not-observed', recipe: recipe.key }
|
|
212
225
|
} else {
|
|
213
226
|
/*
|
|
214
227
|
* 관측: **바인딩된 자재까지 내려가 센다** — 중간 산물(WIP)은 통과한다.
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ReadBasis } from '@operato/ops-contract'
|
|
2
|
+
|
|
1
3
|
import { twinWarn } from '../../engine/log.js'
|
|
2
4
|
import { Arg, Ctx, Int, Query, Resolver } from 'type-graphql'
|
|
3
5
|
|
|
@@ -20,6 +22,26 @@ import { at, idOf, nameIndex, namesFor } from './name-index.js'
|
|
|
20
22
|
/* 커널 런타임 로드 — 축·관계·속성 선언의 SSOT. 이 파일은 도메인 모양을 알지 않는다. */
|
|
21
23
|
const { TWIN_AXES, TWIN_RELATIONS, axisInfo, axisSource, documentPath, isCacheableTense, relationsFrom, relationsTo, propertiesOf, splitRelationIds, tenseOf } =
|
|
22
24
|
require('@operato/ops-contract')
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 목록의 답 — **근거는 계약이 닫아 둔 다섯 중 하나다**(`READ_BASIS`).
|
|
28
|
+
*
|
|
29
|
+
* 2026-09-18 판정(ADR-0075 결정 3 을 코드에 대어 본 결과): 이 문이 `'rows-scanned'` 를 내고 있었다.
|
|
30
|
+
* 그것은 출처가 아니라 **우리가 어떻게 셌나**이고, 목록 소비자가 근거로 갈래를 나눌 때 계약에 없는
|
|
31
|
+
* 값을 만나게 된다. 출처는 `rows` 이고, 훑어 걸렀다는 사실은 `scanned` · `truncated` 가 말한다.
|
|
32
|
+
* 여기에 칸을 묶어 두면 계약 밖의 값이 다시 생길 때 타입 검사가 잡는다.
|
|
33
|
+
*/
|
|
34
|
+
type AxisListAnswer = {
|
|
35
|
+
axis: string
|
|
36
|
+
label: string
|
|
37
|
+
basis: ReadBasis
|
|
38
|
+
total: number | null
|
|
39
|
+
offset: number
|
|
40
|
+
/** 훑어 센 경우에만 — 몇 줄을 읽었나, 상한에서 잘렸나. */
|
|
41
|
+
scanned?: number
|
|
42
|
+
truncated?: boolean
|
|
43
|
+
[key: string]: unknown
|
|
44
|
+
}
|
|
23
45
|
import { externalIncoming, resolveExternal } from './external-resolver.js'
|
|
24
46
|
|
|
25
47
|
/**
|
|
@@ -131,7 +153,7 @@ export class TwinModelItemQuery {
|
|
|
131
153
|
const repo = getRepository(row.entity)
|
|
132
154
|
const where = { domain: { id: domainId } as any, instance: { id: inst.id } as any }
|
|
133
155
|
const [rows, total] = await repo.findAndCount({ where, order: row.order, skip, take })
|
|
134
|
-
|
|
156
|
+
const answer: AxisListAnswer = {
|
|
135
157
|
axis,
|
|
136
158
|
label: info.label,
|
|
137
159
|
basis: 'rows',
|
|
@@ -141,23 +163,27 @@ export class TwinModelItemQuery {
|
|
|
141
163
|
resourceClassLabels: this.classLabels(inst.model ?? {}),
|
|
142
164
|
items: rows.map((r: any) => this.rowItem(axis, r))
|
|
143
165
|
}
|
|
166
|
+
return answer
|
|
144
167
|
}
|
|
145
168
|
|
|
146
169
|
if (row && q) {
|
|
147
170
|
/*
|
|
148
|
-
* 검색은 아직 DB 로 내리지 않았다. **그 사실을 밝힌다**
|
|
149
|
-
*
|
|
150
|
-
*
|
|
171
|
+
* 검색은 아직 DB 로 내리지 않았다. **그 사실을 밝힌다** — 흉내로 덮으면 규모가 오는 날까지
|
|
172
|
+
* 아무도 이 한계를 모른다. 훑는 건수를 상한으로 막고, 상한에 닿았으면 잘렸다고 말한다
|
|
173
|
+
* (알리지 않고 자르지 않는다).
|
|
174
|
+
*
|
|
175
|
+
* 말하는 자리는 `scanned` · `truncated` 다. 근거(`basis`)는 그대로 `rows` 이고 — 값이 어디서
|
|
176
|
+
* 왔는지는 훑었든 DB 가 골랐든 같은 행이다 — 계약이 닫아 둔 다섯 밖으로 나가지 않는다.
|
|
151
177
|
*/
|
|
152
178
|
const SCAN_MAX = 5000
|
|
153
179
|
const repo = getRepository(row.entity)
|
|
154
180
|
const where = { domain: { id: domainId } as any, instance: { id: inst.id } as any }
|
|
155
181
|
const rows = await repo.find({ where, order: row.order, take: SCAN_MAX })
|
|
156
182
|
const hit = rows.filter((r: any) => matches(this.rowItem(axis, r), q))
|
|
157
|
-
|
|
183
|
+
const answer: AxisListAnswer = {
|
|
158
184
|
axis,
|
|
159
185
|
label: info.label,
|
|
160
|
-
basis: 'rows
|
|
186
|
+
basis: 'rows',
|
|
161
187
|
total: hit.length,
|
|
162
188
|
offset: skip,
|
|
163
189
|
resourceClassLabels: this.classLabels(inst.model ?? {}),
|
|
@@ -168,6 +194,7 @@ export class TwinModelItemQuery {
|
|
|
168
194
|
return { items, names: namesFor(items, nameIndex(inst.model ?? {})) }
|
|
169
195
|
})()
|
|
170
196
|
}
|
|
197
|
+
return answer
|
|
171
198
|
}
|
|
172
199
|
|
|
173
200
|
/*
|
|
@@ -387,11 +414,28 @@ export class TwinModelItemQuery {
|
|
|
387
414
|
/* 같은 규칙 — 축 이름이 아니라 선언된 자리에서 읽는다(§axisValueOf). */
|
|
388
415
|
const bucket = snap ? axisValueOf(snap, info) : null
|
|
389
416
|
const found = Array.isArray(bucket) ? bucket.find((o: any) => idOf(o, info) === itemId) : undefined
|
|
390
|
-
|
|
417
|
+
/*
|
|
418
|
+
* **상태를 읽었는데 그 개체가 없으면, 그런 개체가 없는 것이다** (2026-09-18).
|
|
419
|
+
*
|
|
420
|
+
* 이 갈래가 `!snap || !found` 하나였다. 그래서 없는 식별자로 물어도 「트윈이 실제로 본 것입니다」
|
|
421
|
+
* 라고 적힌 패널이 돌아왔다 — 화면은 그것을 그려서, 있지도 않은 개체가 값이 비어 있는 개체처럼
|
|
422
|
+
* 보였다. 문서 축은 같은 물음에 `null` 을 답한다(없는 것은 없다고 답한다). 상태 축도 같다.
|
|
423
|
+
*
|
|
424
|
+
* 못 읽은 것은 다른 사실이라 아래에서 따로 답한다 — 「없다」로 접지 않는다.
|
|
425
|
+
*/
|
|
426
|
+
if (snap && !found) return null
|
|
427
|
+
if (!snap) {
|
|
391
428
|
return {
|
|
392
429
|
instanceId, axis, label: info.label, itemId,
|
|
393
|
-
identity: { standardClass: info.standardClass ?? null, typeRole: info.typeRole ?? null, kind: info.kind, gs1Scheme: info.gs1Scheme ?? null },
|
|
394
|
-
declared: null,
|
|
430
|
+
identity: { standardClass: info.standardClass ?? null, typeRole: info.typeRole ?? null, kind: info.kind, gs1Scheme: info.gs1Scheme ?? null, journalAxis: info.journalAxis ?? null, describedBy: info.describedBy ?? null },
|
|
431
|
+
declared: null,
|
|
432
|
+
observed: null,
|
|
433
|
+
observedOnly: true,
|
|
434
|
+
/*
|
|
435
|
+
* **왜 관측 열이 비었나** — 커널이 멈춰 있고 저널로도 되세우지 못했다. 코드는 목록·비교 열이
|
|
436
|
+
* 쓰는 것과 같은 것을 쓴다(`not-running`) — 같은 사실에 두 낱말을 만들지 않는다.
|
|
437
|
+
*/
|
|
438
|
+
observedAbsence: 'not-running' as const,
|
|
395
439
|
basis: liveItem ? 'state' : 'journal', observedBasis: liveItem ? 'state' : 'journal', observing: liveItem,
|
|
396
440
|
links: { out: [], incoming: [] }, properties: propertiesOf(axis).map((pr: any) => ({ key: pr.key, label: pr.label, kind: pr.kind, uom: pr.uom ?? null, observedBy: pr.observedBy ?? null })),
|
|
397
441
|
/* 어떤 속성이 **효과가 있나** — 등록부가 말한다(§property-effects). 없는 것은 표시만 된다. */
|
|
@@ -402,7 +446,7 @@ export class TwinModelItemQuery {
|
|
|
402
446
|
const names = namesFor([{ id: itemId, fields: found }], nameIndex(inst.model ?? {}))
|
|
403
447
|
return {
|
|
404
448
|
instanceId, axis, label: info.label, itemId,
|
|
405
|
-
identity: { standardClass: info.standardClass ?? null, typeRole: info.typeRole ?? null, kind: info.kind, gs1Scheme: info.gs1Scheme ?? null },
|
|
449
|
+
identity: { standardClass: info.standardClass ?? null, typeRole: info.typeRole ?? null, kind: info.kind, gs1Scheme: info.gs1Scheme ?? null, journalAxis: info.journalAxis ?? null, describedBy: info.describedBy ?? null },
|
|
406
450
|
/*
|
|
407
451
|
* **선언이 없는 축이다.** 두 열 중 왼쪽이 비는 것이 정상이고, 그 사실을 값으로 말한다
|
|
408
452
|
* (`observedOnly`) — 빈 열을 "원본이 침묵했다" 로 읽으면 안 된다. 원본은 애초에 이 축을
|
|
@@ -484,7 +528,13 @@ export class TwinModelItemQuery {
|
|
|
484
528
|
standardClass: info.standardClass ?? null,
|
|
485
529
|
typeRole: info.typeRole ?? null,
|
|
486
530
|
kind: info.kind,
|
|
487
|
-
gs1Scheme: info.gs1Scheme ?? null
|
|
531
|
+
gs1Scheme: info.gs1Scheme ?? null,
|
|
532
|
+
/*
|
|
533
|
+
* 선언 둘을 그대로 나른다 (ADR-0074) — 이 개체의 이력을 저널의 어느 축으로 묻나,
|
|
534
|
+
* 그리고 식별자 말고 무엇이 이 개체를 말하나. 화면이 그 목록을 갖지 않게 한다.
|
|
535
|
+
*/
|
|
536
|
+
journalAxis: info.journalAxis ?? null,
|
|
537
|
+
describedBy: info.describedBy ?? null
|
|
488
538
|
},
|
|
489
539
|
/*
|
|
490
540
|
* **그릇인가** — 문서에 객체 하나로 있는 축(`productionSpec`)인지.
|
|
@@ -831,7 +881,7 @@ export class TwinModelItemQuery {
|
|
|
831
881
|
*/
|
|
832
882
|
for (const l of out) {
|
|
833
883
|
if (l.target?.kind !== 'external' || !l.ids.length) continue
|
|
834
|
-
const got = await resolveExternal(l.target.entity, domainId, l.ids, m => twinWarn(m))
|
|
884
|
+
const got = await resolveExternal(l.target.entity, domainId, l.ids, { instanceId: inst.instanceId }, m => twinWarn(m))
|
|
835
885
|
if (got.refs) (l as any).refs = got.refs
|
|
836
886
|
else (l as any).unresolved = got.unresolved
|
|
837
887
|
}
|
|
@@ -306,6 +306,14 @@ export class TwinModelQuery {
|
|
|
306
306
|
}
|
|
307
307
|
})())
|
|
308
308
|
|
|
309
|
+
/*
|
|
310
|
+
* 개념 카드가 선언을 그대로 나른다 (2026-09-18, ADR-0074).
|
|
311
|
+
*
|
|
312
|
+
* `journalAxis` 는 「이 축의 개체 이력을 저널의 어느 축으로 묻나」이고, 없으면 저널이 그 축으로
|
|
313
|
+
* 기록하지 않는다는 뜻이다. `describedBy` 는 식별자 말고 무엇이 그 개체를 말하나이다. 둘 다 앱의
|
|
314
|
+
* 화면 상수에 있었고, 축 이름이 바뀌었을 때 따라오지 못해 세 축의 이력이 조용히 비었다.
|
|
315
|
+
* 여기서 내보내면 화면은 목록을 갖지 않는다 — 축이 늘어도 화면은 그대로다.
|
|
316
|
+
*/
|
|
309
317
|
const concepts = await Promise.all(
|
|
310
318
|
TWIN_AXES.map(async (a: any) => {
|
|
311
319
|
/*
|
|
@@ -325,6 +333,8 @@ export class TwinModelQuery {
|
|
|
325
333
|
container: false,
|
|
326
334
|
standardClass: a.standardClass,
|
|
327
335
|
typeRole: a.typeRole ?? null,
|
|
336
|
+
journalAxis: a.journalAxis ?? null,
|
|
337
|
+
describedBy: a.describedBy ?? null,
|
|
328
338
|
count: null,
|
|
329
339
|
notApplicable: true,
|
|
330
340
|
emptyBecause: 'not-applicable',
|
|
@@ -419,6 +429,8 @@ export class TwinModelQuery {
|
|
|
419
429
|
container: false,
|
|
420
430
|
standardClass: a.standardClass,
|
|
421
431
|
typeRole: a.typeRole ?? null,
|
|
432
|
+
journalAxis: a.journalAxis ?? null,
|
|
433
|
+
describedBy: a.describedBy ?? null,
|
|
422
434
|
count: Array.isArray(bucket) ? bucket.length : null,
|
|
423
435
|
/*
|
|
424
436
|
* **수 하나가 뜻을 바꾸는 자리** (2026-08-23).
|
|
@@ -461,6 +473,8 @@ export class TwinModelQuery {
|
|
|
461
473
|
container: !Array.isArray(raw) && raw != null,
|
|
462
474
|
standardClass: a.standardClass,
|
|
463
475
|
typeRole: a.typeRole ?? null,
|
|
476
|
+
journalAxis: a.journalAxis ?? null,
|
|
477
|
+
describedBy: a.describedBy ?? null,
|
|
464
478
|
count: entity ? inRows : inDocument,
|
|
465
479
|
/*
|
|
466
480
|
* 비었으면 왜 비었는지 — 선언 쪽은 세 갈래다.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* What an area id is — the host's answer for `external: 'space.area'`.
|
|
3
|
+
*
|
|
4
|
+
* The contract declares a location's parent as another location or, when no location has that id, an area of the
|
|
5
|
+
* space (`axis-or-external`), and says the host resolves the external half. Nothing registered a resolver, so on
|
|
6
|
+
* 2026-09-18 every location in the running twin showed its parent as "no component can resolve this relation" —
|
|
7
|
+
* the areas were in this package's own table all along.
|
|
8
|
+
*
|
|
9
|
+
* The lookup is (domain, space, areaId), the table's own unique key: an `areaId` is unique inside a space, so a
|
|
10
|
+
* tenant can hold the same id in two spaces. The space comes from the twin the relation belongs to, which is why
|
|
11
|
+
* `resolve` is given the instance (external-resolver.ts `ExternalResolveContext`). Looking up by tenant alone
|
|
12
|
+
* would answer another space's name as this location's parent.
|
|
13
|
+
*
|
|
14
|
+
* No `href`: the screen that opens an area belongs to the product app, and putting its route here would fix a
|
|
15
|
+
* product screen's address in this package. With no href the screen shows the name and makes no link — that is
|
|
16
|
+
* what the field is for (no dead links). A route is registered the day a screen needs it.
|
|
17
|
+
*/
|
|
18
|
+
import { getRepository } from '@things-factory/shell'
|
|
19
|
+
import { In } from 'typeorm'
|
|
20
|
+
|
|
21
|
+
import { registerExternalResolver, type ExternalRef, type ExternalResolveContext } from '../twin-model/external-resolver.js'
|
|
22
|
+
import { TwinInstance } from '../twin-instance/twin-instance.js'
|
|
23
|
+
import { TwinSpace } from './twin-space.js'
|
|
24
|
+
import { TwinArea } from './twin-area.js'
|
|
25
|
+
|
|
26
|
+
/** The contract's word for this target. Must match `target.entity` or it attaches to no relation. */
|
|
27
|
+
export const SPACE_AREA_ENTITY = 'space.area'
|
|
28
|
+
|
|
29
|
+
export async function resolveAreas(
|
|
30
|
+
domainId: string,
|
|
31
|
+
ids: readonly string[],
|
|
32
|
+
context: ExternalResolveContext,
|
|
33
|
+
warn?: (msg: string) => void
|
|
34
|
+
): Promise<ExternalRef[]> {
|
|
35
|
+
const wanted = [...new Set(ids.map(String).filter(Boolean))]
|
|
36
|
+
if (!wanted.length) return []
|
|
37
|
+
|
|
38
|
+
/* Which space is this twin in? A twin with no space has no areas to point at — every id is then missing. */
|
|
39
|
+
const inst = await getRepository(TwinInstance).findOne({
|
|
40
|
+
where: { domain: { id: domainId } as any, instanceId: context.instanceId }
|
|
41
|
+
})
|
|
42
|
+
const spaceId = inst?.spaceId
|
|
43
|
+
if (!spaceId) {
|
|
44
|
+
warn?.(`[area-resolver] twin "${context.instanceId}" is in no space — its parent ids cannot be areas`)
|
|
45
|
+
return wanted.map(id => ({ id, missing: true }))
|
|
46
|
+
}
|
|
47
|
+
const space = await getRepository(TwinSpace).findOne({ where: { domain: { id: domainId } as any, spaceId } })
|
|
48
|
+
if (!space) {
|
|
49
|
+
warn?.(`[area-resolver] space "${spaceId}" of twin "${context.instanceId}" is not in this tenant`)
|
|
50
|
+
return wanted.map(id => ({ id, missing: true }))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const rows = await getRepository(TwinArea).find({
|
|
54
|
+
where: { domain: { id: domainId } as any, space: { id: space.id } as any, areaId: In(wanted) }
|
|
55
|
+
})
|
|
56
|
+
const byId = new Map(rows.map(r => [r.areaId, r]))
|
|
57
|
+
|
|
58
|
+
/* An id that is in no area of this space is said to be missing — it is not dropped from the answer. */
|
|
59
|
+
return wanted.map(id => {
|
|
60
|
+
const area = byId.get(id)
|
|
61
|
+
if (!area) return { id, missing: true }
|
|
62
|
+
return area.name ? { id, name: area.name } : { id }
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Registered at boot, once — the same shape as the reference link resolver. */
|
|
67
|
+
export function installAreaResolver(warn?: (msg: string) => void): void {
|
|
68
|
+
registerExternalResolver(
|
|
69
|
+
{
|
|
70
|
+
entity: SPACE_AREA_ENTITY,
|
|
71
|
+
resolve: (domainId, ids, context) => resolveAreas(domainId, ids, context, warn)
|
|
72
|
+
},
|
|
73
|
+
warn
|
|
74
|
+
)
|
|
75
|
+
}
|
|
@@ -13,7 +13,7 @@ import { test } from 'node:test'
|
|
|
13
13
|
import assert from 'node:assert/strict'
|
|
14
14
|
import { readFileSync } from 'node:fs'
|
|
15
15
|
|
|
16
|
-
import { actuationOutcomePatch } from '../server/service/reference/actuation-verification.js'
|
|
16
|
+
import { actuationAddressed, actuationOutcomePatch, actuationResetPatch } from '../server/service/reference/actuation-verification.js'
|
|
17
17
|
|
|
18
18
|
const AT = '2026-09-16T04:05:06.000Z'
|
|
19
19
|
|
|
@@ -92,3 +92,48 @@ test('★ 기록하는 자리가 판단을 쓰고 그 값을 저장한다 —
|
|
|
92
92
|
assert.match(record, /actuationOutcomePatch\(/, '판단을 부르지 않는다 — 두 벌이 된다')
|
|
93
93
|
assert.match(record, /\.update\(/, '부른 결과를 저장하지 않는다')
|
|
94
94
|
})
|
|
95
|
+
|
|
96
|
+
test('★ 주소를 바꾸는 저장은 검증을 되돌린다 — 지난 왕복은 지난 주소의 이야기다', () => {
|
|
97
|
+
/*
|
|
98
|
+
* 2026-09-18 실측(ADR-0046 ② 를 코드에 대어 본 결과): 칸 셋도 왕복의 결과를 적는 자리도 섰는데,
|
|
99
|
+
* `verified` 인 연결의 주소를 바꿔도 그대로 `verified` 였다. 화면은 다른 공장을 가리키는 연결을
|
|
100
|
+
* 「확인됨」으로 그린다.
|
|
101
|
+
*/
|
|
102
|
+
const before = { intakeEndpoint: 'https://a.example/intake', intakeSiteId: 'line-a', intakeSecret: 's1' }
|
|
103
|
+
const moved = actuationResetPatch(before, { ...before, intakeEndpoint: 'https://b.example/intake' })
|
|
104
|
+
assert.deepEqual(moved, { actuationState: 'unverified', actuationVerifiedAt: null, actuationFailure: null })
|
|
105
|
+
assert.ok(actuationResetPatch(before, { ...before, intakeSiteId: 'line-b' }), '받는 사이트가 바뀌었다')
|
|
106
|
+
assert.ok(actuationResetPatch(before, { ...before, intakeSecret: 's2' }), '저쪽이 우리를 못 알아보면 그 왕복은 다시 성립하지 않는다')
|
|
107
|
+
assert.ok(actuationResetPatch(undefined, before), '처음 적는 주소도 아직 왕복이 없다')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('주소가 그대로면 검증도 그대로 — 이름만 고쳤다고 되돌리지 않는다', () => {
|
|
111
|
+
const cfg = { intakeEndpoint: 'https://a.example/intake', intakeSiteId: 'line-a', intakeSecret: 's1' }
|
|
112
|
+
assert.equal(actuationResetPatch(cfg, { ...cfg, label: '군산 1라인' }), null)
|
|
113
|
+
/* 끝의 빗금은 같은 주소다 — 그것으로 되돌리면 저장할 때마다 초록이 사라진다. */
|
|
114
|
+
assert.equal(actuationResetPatch(cfg, { ...cfg, intakeEndpoint: 'https://a.example/intake/' }), null)
|
|
115
|
+
/* 가려진 비밀값은 저장 쪽이 병합해 옛 값 그대로 온다(그래서 여기까지 같은 값이 온다). */
|
|
116
|
+
assert.equal(actuationResetPatch(cfg, { ...cfg }), null)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('★ 보낼 주소가 적힌 연결에만 검증 상태를 말한다 — 읽기만 하는 연결에 할 일을 만들지 않는다', () => {
|
|
120
|
+
assert.equal(actuationAddressed({ intakeEndpoint: 'https://a.example/intake', intakeSiteId: 'line-a' }), true)
|
|
121
|
+
assert.equal(actuationAddressed({ intakeEndpoint: 'https://a.example/intake', siteId: 'line-a' }), true, 'siteId 가 그 자리를 대신한다')
|
|
122
|
+
assert.equal(actuationAddressed({ intakeSiteId: 'line-a' }), false, '주소가 없으면 아무 데도 안 간다')
|
|
123
|
+
assert.equal(actuationAddressed({ intakeEndpoint: 'https://a.example/intake' }), false, '받는 사이트를 모르면 못 보낸다')
|
|
124
|
+
assert.equal(actuationAddressed(undefined), false)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('★ 읽기 문이 그 상태를 싣는다 — 저장돼 있어도 나르지 않으면 화면이 볼 길이 없다', () => {
|
|
128
|
+
/*
|
|
129
|
+
* 2026-09-18 실측: 칸 셋은 2026-09 부터 서 있었고 왕복의 결과도 적히는데, 목록도 상세도 그 값을
|
|
130
|
+
* 싣지 않았다. 저장된 사실이 아무에게도 닿지 않는 상태였다.
|
|
131
|
+
*/
|
|
132
|
+
const src = readFileSync(new URL('../server/service/reference/reference-resolver.ts', import.meta.url).pathname, 'utf-8')
|
|
133
|
+
for (const door of ['async twinReferences(', 'async twinReference(']) {
|
|
134
|
+
const at = src.indexOf(door)
|
|
135
|
+
assert.ok(at > 0, `${door} 를 못 찾았다 — 읽는 쪽이 깨졌다`)
|
|
136
|
+
const body = src.slice(at, at + 3000)
|
|
137
|
+
assert.ok(/actuationState/.test(body), `${door} 가 검증 상태를 싣지 않는다`)
|
|
138
|
+
}
|
|
139
|
+
})
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* What an area id is, and whose area it is.
|
|
3
|
+
*
|
|
4
|
+
* The item panel shows a location's parent. When the parent is an area, the host answers what that id is
|
|
5
|
+
* (`external: 'space.area'`). Nothing registered that answer until 2026-09-18, so every location's parent read
|
|
6
|
+
* "no component can resolve this relation" on the running twin.
|
|
7
|
+
*
|
|
8
|
+
* The lookup is (domain, space, areaId) — the area table's own unique key. An `areaId` is unique inside a space,
|
|
9
|
+
* so the same id can exist in two spaces of one tenant; the space comes from the twin the relation belongs to.
|
|
10
|
+
* Held here: the name of this twin's area, "missing" for an id that is in no area of this space, and another
|
|
11
|
+
* space's area of the same id never answering.
|
|
12
|
+
*/
|
|
13
|
+
import { test, before, after } from 'node:test'
|
|
14
|
+
import assert from 'node:assert/strict'
|
|
15
|
+
import { createRequire } from 'node:module'
|
|
16
|
+
import type { DataSource } from 'typeorm'
|
|
17
|
+
|
|
18
|
+
const req = createRequire(import.meta.url)
|
|
19
|
+
const { NamingStrategy, addDataSource, entities: shellEntities, Domain } = req('@things-factory/shell')
|
|
20
|
+
const { entities: authEntities } = req('@things-factory/auth-base')
|
|
21
|
+
const { entities: twinEntities } = req('../dist-server/service/index.js')
|
|
22
|
+
const { resolveAreas, installAreaResolver, SPACE_AREA_ENTITY } = req('../dist-server/service/twin-space/area-resolver.js')
|
|
23
|
+
const { externalResolverEntities, clearExternalResolvers, resolveExternal } = req('../dist-server/service/twin-model/external-resolver.js')
|
|
24
|
+
const { TwinSpace } = req('../dist-server/service/twin-space/twin-space.js')
|
|
25
|
+
const { TwinArea } = req('../dist-server/service/twin-space/twin-area.js')
|
|
26
|
+
const { TwinInstance } = req('../dist-server/service/twin-instance/twin-instance.js')
|
|
27
|
+
const { DataSource: TypeOrmDataSource } = req('typeorm')
|
|
28
|
+
|
|
29
|
+
let ds: DataSource
|
|
30
|
+
let domainId: string
|
|
31
|
+
let otherDomainId: string
|
|
32
|
+
|
|
33
|
+
before(async () => {
|
|
34
|
+
ds = new TypeOrmDataSource({
|
|
35
|
+
type: 'sqlite',
|
|
36
|
+
database: ':memory:',
|
|
37
|
+
synchronize: true,
|
|
38
|
+
dropSchema: true,
|
|
39
|
+
namingStrategy: new NamingStrategy(),
|
|
40
|
+
entities: [...shellEntities, ...authEntities, ...twinEntities],
|
|
41
|
+
logging: false
|
|
42
|
+
})
|
|
43
|
+
await ds.initialize()
|
|
44
|
+
addDataSource('default', ds)
|
|
45
|
+
const d = await ds.getRepository(Domain).save({ name: 'area-test', subdomain: 'area-test' } as any)
|
|
46
|
+
const other = await ds.getRepository(Domain).save({ name: 'area-other', subdomain: 'area-other' } as any)
|
|
47
|
+
domainId = d.id
|
|
48
|
+
otherDomainId = other.id
|
|
49
|
+
|
|
50
|
+
const spaces = ds.getRepository(TwinSpace)
|
|
51
|
+
const north = await spaces.save({ domain: { id: domainId }, spaceId: 'north', name: 'North site' } as any)
|
|
52
|
+
const south = await spaces.save({ domain: { id: domainId }, spaceId: 'south', name: 'South site' } as any)
|
|
53
|
+
const far = await spaces.save({ domain: { id: otherDomainId }, spaceId: 'far', name: 'Far site' } as any)
|
|
54
|
+
|
|
55
|
+
const areas = ds.getRepository(TwinArea)
|
|
56
|
+
/* The same areaId in two spaces of one tenant — that is what the space scope is for. */
|
|
57
|
+
await areas.save({ domain: { id: domainId }, space: { id: north.id }, areaId: 'hall-a', name: 'North Hall A' } as any)
|
|
58
|
+
await areas.save({ domain: { id: domainId }, space: { id: south.id }, areaId: 'hall-a', name: 'South Hall A' } as any)
|
|
59
|
+
await areas.save({ domain: { id: otherDomainId }, space: { id: far.id }, areaId: 'hall-a', name: 'Far Hall A' } as any)
|
|
60
|
+
/* An area with no name — the answer then carries the id alone, and does not invent one. */
|
|
61
|
+
await areas.save({ domain: { id: domainId }, space: { id: north.id }, areaId: 'annex' } as any)
|
|
62
|
+
|
|
63
|
+
const instances = ds.getRepository(TwinInstance)
|
|
64
|
+
await instances.save({ domain: { id: domainId }, instanceId: 'north-twin', kind: 'wms', restartPolicy: 'resume', purpose: 'operational', spaceId: 'north', status: 'stopped' } as any)
|
|
65
|
+
await instances.save({ domain: { id: domainId }, instanceId: 'south-twin', kind: 'wms', restartPolicy: 'resume', purpose: 'operational', spaceId: 'south', status: 'stopped' } as any)
|
|
66
|
+
await instances.save({ domain: { id: domainId }, instanceId: 'homeless-twin', kind: 'wms', restartPolicy: 'resume', purpose: 'operational', status: 'stopped' } as any)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
after(async () => {
|
|
70
|
+
clearExternalResolvers()
|
|
71
|
+
await ds?.destroy()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('an area of this twin comes back with its name', async () => {
|
|
75
|
+
const refs = await resolveAreas(domainId, ['hall-a'], { instanceId: 'north-twin' })
|
|
76
|
+
assert.deepEqual(refs, [{ id: 'hall-a', name: 'North Hall A' }])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('the same areaId in another space of the same tenant never answers', async () => {
|
|
80
|
+
const south = await resolveAreas(domainId, ['hall-a'], { instanceId: 'south-twin' })
|
|
81
|
+
assert.deepEqual(south, [{ id: 'hall-a', name: 'South Hall A' }], 'each twin sees its own space')
|
|
82
|
+
const other = await resolveAreas(otherDomainId, ['hall-a'], { instanceId: 'north-twin' })
|
|
83
|
+
assert.deepEqual(other, [{ id: 'hall-a', missing: true }], 'a twin of another tenant is not this tenant’s twin')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('an id that is in no area of this space is missing — it is not dropped', async () => {
|
|
87
|
+
const refs = await resolveAreas(domainId, ['hall-a', 'nowhere'], { instanceId: 'north-twin' })
|
|
88
|
+
assert.deepEqual(refs, [{ id: 'hall-a', name: 'North Hall A' }, { id: 'nowhere', missing: true }])
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('an area with no name answers with the id alone — no name is invented', async () => {
|
|
92
|
+
const refs = await resolveAreas(domainId, ['annex'], { instanceId: 'north-twin' })
|
|
93
|
+
assert.deepEqual(refs, [{ id: 'annex' }])
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('a twin with no space cannot have area parents, and says so', async () => {
|
|
97
|
+
const warned: string[] = []
|
|
98
|
+
const refs = await resolveAreas(domainId, ['hall-a'], { instanceId: 'homeless-twin' }, m => warned.push(m))
|
|
99
|
+
assert.deepEqual(refs, [{ id: 'hall-a', missing: true }])
|
|
100
|
+
assert.equal(warned.length, 1, 'the reason is said once, not swallowed')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('the resolver is registered under the word the contract uses, and answers through the seam', async () => {
|
|
104
|
+
clearExternalResolvers()
|
|
105
|
+
installAreaResolver()
|
|
106
|
+
assert.ok(externalResolverEntities().includes(SPACE_AREA_ENTITY))
|
|
107
|
+
assert.equal(SPACE_AREA_ENTITY, 'space.area')
|
|
108
|
+
const got = await resolveExternal(SPACE_AREA_ENTITY, domainId, ['hall-a'], { instanceId: 'north-twin' })
|
|
109
|
+
assert.deepEqual(got, { refs: [{ id: 'hall-a', name: 'North Hall A' }] })
|
|
110
|
+
})
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 축의 선언이 읽기 문을 그대로 지나간다 — `journalAxis` · `describedBy` (ADR-0074).
|
|
3
|
+
*
|
|
4
|
+
* 둘은 앱의 화면 상수에 있던 것이다. 그 자리에 있는 동안 축 이름이 한 세대 바뀌자 표가 따라오지
|
|
5
|
+
* 못했고, 로케이션 · 오더 · 품목의 360 은 저널을 **묻지도 않은 채** 「이 개념은 이력 축이 없다」를
|
|
6
|
+
* 그렸다. 계약으로 올라온 지금, 화면이 다시 자기 표를 만들지 않으려면 읽기 문이 그 값을 날라야 한다.
|
|
7
|
+
*
|
|
8
|
+
* 여기서 지키는 것은 하나다: **계약이 말한 값이 그대로 도착한다.** 값을 이 파일에 다시 적지 않는다 —
|
|
9
|
+
* 다시 적으면 계약이 바뀔 때 시험이 같이 썩고, 지나가는지를 못 본다.
|
|
10
|
+
*/
|
|
11
|
+
import { test, before, after } from 'node:test'
|
|
12
|
+
import assert from 'node:assert/strict'
|
|
13
|
+
import { createRequire } from 'node:module'
|
|
14
|
+
import type { DataSource } from 'typeorm'
|
|
15
|
+
|
|
16
|
+
const req = createRequire(import.meta.url)
|
|
17
|
+
const { NamingStrategy, addDataSource, entities: shellEntities, Domain } = req('@things-factory/shell')
|
|
18
|
+
const { entities: authEntities } = req('@things-factory/auth-base')
|
|
19
|
+
const { entities: twinEntities } = req('../dist-server/service/index.js')
|
|
20
|
+
const { TwinEngine } = req('../dist-server/engine/index.js')
|
|
21
|
+
const { TwinModelItemQuery } = req('../dist-server/service/twin-model/twin-model-item-query.js')
|
|
22
|
+
const { axisInfo } = req('@operato/ops-contract')
|
|
23
|
+
const { DataSource: TypeOrmDataSource } = req('typeorm')
|
|
24
|
+
|
|
25
|
+
let ds: DataSource
|
|
26
|
+
let q: any
|
|
27
|
+
let ctx: any
|
|
28
|
+
const INSTANCE = 'declaration-probe'
|
|
29
|
+
|
|
30
|
+
before(async () => {
|
|
31
|
+
ds = new TypeOrmDataSource({
|
|
32
|
+
type: 'sqlite',
|
|
33
|
+
database: ':memory:',
|
|
34
|
+
synchronize: true,
|
|
35
|
+
dropSchema: true,
|
|
36
|
+
namingStrategy: new NamingStrategy(),
|
|
37
|
+
entities: [...shellEntities, ...authEntities, ...twinEntities],
|
|
38
|
+
logging: false
|
|
39
|
+
})
|
|
40
|
+
await ds.initialize()
|
|
41
|
+
addDataSource('default', ds)
|
|
42
|
+
const d = await ds.getRepository(Domain).save({ name: 'decl-test', subdomain: 'decl-test' } as any)
|
|
43
|
+
q = new TwinModelItemQuery()
|
|
44
|
+
ctx = { state: { domain: { id: d.id } } }
|
|
45
|
+
|
|
46
|
+
/*
|
|
47
|
+
* 축마다 **자기 모양대로** 한 항목씩 — 설명 칸이 축마다 다른 이름으로 온다는 것이 아래 시험의 요점이다.
|
|
48
|
+
* (등급 넷과 검사 명세는 `name` 으로 들어오는데 형 선언은 `description` 을 말한다 — 실측, 2026-09-18.)
|
|
49
|
+
*/
|
|
50
|
+
await TwinEngine.ingestMaster(d.id, {
|
|
51
|
+
source: INSTANCE,
|
|
52
|
+
system: 'wms',
|
|
53
|
+
siteName: 'declaration site',
|
|
54
|
+
space: { width: 40, depth: 20, unit: 'm' },
|
|
55
|
+
locations: [{ id: 'bin-1', type: 'storage', role: 'location', name: 'Bin 1', capacity: 10 }],
|
|
56
|
+
equipment: [{ id: 'fk-1', kind: 'forklift', homeLocationId: 'bin-1' }],
|
|
57
|
+
equipmentClasses: [{ id: 'EC-FORKLIFT', name: '지게차' }],
|
|
58
|
+
materialClasses: [{ id: 'MC-BOX', name: '상자류' }],
|
|
59
|
+
materialDefinitions: [{ id: 'MD-BOX-01', name: '표준 상자', materialClassIds: ['MC-BOX'] }],
|
|
60
|
+
testSpecifications: [{ id: 'TS-DROP', name: '낙하 시험', version: '1', criteria: [] }]
|
|
61
|
+
}, 'reset')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
after(async () => {
|
|
65
|
+
await TwinEngine.stop(ctx.state.domain.id, INSTANCE).catch(() => {})
|
|
66
|
+
await ds?.destroy()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('★ 자리의 이력 축과 설명 필드가 계약이 말한 그대로 도착한다', async () => {
|
|
70
|
+
const item = await q.twinModelItem(INSTANCE, 'locations', 'bin-1', ctx)
|
|
71
|
+
const declared = axisInfo('locations')
|
|
72
|
+
assert.ok(declared?.journalAxis, '계약이 자리의 저널 축을 말하지 않는다 — 시험의 기준이 비었다')
|
|
73
|
+
assert.equal(item.identity.journalAxis, declared.journalAxis)
|
|
74
|
+
assert.deepEqual(item.identity.describedBy, declared.describedBy)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('★ 설비도 같은 자리에서 자기 선언을 받는다 — 축마다 다른 값이 온다', async () => {
|
|
78
|
+
const item = await q.twinModelItem(INSTANCE, 'equipment', 'fk-1', ctx)
|
|
79
|
+
const declared = axisInfo('equipment')
|
|
80
|
+
assert.equal(item.identity.journalAxis, declared!.journalAxis)
|
|
81
|
+
assert.deepEqual(item.identity.describedBy, declared!.describedBy)
|
|
82
|
+
/* 두 축이 같은 값을 받으면 「지나간다」를 못 본 것이다. */
|
|
83
|
+
assert.notEqual(declared!.journalAxis, axisInfo('locations')!.journalAxis)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('★ 저널이 그 축으로 기록하지 않는 개념은 null 로 온다 — 없는 것을 이름으로 채우지 않는다', async () => {
|
|
87
|
+
const declared = axisInfo('operations')
|
|
88
|
+
assert.equal(declared?.journalAxis, undefined, '명세 축은 저널 축을 갖지 않는다')
|
|
89
|
+
/* 이 트윈에는 공정 선언이 없다 — 선언이 없는 축도 문은 같은 모양으로 답한다. */
|
|
90
|
+
const item = await q.twinModelItem(INSTANCE, 'operations', 'no-such-operation', ctx)
|
|
91
|
+
if (item) assert.equal(item.identity.journalAxis, null, '없는 것은 null 이고 빈 문자열이 아니다')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
/*
|
|
95
|
+
* 상태 축에 없는 개체를 물으면 — **그런 개체가 없다**고 답한다.
|
|
96
|
+
*
|
|
97
|
+
* 2026-09-18, 도는 서버에서: 지어낸 작업 식별자(`tasks:T-1`)로 물었더니 패널이 하나 돌아왔고 화면은
|
|
98
|
+
* 「트윈이 실제로 본 것입니다」라고 적었다. 있지도 않은 개체가 **값이 비어 있는 개체**처럼 보였다.
|
|
99
|
+
* 갈래가 `!snap || !found` 하나여서 두 사실이 한 답으로 접혔기 때문이다.
|
|
100
|
+
*
|
|
101
|
+
* 상태를 읽었는데 없다 → 그런 개체가 없다(문서 축과 같은 `null`)
|
|
102
|
+
* 상태를 못 읽었다 → 모른다. 이유를 코드로 말한다(`not-running`)
|
|
103
|
+
*/
|
|
104
|
+
test('★ 상태를 읽었는데 그 개체가 없으면 없다고 답한다 — 빈 개체를 만들지 않는다', async () => {
|
|
105
|
+
const gone = await q.twinModelItem(INSTANCE, 'tasks', 'NO-SUCH-TASK-ID', ctx)
|
|
106
|
+
assert.equal(gone, null, '없는 개체에 패널을 돌려주면 화면이 그것을 그린다')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('★ 같은 물음을 문서 축에 해도 같은 답이다 — 축마다 다른 말을 하지 않는다', async () => {
|
|
110
|
+
const gone = await q.twinModelItem(INSTANCE, 'locations', 'NO-SUCH-LOCATION', ctx)
|
|
111
|
+
assert.equal(gone, null)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('★ 있는 개체는 그대로 답한다 — 위의 고침이 다 지워 버리지 않았는지', async () => {
|
|
115
|
+
const item = await q.twinModelItem(INSTANCE, 'locations', 'bin-1', ctx)
|
|
116
|
+
assert.ok(item, '있는 개체가 사라졌다')
|
|
117
|
+
assert.equal(item.itemId, 'bin-1')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
/*
|
|
121
|
+
* **선언이 고른 칸이 실제 항목에 있는가** (2026-09-18).
|
|
122
|
+
*
|
|
123
|
+
* `describedBy` 를 형 선언에서 뽑아 적었더니 여섯 축이 빗나갔다 — 등급 넷 · 품목 정의 · 검사 명세는
|
|
124
|
+
* 형이 `description` 을 말하는데 들어오는 자료는 `name` 을 든다. 화면은 오류 없이 **밑줄이 없는
|
|
125
|
+
* 제목**만 그렸다(도는 트윈에서 실측). 계약과 자료가 갈린 것을 시험이 못 잡았던 이유는, 그 시험이
|
|
126
|
+
* 「그 이름이 계약 어딘가에 선언돼 있나」까지만 봤기 때문이다.
|
|
127
|
+
*
|
|
128
|
+
* 그래서 여기서는 **들어온 항목**에 대고 잰다: 선언한 칸 중 적어도 하나가 그 항목에 값으로 있어야 한다.
|
|
129
|
+
*/
|
|
130
|
+
test('★ 선언이 고른 칸 중 하나는 실제 항목이 들고 있다 — 계약과 자료가 갈리면 밑줄이 사라진다', async () => {
|
|
131
|
+
const sample: Record<string, string> = {
|
|
132
|
+
locations: 'bin-1',
|
|
133
|
+
equipment: 'fk-1',
|
|
134
|
+
equipmentClasses: 'EC-FORKLIFT',
|
|
135
|
+
materialClasses: 'MC-BOX',
|
|
136
|
+
materialDefinitions: 'MD-BOX-01',
|
|
137
|
+
testSpecifications: 'TS-DROP'
|
|
138
|
+
}
|
|
139
|
+
const missing: string[] = []
|
|
140
|
+
for (const [axis, itemId] of Object.entries(sample)) {
|
|
141
|
+
const item = await q.twinModelItem(INSTANCE, axis, itemId, ctx)
|
|
142
|
+
assert.ok(item, `${axis}: 픽스처가 안 들어갔다 — 이 시험이 아무것도 재지 못한다`)
|
|
143
|
+
const keys: string[] = item.identity?.describedBy ?? []
|
|
144
|
+
assert.ok(keys.length, `${axis}: 계약이 설명 칸을 말하지 않는다`)
|
|
145
|
+
const has = keys.some(k => {
|
|
146
|
+
const v = (item.observed ?? {})[k] ?? (item.declared ?? {})[k]
|
|
147
|
+
return v != null && v !== ''
|
|
148
|
+
})
|
|
149
|
+
if (!has) missing.push(`${axis}: 선언은 [${keys.join(', ')}] 인데 항목이 든 것은 [${Object.keys(item.declared ?? {}).join(', ')}]`)
|
|
150
|
+
}
|
|
151
|
+
assert.deepEqual(missing, [], missing.join('\n'))
|
|
152
|
+
})
|