@things-factory/headless-twin 10.1.27 → 10.1.28
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/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-model-item-query.js +30 -6
- 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/twin-model/external-resolver.ts +15 -3
- package/server/service/twin-model/twin-model-item-query.ts +29 -6
- package/server/service/twin-model/twin-model-query.ts +14 -0
- package/server/service/twin-space/area-resolver.ts +75 -0
- package/test/area-resolver-db.test.ts +110 -0
- package/test/axis-declaration-passthrough-db.test.ts +110 -0
- package/test/external-resolver.test.ts +11 -5
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -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
|
+
}
|
|
@@ -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,110 @@
|
|
|
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
|
+
await TwinEngine.ingestMaster(d.id, {
|
|
47
|
+
source: INSTANCE,
|
|
48
|
+
system: 'wms',
|
|
49
|
+
siteName: 'declaration site',
|
|
50
|
+
space: { width: 40, depth: 20, unit: 'm' },
|
|
51
|
+
locations: [{ id: 'bin-1', type: 'storage', role: 'location', name: 'Bin 1', capacity: 10 }],
|
|
52
|
+
equipment: [{ id: 'fk-1', kind: 'forklift', homeLocationId: 'bin-1' }]
|
|
53
|
+
}, 'reset')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
after(async () => {
|
|
57
|
+
await TwinEngine.stop(ctx.state.domain.id, INSTANCE).catch(() => {})
|
|
58
|
+
await ds?.destroy()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('★ 자리의 이력 축과 설명 필드가 계약이 말한 그대로 도착한다', async () => {
|
|
62
|
+
const item = await q.twinModelItem(INSTANCE, 'locations', 'bin-1', ctx)
|
|
63
|
+
const declared = axisInfo('locations')
|
|
64
|
+
assert.ok(declared?.journalAxis, '계약이 자리의 저널 축을 말하지 않는다 — 시험의 기준이 비었다')
|
|
65
|
+
assert.equal(item.identity.journalAxis, declared.journalAxis)
|
|
66
|
+
assert.deepEqual(item.identity.describedBy, declared.describedBy)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('★ 설비도 같은 자리에서 자기 선언을 받는다 — 축마다 다른 값이 온다', async () => {
|
|
70
|
+
const item = await q.twinModelItem(INSTANCE, 'equipment', 'fk-1', ctx)
|
|
71
|
+
const declared = axisInfo('equipment')
|
|
72
|
+
assert.equal(item.identity.journalAxis, declared!.journalAxis)
|
|
73
|
+
assert.deepEqual(item.identity.describedBy, declared!.describedBy)
|
|
74
|
+
/* 두 축이 같은 값을 받으면 「지나간다」를 못 본 것이다. */
|
|
75
|
+
assert.notEqual(declared!.journalAxis, axisInfo('locations')!.journalAxis)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('★ 저널이 그 축으로 기록하지 않는 개념은 null 로 온다 — 없는 것을 이름으로 채우지 않는다', async () => {
|
|
79
|
+
const declared = axisInfo('operations')
|
|
80
|
+
assert.equal(declared?.journalAxis, undefined, '명세 축은 저널 축을 갖지 않는다')
|
|
81
|
+
/* 이 트윈에는 공정 선언이 없다 — 선언이 없는 축도 문은 같은 모양으로 답한다. */
|
|
82
|
+
const item = await q.twinModelItem(INSTANCE, 'operations', 'no-such-operation', ctx)
|
|
83
|
+
if (item) assert.equal(item.identity.journalAxis, null, '없는 것은 null 이고 빈 문자열이 아니다')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
/*
|
|
87
|
+
* 상태 축에 없는 개체를 물으면 — **그런 개체가 없다**고 답한다.
|
|
88
|
+
*
|
|
89
|
+
* 2026-09-18, 도는 서버에서: 지어낸 작업 식별자(`tasks:T-1`)로 물었더니 패널이 하나 돌아왔고 화면은
|
|
90
|
+
* 「트윈이 실제로 본 것입니다」라고 적었다. 있지도 않은 개체가 **값이 비어 있는 개체**처럼 보였다.
|
|
91
|
+
* 갈래가 `!snap || !found` 하나여서 두 사실이 한 답으로 접혔기 때문이다.
|
|
92
|
+
*
|
|
93
|
+
* 상태를 읽었는데 없다 → 그런 개체가 없다(문서 축과 같은 `null`)
|
|
94
|
+
* 상태를 못 읽었다 → 모른다. 이유를 코드로 말한다(`not-running`)
|
|
95
|
+
*/
|
|
96
|
+
test('★ 상태를 읽었는데 그 개체가 없으면 없다고 답한다 — 빈 개체를 만들지 않는다', async () => {
|
|
97
|
+
const gone = await q.twinModelItem(INSTANCE, 'tasks', 'NO-SUCH-TASK-ID', ctx)
|
|
98
|
+
assert.equal(gone, null, '없는 개체에 패널을 돌려주면 화면이 그것을 그린다')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('★ 같은 물음을 문서 축에 해도 같은 답이다 — 축마다 다른 말을 하지 않는다', async () => {
|
|
102
|
+
const gone = await q.twinModelItem(INSTANCE, 'locations', 'NO-SUCH-LOCATION', ctx)
|
|
103
|
+
assert.equal(gone, null)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('★ 있는 개체는 그대로 답한다 — 위의 고침이 다 지워 버리지 않았는지', async () => {
|
|
107
|
+
const item = await q.twinModelItem(INSTANCE, 'locations', 'bin-1', ctx)
|
|
108
|
+
assert.ok(item, '있는 개체가 사라졌다')
|
|
109
|
+
assert.equal(item.itemId, 'bin-1')
|
|
110
|
+
})
|
|
@@ -28,6 +28,12 @@ import {
|
|
|
28
28
|
|
|
29
29
|
const AREA = 'space.area'
|
|
30
30
|
|
|
31
|
+
/*
|
|
32
|
+
* 어느 트윈의 관계인지 — `resolve` 는 이것을 함께 받는다(2026-09-18). 밖의 id 는 도메인에서 유일하지
|
|
33
|
+
* 않아서(구역 id 는 공간 안에서만 유일), 문맥 없이 답하면 남의 공간 이름이 이 자리의 부모로 보인다.
|
|
34
|
+
*/
|
|
35
|
+
const CTX = { instanceId: 'probe-twin' }
|
|
36
|
+
|
|
31
37
|
/* ── 못 풀면 못 푼다고 말한다 ────────────────────────────────────────────── */
|
|
32
38
|
|
|
33
39
|
test('★ 꽂힌 것이 없으면 「해소기가 없다」고 말한다 — 「없다」가 아니다', async () => {
|
|
@@ -36,7 +42,7 @@ test('★ 꽂힌 것이 없으면 「해소기가 없다」고 말한다 — 「
|
|
|
36
42
|
* 사람이 할 일이 다르다 — 앞은 자료를 채울 일이고 뒤는 해소기를 꽂을 일이다.
|
|
37
43
|
*/
|
|
38
44
|
clearExternalResolvers()
|
|
39
|
-
const got = await resolveExternal(AREA, 'd1', ['LINE-CAM'])
|
|
45
|
+
const got = await resolveExternal(AREA, 'd1', ['LINE-CAM'], CTX)
|
|
40
46
|
|
|
41
47
|
assert.equal(got.unresolved, 'no-resolver')
|
|
42
48
|
assert.equal(got.refs, undefined)
|
|
@@ -47,7 +53,7 @@ test('★ 해소기가 터져도 360 을 죽이지 않는다 — 알리고 못
|
|
|
47
53
|
const said: string[] = []
|
|
48
54
|
registerExternalResolver({ entity: AREA, resolve: async () => { throw new Error('DB down') } })
|
|
49
55
|
|
|
50
|
-
const got = await resolveExternal(AREA, 'd1', ['X'], m => said.push(m))
|
|
56
|
+
const got = await resolveExternal(AREA, 'd1', ['X'], CTX, m => said.push(m))
|
|
51
57
|
|
|
52
58
|
assert.equal(got.unresolved, 'failed')
|
|
53
59
|
assert.ok(said.some(m => m.includes('DB down')), '사유를 삼켰다')
|
|
@@ -62,7 +68,7 @@ test('★ 이름과 갈 곳을 낸다 — 막다른 링크를 만들지 않으
|
|
|
62
68
|
resolve: async (_d, ids) => ids.map(id => ({ id, name: `라인 ${id}`, href: `/areas/${id}` }))
|
|
63
69
|
})
|
|
64
70
|
|
|
65
|
-
const got = await resolveExternal(AREA, 'd1', ['LINE-CAM'])
|
|
71
|
+
const got = await resolveExternal(AREA, 'd1', ['LINE-CAM'], CTX)
|
|
66
72
|
assert.equal(got.refs?.[0].name, '라인 LINE-CAM')
|
|
67
73
|
assert.equal(got.refs?.[0].href, '/areas/LINE-CAM')
|
|
68
74
|
})
|
|
@@ -72,7 +78,7 @@ test('★ 없는 id 는 없다고 말한다 — 답에서 빼지 않는다', asy
|
|
|
72
78
|
clearExternalResolvers()
|
|
73
79
|
registerExternalResolver({ entity: AREA, resolve: async (_d, ids) => ids.map(id => ({ id, missing: true })) })
|
|
74
80
|
|
|
75
|
-
const got = await resolveExternal(AREA, 'd1', ['사라진구역'])
|
|
81
|
+
const got = await resolveExternal(AREA, 'd1', ['사라진구역'], CTX)
|
|
76
82
|
assert.equal(got.refs?.length, 1)
|
|
77
83
|
assert.equal(got.refs?.[0].missing, true)
|
|
78
84
|
})
|
|
@@ -166,6 +172,6 @@ test('★ 이 검사가 실제로 무언가를 잰다 — 견준 것이 0이면
|
|
|
166
172
|
clearExternalResolvers()
|
|
167
173
|
assert.deepEqual(externalResolverEntities(), [], '지우기가 안 듣는다')
|
|
168
174
|
registerExternalResolver({ entity: AREA, resolve: async (_d, ids) => ids.map(id => ({ id })) })
|
|
169
|
-
const got = await resolveExternal(AREA, 'd', ['X'])
|
|
175
|
+
const got = await resolveExternal(AREA, 'd', ['X'], CTX)
|
|
170
176
|
assert.equal(got.refs?.length, 1, '꽂았는데 안 불린다')
|
|
171
177
|
})
|