/* * ★ 개념 안의 목록과 항목 하나 — **막다른 길을 만들지 않는다.** * * 개념 지도가 숫자만 내면 그 숫자가 막다른 길이다. 인스펙션의 원칙은 "화면에 보이는 모든 식별자는 * 그 자신의 상세로 가는 문" 이므로, 셈 다음에 **목록**과 **항목**이 있어야 그 원칙이 성립한다. * * 여기서 지키는 것: * · 어떻게 셌는지 밝힌다 — 행이면 DB 가 페이징, 문서면 서버가 통째로 읽어 잘랐다(`basis`) * · 알리지 않고 자르지 않는다 — 총 건수가 항상 함께 오고, 훑기가 상한에 닿으면 그렇다고 말한다 * · 모르는 축은 **거절한다** — 빈 목록으로 답하면 오타 하나가 "우리 트윈엔 그게 없다" 로 굳는다 * · 투영이 낡아도 항목은 열린다 — 행에 없으면 문서에서 찾는다 * · 이어진 것은 **양방향**이고, 선언된 관계만 따른다(선언에 없는 길은 그리지 않는다) * · 관측이 없는 것과 견주지 못한 것을 구별한다(`observed: null`) */ 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 { TwinModelQuery } = req('../dist-server/service/twin-model/twin-model-query.js') const { TwinSpace } = req('../dist-server/service/twin-space/twin-space.js') const { TwinArea } = req('../dist-server/service/twin-space/twin-area.js') const { DataSource: TypeOrmDataSource } = req('typeorm') let ds: DataSource let domainId: string let q: any let ctx: any const INSTANCE = 'item-probe' /** MES 마스터 — 자리·설비·공정에 더해 **문서에만 사는 축**(레시피·라우트)까지 있는 모양. */ const master = (): any => ({ source: INSTANCE, system: 'mes', siteName: '항목 공장', space: { width: 80, depth: 40, unit: 'm' }, locations: [ { id: 'a-floor', type: 'production-line', role: 'area', name: '생산 플로어' }, { id: 'cut-station', type: 'cut-station', role: 'location', name: '절단', parentId: 'a-floor', capacity: 20 }, { id: 'weld-station', type: 'weld-station', role: 'location', name: '용접', parentId: 'a-floor', capacity: 20 } ], equipment: [ { id: 'cutter-1', kind: 'cutter', homeLocationId: 'cut-station', mtbfMs: 3600000 }, { id: 'welder-1', kind: 'welder', homeLocationId: 'weld-station' }, { id: 'welder-2', kind: 'welder', homeLocationId: 'weld-station' } ], operations: [ { key: 'cut', label: '절단', intent: 'process', locationType: 'cut-station', resourceType: 'cutter', duration: 'PT10M' }, { key: 'weld', label: '용접', intent: 'process', locationType: 'weld-station', resourceType: 'welder', duration: 'PT20M' } ], productionSpec: { definition: { materials: [{ id: 'steel' }, { id: 'frame' }], routes: [{ key: 'frame-route', steps: ['cut', 'weld'] }], recipes: [{ key: 'frame-recipe', route: 'frame-route', inputs: [{ material: 'steel' }], outputs: [{ material: 'frame' }] }], operations: [ { key: 'cut', label: '절단', intent: 'process', locationType: 'cut-station', resourceType: 'cutter', duration: 'PT10M' }, { key: 'weld', label: '용접', intent: 'process', locationType: 'weld-station', resourceType: 'welder', duration: 'PT20M' } ] } } }) 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: 'item-test', subdomain: 'item-test' } as any) domainId = d.id q = new TwinModelItemQuery() ctx = { state: { domain: { id: domainId } } } await TwinEngine.ingestMaster(domainId, master(), 'reset') }) after(async () => { await TwinEngine.stop(domainId, INSTANCE).catch(() => {}) await ds?.destroy() }) test('행으로 옮긴 축은 DB 가 페이징하고 그 사실을 밝힌다', async () => { const page1 = await q.twinModelEntities(INSTANCE, 'equipment', ctx, undefined, 0, 2) assert.equal(page1.basis, 'rows', 'DB 가 셌다는 사실이 응답에 있어야 한다') assert.equal(page1.total, 3, '총 건수는 페이지 크기와 무관하게 사실이다') assert.equal(page1.items.length, 2) const page2 = await q.twinModelEntities(INSTANCE, 'equipment', ctx, undefined, 2, 2) assert.equal(page2.items.length, 1, '이어 읽을 수 있다') const seen = [...page1.items, ...page2.items].map((i: any) => i.id).sort() assert.deepEqual(seen, ['cutter-1', 'welder-1', 'welder-2'], '두 페이지가 전건을 덮는다') }) test('아직 문서에 사는 축도 열린다 — 이주 전이라고 안 보이면 안 된다', async () => { const r = await q.twinModelEntities(INSTANCE, 'recipes', ctx) assert.equal(r.basis, 'document', '문서를 읽어 잘랐다는 사실을 밝힌다(규모 한계가 드러나야 한다)') assert.equal(r.total, 1) assert.deepEqual(r.items.map((i: any) => i.id), ['frame-recipe']) }) test('검색이 전수 조회라는 사실을 드러낸다 — 감추면 한계를 아무도 모른다', async () => { const r = await q.twinModelEntities(INSTANCE, 'equipment', ctx, 'weld') assert.equal(r.total, 2) /* 훑어 걸렀다는 사실은 **이 두 칸이** 말한다(근거가 아니라 셈의 한계다). */ assert.equal(typeof r.scanned, 'number', '몇 줄을 읽었는지 말하지 않는다') assert.equal(r.truncated, false, '상한에 닿지 않았으면 잘리지 않았다고 말한다') assert.deepEqual(r.items.map((i: any) => i.id).sort(), ['welder-1', 'welder-2']) }) test('★ 근거는 계약이 닫아 둔 다섯 안에서만 답한다', async () => { /* * 2026-09-18 판정(ADR-0075 결정 3 을 코드에 대어 본 결과): 검색 갈래가 `'rows-scanned'` 를 냈다. * 그것은 출처가 아니라 우리가 어떻게 셌나이고, 목록 소비자는 계약에 없는 값을 만난다. 출처는 * 훑었든 DB 가 골랐든 같은 행이므로 `rows` 다. */ const { READ_BASIS } = req('@operato/ops-contract') const answers = [ await q.twinModelEntities(INSTANCE, 'equipment', ctx, 'weld'), await q.twinModelEntities(INSTANCE, 'equipment', ctx), await q.twinModelEntities(INSTANCE, 'recipes', ctx) ] for (const a of answers) assert.ok(READ_BASIS.includes(a.basis), `계약 밖의 근거 '${a.basis}'`) assert.equal(answers[0].basis, 'rows', '훑어 세도 값이 온 곳은 행이다') }) test('모르는 축은 거절한다 — 빈 목록은 "없다" 로 읽힌다', async () => { await assert.rejects(() => q.twinModelEntities(INSTANCE, 'nosuch', ctx), /unknown concept axis/) await assert.rejects(() => q.twinModelItem(INSTANCE, 'nosuch', 'x', ctx), /unknown concept axis/) }) test('항목 하나 — 정체는 카탈로그에서, 선언은 원본에서, 출처가 함께 온다', async () => { const it = await q.twinModelItem(INSTANCE, 'equipment', 'cutter-1', ctx) assert.ok(it, '있는 항목은 열려야 한다') assert.equal(it.identity.typeRole, 'equipment', '표준 역할은 커널 카탈로그가 말한다') assert.equal(it.declared.kind, 'cutter') assert.equal(it.declared.mtbfMs, 3600000) assert.equal(it.provenance.basis, 'rows') assert.equal(it.provenance.sourceRef, INSTANCE, '어느 원본에서 왔는지가 항목에 붙어 있다') }) test('비교하지 못했으면 관측은 null 이고, 답했으면 **근거를 알린다**', async () => { const it = await q.twinModelItem(INSTANCE, 'equipment', 'cutter-1', ctx) /* * 이 픽스처에는 저널이 없다(행만 있다) — 되짚을 것이 없으므로 관측은 없다. **0 이나 빈 객체로 * 메우면 "관측했는데 아무것도 없었다" 로 읽힌다.** * * 예전 이 검사는 "트윈이 안 돌면 관측은 null" 이었다. 지금은 멈춘 트윈도 저널을 계산해 답할 수 있고, * 그 답이 **자기 근거를 달고 온다**(`observedBasis`). 그래서 규율을 그 자리에 맞춰 다시 적는다: * 답이 없으면 null, 답이 있으면 그것이 라이브인지 되짚은 것인지 말한다. 근거 없는 답은 없다. */ assert.equal(it.observed, null, '되짚을 저널이 없으면 관측은 없다') assert.equal(it.observedBasis, undefined, '답이 없으면 근거도 말하지 않는다(빈 답에 근거를 붙이면 견준 것처럼 보인다)') }) test('이어진 것은 양방향이고 선언된 관계만 따른다', async () => { const loc = await q.twinModelItem(INSTANCE, 'locations', 'weld-station', ctx) /* 들어오는 쪽 — 이 자리를 기준으로 삼는 설비 둘. 선언(`equipment.homeLocation`)이 알려 준다. */ const home = loc.links.incoming.find((l: any) => l.via === 'ops.rel.home' && l.from === 'equipment') assert.ok(home, '나를 가리키는 관계가 보여야 한 걸음씩 걸을 수 있다') assert.deepEqual(home.ids.sort(), ['welder-1', 'welder-2']) assert.equal(home.total, 2) /* 나가는 쪽 — 자리는 구역을 가리킨다. 대상이 **축 밖**(공간의 구역)이라는 사실을 그대로 낸다. */ const area = loc.links.out.find((l: any) => l.via === 'ops.rel.area') assert.ok(area, '커널이 "밖" 이라 부른 참조도 이어진 것으로 낸다') assert.equal(area.target.kind, 'external', '갈 곳이 다르므로 화면이 링크를 만들지 말지 알 수 있어야 한다') }) test('문서에만 사는 항목의 관계도 선언대로 이어진다', async () => { const recipe = await q.twinModelItem(INSTANCE, 'recipes', 'frame-recipe', ctx) assert.equal(recipe.provenance.basis, 'document') const route = recipe.links.out.find((l: any) => l.via === 'ops.rel.route') assert.deepEqual(route.ids, ['frame-route']) /* `inputs[].material` — 배열을 지나는 경로가 선언의 문법이고, 그것을 따르는 것이 선언을 존중하는 것이다. */ const input = recipe.links.out.find((l: any) => l.via === 'ops.rel.input') assert.deepEqual(input.ids, ['steel']) const output = recipe.links.out.find((l: any) => l.via === 'ops.rel.output') assert.deepEqual(output.ids, ['frame']) }) test('없는 항목은 null — 빈 껍데기를 내주지 않는다', async () => { assert.equal(await q.twinModelItem(INSTANCE, 'equipment', 'no-such-machine', ctx), null) }) test('내부 배관은 새어 나가지 않는다 — 사용자가 볼 것은 현장의 사실이지 우리 스키마가 아니다', async () => { /* * 실제로 새었다: `domainId`·`instanceRowId` 같은 내부 UUID 가 속성 패널까지 갔다. * 손으로 제외 목록을 두면 컬럼이 늘 때마다 잊는다 — 관계와 조인 컬럼은 엔티티 메타데이터가 안다. */ const INTERNAL = /^(id|domainId?|instanceRowId|areaRowId|homeLocationRowId|createdAt|updatedAt|creator|updater|creatorId|updaterId)$/ const list = await q.twinModelEntities(INSTANCE, 'locations', ctx) for (const it of list.items) for (const k of Object.keys(it.fields)) assert.ok(!INTERNAL.test(k), `목록에 내부 컬럼이 새었다: ${k}`) const item = await q.twinModelItem(INSTANCE, 'locations', 'weld-station', ctx) for (const k of Object.keys(item.declared)) assert.ok(!INTERNAL.test(k), `속성 패널에 내부 컬럼이 새었다: ${k}`) /* 식별자도 두 번 보이지 않는다 — 이미 itemId 로 나간다. */ assert.ok(!('locationId' in item.declared), '항목 자신의 id 를 속성으로 또 내지 않는다') /* 출처는 속성이 아니라 근거다. */ assert.ok(!('sourceRef' in item.declared) && !('ingestedAt' in item.declared)) assert.equal(item.provenance.sourceRef, INSTANCE) }) test('지도의 선이 진단이 된다 — 이어진 것뿐 아니라 **끊긴 것**을 센다', async () => { /* * 선이 "선언되었고 양끝이 존재한다" 만 말하면 지도는 그림일 뿐이다. 관계에서 가장 중요한 정보는 * 대개 부재다 — 기준 자리가 비어 있는 설비, 없는 자리를 가리키는 설비. */ const bad = master() bad.equipment.push({ id: 'ghost-1', kind: 'welder', homeLocationId: 'no-such-station' }) // 끊어진 참조 bad.equipment.push({ id: 'floater-1', kind: 'welder' }) // 기준 자리 없음 await TwinEngine.ingestMaster(domainId, bad, 'reset') const sum = await new TwinModelQuery().twinModelSummary(INSTANCE, ctx) const home = sum.edges.find((e: any) => e.from === 'equipment' && e.to === 'locations' && e.via === 'ops.rel.home') assert.ok(home, '설비→자리 선이 있어야 한다') assert.equal(home.linked, 3, '제대로 이어진 설비 셋') assert.equal(home.dangling, 1, '없는 자리를 가리키는 설비 하나 — 가장 나쁜 부류이므로 따로 센다') assert.equal(home.missing, 1, '기준 자리를 말하지 않은 설비 하나') /* 선택 관계인지도 함께 온다 — `missing` 이 결손인지 정상인지는 그것으로 갈린다. */ assert.equal(home.optional, true) }) /* * ★ 관측 열이 **자기 근거를 말한다** — 라이브인지 되짚은 것인지. * * 둘은 같은 모양의 값이라, 근거가 없으면 되짚은 값을 "지금 이렇다" 로 읽는다. 그래서 근거를 실을 * 자리가 없던 동안에는 멈춘 트윈의 저널을 아예 계산하지 않았다(관측 열이 통째로 `—`). 자리를 만든 뒤의 * 규율은 하나다: **답하면 근거를 말한다.** */ test('멈춘 트윈도 저널을 계산해 답하고, 그 답은 다시 계산한 값이라고 알린다', async () => { const { WmsKernel, EventJournal } = req('@operato/twin-kernel') const model = { spaceId: 'journal-space', utcOffsetMinutes: 0, /* 커널은 선언 없이 팔레트·문서를 만들지 않는다 — 픽스처 자기 이름공간을 준다. */ identity: { namespaces: ['https://twin.test/twin-model-item/journal'] }, locations: [ { id: 'dock-recv', type: 'dock', capacity: 100 }, { id: 'bin-1', type: 'storage', capacity: 20 } ], equipment: [{ id: 'fk-1', kind: 'forklift', homeLocation: 'dock-recv' }] } /* 커널을 실제로 굴려 봉투를 얻는다 — 손으로 지어낸 모양은 검증이 아니다. */ const journal = new EventJournal() const k = new WmsKernel('journal-probe') k.onEvent((e: any) => journal.append(e)) k.loadTwinModel(model) k.scenario.load({ seed: 7, generators: [ { kind: 'inbound-arrival', rate: { distribution: 'constant', meanPerHour: 240 }, content: { skuMix: [{ gtin: 'urn:epc:idpat:sgtin:0614141.012345.*', weight: 1 }], qtyPerLine: { min: 1, max: 1 } } } ] }) k.scenario.start() for (let i = 0; i < 200; i++) k.tick(1000) const INST = 'journal-probe' await TwinEngine.provision(domainId, INST, 'wms', model, 'reset') await TwinEngine.persistBatch(domainId, INST, journal.all(), 0) await TwinEngine.stop(domainId, INST).catch(() => {}) delete (TwinEngine as any).recovered[`${domainId}:${INST}`] const it = await q.twinModelItem(INST, 'equipment', 'fk-1', ctx) assert.ok(it?.observed, '저널이 있으면 멈춘 트윈도 마지막으로 알던 사실을 답할 수 있다') assert.equal(it.observedBasis, 'journal', '라이브가 아니라 되짚은 것이라고 말해야 "지금 이렇다" 로 읽히지 않는다') assert.ok(it.observedRevision > 0, '몇 번째 리비전까지 계산했는지 함께 말한다 — 규모의 한계도 그 수가 말한다') }) test('★ 멈춘 트윈을 되세우지 못한 답도 출처가 제 근거와 같다 — 저널을 보러 간 답이 「지금 상태」라고 말하지 않는다', async () => { /* * ADR-0075 결정 3 · 결함 넘김 2026-09-24: 이 갈래만 `provenance.basis` 를 `state` 로 박아 두어, 같은 답이 `basis` 는 * `journal` 인데 출처는 `state` 였다. 커널이 멈춰 있고(`isRunning` false) 되세우기도 실패하는 때를 대역으로 세운다. */ const { READ_BASIS } = req('@operato/ops-contract') const isRunning = TwinEngine.isRunning const recover = TwinEngine.recover TwinEngine.isRunning = () => false TwinEngine.recover = async () => { throw new Error('nothing to rebuild') } try { /* 상태에서 읽는 축이라야 이 갈래를 지난다(`orders` — 계약이 `source: 'state'` 로 선언). */ const it = await q.twinModelItem(INSTANCE, 'orders', 'order-1', ctx) assert.equal(it.observedAbsence, 'not-running', '전제: 못 읽은 갈래다') assert.equal(it.provenance.basis, it.basis, '출처와 근거가 같은 말을 한다') assert.equal(it.provenance.basis, 'journal', '멈춘 트윈은 저널을 보러 갔다') assert.ok(READ_BASIS.includes(it.provenance.basis)) } finally { TwinEngine.isRunning = isRunning TwinEngine.recover = recover } })