/* * 축의 선언이 읽기 문을 그대로 지나간다 — `journalAxis` · `describedBy` (ADR-0074). * * 둘은 앱의 화면 상수에 있던 것이다. 그 자리에 있는 동안 축 이름이 한 세대 바뀌자 표가 따라오지 * 못했고, 로케이션 · 오더 · 품목의 360 은 저널을 **묻지도 않은 채** 「이 개념은 이력 축이 없다」를 * 그렸다. 계약으로 올라온 지금, 화면이 다시 자기 표를 만들지 않으려면 읽기 문이 그 값을 날라야 한다. * * 여기서 지키는 것은 하나다: **계약이 말한 값이 그대로 도착한다.** 값을 이 파일에 다시 적지 않는다 — * 다시 적으면 계약이 바뀔 때 시험이 같이 썩고, 지나가는지를 못 본다. */ import { test, before, after } from 'node:test' import assert from 'node:assert/strict' import { createRequire } from 'node:module' import type { DataSource } from 'typeorm' const req = createRequire(import.meta.url) const { NamingStrategy, addDataSource, entities: shellEntities, Domain } = req('@things-factory/shell') const { entities: authEntities } = req('@things-factory/auth-base') const { entities: twinEntities } = req('../dist-server/service/index.js') const { TwinEngine } = req('../dist-server/engine/index.js') const { TwinModelItemQuery } = req('../dist-server/service/twin-model/twin-model-item-query.js') const { axisInfo } = req('@operato/ops-contract') const { DataSource: TypeOrmDataSource } = req('typeorm') let ds: DataSource let q: any let ctx: any const INSTANCE = 'declaration-probe' before(async () => { ds = new TypeOrmDataSource({ type: 'sqlite', database: ':memory:', synchronize: true, dropSchema: true, namingStrategy: new NamingStrategy(), entities: [...shellEntities, ...authEntities, ...twinEntities], logging: false }) await ds.initialize() addDataSource('default', ds) const d = await ds.getRepository(Domain).save({ name: 'decl-test', subdomain: 'decl-test' } as any) q = new TwinModelItemQuery() ctx = { state: { domain: { id: d.id } } } /* * 축마다 **자기 모양대로** 한 항목씩 — 설명 칸이 축마다 다른 이름으로 온다는 것이 아래 시험의 요점이다. * (등급 넷과 검사 명세는 `name` 으로 들어오는데 형 선언은 `description` 을 말한다 — 실측, 2026-09-18.) */ await TwinEngine.ingestMaster(d.id, { source: INSTANCE, system: 'wms', siteName: 'declaration site', space: { width: 40, depth: 20, unit: 'm' }, locations: [{ id: 'bin-1', type: 'storage', role: 'location', name: 'Bin 1', capacity: 10 }], equipment: [{ id: 'fk-1', kind: 'forklift', homeLocationId: 'bin-1' }], equipmentClasses: [{ id: 'EC-FORKLIFT', name: '지게차' }], materialClasses: [{ id: 'MC-BOX', name: '상자류' }], materialDefinitions: [{ id: 'MD-BOX-01', name: '표준 상자', materialClassIds: ['MC-BOX'] }], testSpecifications: [{ id: 'TS-DROP', name: '낙하 시험', version: '1', criteria: [] }] }, 'reset') }) after(async () => { await TwinEngine.stop(ctx.state.domain.id, INSTANCE).catch(() => {}) await ds?.destroy() }) test('★ 자리의 이력 축과 설명 필드가 계약이 말한 그대로 도착한다', async () => { const item = await q.twinModelItem(INSTANCE, 'locations', 'bin-1', ctx) const declared = axisInfo('locations') assert.ok(declared?.journalAxis, '계약이 자리의 저널 축을 말하지 않는다 — 시험의 기준이 비었다') assert.equal(item.identity.journalAxis, declared.journalAxis) assert.deepEqual(item.identity.describedBy, declared.describedBy) }) test('★ 설비도 같은 자리에서 자기 선언을 받는다 — 축마다 다른 값이 온다', async () => { const item = await q.twinModelItem(INSTANCE, 'equipment', 'fk-1', ctx) const declared = axisInfo('equipment') assert.equal(item.identity.journalAxis, declared!.journalAxis) assert.deepEqual(item.identity.describedBy, declared!.describedBy) /* 두 축이 같은 값을 받으면 「지나간다」를 못 본 것이다. */ assert.notEqual(declared!.journalAxis, axisInfo('locations')!.journalAxis) }) test('★ 저널이 그 축으로 기록하지 않는 개념은 null 로 온다 — 없는 것을 이름으로 채우지 않는다', async () => { const declared = axisInfo('operations') assert.equal(declared?.journalAxis, undefined, '명세 축은 저널 축을 갖지 않는다') /* 이 트윈에는 공정 선언이 없다 — 선언이 없는 축도 문은 같은 모양으로 답한다. */ const item = await q.twinModelItem(INSTANCE, 'operations', 'no-such-operation', ctx) if (item) assert.equal(item.identity.journalAxis, null, '없는 것은 null 이고 빈 문자열이 아니다') }) /* * 상태 축에 없는 개체를 물으면 — **그런 개체가 없다**고 답한다. * * 2026-09-18, 도는 서버에서: 지어낸 작업 식별자(`tasks:T-1`)로 물었더니 패널이 하나 돌아왔고 화면은 * 「트윈이 실제로 본 것입니다」라고 적었다. 있지도 않은 개체가 **값이 비어 있는 개체**처럼 보였다. * 갈래가 `!snap || !found` 하나여서 두 사실이 한 답으로 접혔기 때문이다. * * 상태를 읽었는데 없다 → 그런 개체가 없다(문서 축과 같은 `null`) * 상태를 못 읽었다 → 모른다. 이유를 코드로 말한다(`not-running`) */ test('★ 상태를 읽었는데 그 개체가 없으면 없다고 답한다 — 빈 개체를 만들지 않는다', async () => { const gone = await q.twinModelItem(INSTANCE, 'tasks', 'NO-SUCH-TASK-ID', ctx) assert.equal(gone, null, '없는 개체에 패널을 돌려주면 화면이 그것을 그린다') }) test('★ 같은 물음을 문서 축에 해도 같은 답이다 — 축마다 다른 말을 하지 않는다', async () => { const gone = await q.twinModelItem(INSTANCE, 'locations', 'NO-SUCH-LOCATION', ctx) assert.equal(gone, null) }) test('★ 있는 개체는 그대로 답한다 — 위의 고침이 다 지워 버리지 않았는지', async () => { const item = await q.twinModelItem(INSTANCE, 'locations', 'bin-1', ctx) assert.ok(item, '있는 개체가 사라졌다') assert.equal(item.itemId, 'bin-1') }) /* * **선언이 고른 칸이 실제 항목에 있는가** (2026-09-18). * * `describedBy` 를 형 선언에서 뽑아 적었더니 여섯 축이 빗나갔다 — 등급 넷 · 품목 정의 · 검사 명세는 * 형이 `description` 을 말하는데 들어오는 자료는 `name` 을 든다. 화면은 오류 없이 **밑줄이 없는 * 제목**만 그렸다(도는 트윈에서 실측). 계약과 자료가 갈린 것을 시험이 못 잡았던 이유는, 그 시험이 * 「그 이름이 계약 어딘가에 선언돼 있나」까지만 봤기 때문이다. * * 그래서 여기서는 **들어온 항목**에 대고 잰다: 선언한 칸 중 적어도 하나가 그 항목에 값으로 있어야 한다. */ test('★ 선언이 고른 칸 중 하나는 실제 항목이 들고 있다 — 계약과 자료가 갈리면 밑줄이 사라진다', async () => { const sample: Record = { locations: 'bin-1', equipment: 'fk-1', equipmentClasses: 'EC-FORKLIFT', materialClasses: 'MC-BOX', materialDefinitions: 'MD-BOX-01', testSpecifications: 'TS-DROP' } const missing: string[] = [] for (const [axis, itemId] of Object.entries(sample)) { const item = await q.twinModelItem(INSTANCE, axis, itemId, ctx) assert.ok(item, `${axis}: 픽스처가 안 들어갔다 — 이 시험이 아무것도 재지 못한다`) const keys: string[] = item.identity?.describedBy ?? [] assert.ok(keys.length, `${axis}: 계약이 설명 칸을 말하지 않는다`) const has = keys.some(k => { const v = (item.observed ?? {})[k] ?? (item.declared ?? {})[k] return v != null && v !== '' }) if (!has) missing.push(`${axis}: 선언은 [${keys.join(', ')}] 인데 항목이 든 것은 [${Object.keys(item.declared ?? {}).join(', ')}]`) } assert.deepEqual(missing, [], missing.join('\n')) })