/* * 구조 투영 — `TwinModelDef`(커널 입력 문서) → **표준 엔티티 행**(ADR-0032). * * ── 왜 DB 검사인가 ───────────────────────────────────────────────────────── * 이 코드의 값어치는 **DB 가 대신 지켜 주는 것**에 있다 — 끊어진 참조를 FK 가 막고, 원본 없는 행을 * NOT NULL 이 막는다. 순수 함수 검사로는 그게 하나도 확인되지 않는다. 그래서 실제 DataSource 를 * 세운다(멀티DB 규약상 sqlite 로 도는 것이 5개 드라이버 공통의 최소선이기도 하다). * * ── 이 검사가 지키는 것 ──────────────────────────────────────────────────── * · 문서의 세 축이 행으로 선다 * · 설비→자리가 **진짜 FK** 로 이어진다(문자열 대조가 아니라) * · 끊어진 참조는 **이름을 대고 보고**되고, 행 자체는 남는다(막지 않되 침묵하지 않는다) * · **전량 교체** — 두 번 돌려도 늘지 않고, 원본에서 사라진 것은 행에서도 사라진다 * · 원본 표식(`sourceRef`·`ingestedAt`) 없는 행은 만들 수 없다(캐시라는 사실이 구조로 지켜진다) * · 소요시간은 ISO 8601 **문자열 그대로** 남는다(분으로 줄이면 초가 오류 없이 사라진다) */ import { test, before, after, beforeEach } 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 { projectStructure } = req('../dist-server/service/twin-model/project-structure.js') const { TwinInstance } = req('../dist-server/service/twin-instance/twin-instance.js') const { TwinSpace } = req('../dist-server/service/twin-space/twin-space.js') const { TwinArea } = req('../dist-server/service/twin-space/twin-area.js') const { TwinLocation } = req('../dist-server/service/twin-model/twin-location.js') const { TwinEquipment } = req('../dist-server/service/twin-model/twin-equipment.js') const { TwinOperation } = req('../dist-server/service/twin-model/twin-operation.js') const { DataSource: TypeOrmDataSource } = req('typeorm') let ds: DataSource let domainId: string const INSTANCE = 'projection-probe' const SOURCE = 'projection-probe' /** 실제 트레일러 트윈과 같은 모양의 문서(축소판). */ const model = () => ({ locations: [ { id: 'cut-station', type: 'cut-station', name: '프레임 절단', capacity: 20, parentId: 'a-floor' }, { id: 'weld-station', type: 'weld-station', capacity: 20, parentId: 'a-floor' }, { id: 'orphan-bin', type: 'storage', capacity: 5, parentId: 'no-such-area' } ], equipment: [ { id: 'cutter-1', kind: 'cutter', name: '절단기 1호', homeLocation: 'cut-station', mtbfMs: 3600000, properties: [{ id: 'speed', value: '1.2', dataType: 'xs:double', uom: 'MTS' }] }, { id: 'welder-1', kind: 'welder', homeLocation: 'weld-station' }, { id: 'ghost-1', kind: 'welder', homeLocation: 'no-such-location' } ], operations: [ { key: 'cut', label: 'twin.tpl.x.cut', intent: 'process', locationType: 'cut-station', resourceType: 'cutter', duration: 'PT1H10M30S', variability: { distribution: 'triangular', min: 'PT1H', max: 'PT1H30M' }, parameters: [{ id: 'yield', value: '0.995' }], /* 네 자원 명세 — 설비는 `resourceType`, 나머지 셋은 아래. */ personnelSpecification: [{ personnelClass: 'cutter-op', quantity: 1 }], physicalAssetSpecification: [{ assetClass: 'jig', quantity: 1 }], materialSpecification: [{ materialDefinition: 'steel', use: 'consumed', quantity: 2 }] } ] }) 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: 'projection-test', subdomain: 'projection-test' } as any) domainId = d.id await ds.getRepository(TwinInstance).save({ domain: { id: domainId }, instanceId: INSTANCE, kind: 'mes', restartPolicy: 'reset', purpose: 'operational', status: 'stopped', model: {} } as any) /* 구역은 model 밖(호스트 개념) — 자리가 FK 로 이걸 가리킨다. */ const space = await ds.getRepository(TwinSpace).save({ domain: { id: domainId }, spaceId: 'sp-1', name: '공장' } as any) await ds.getRepository(TwinArea).save({ domain: { id: domainId }, space: { id: space.id }, areaId: 'a-floor', name: '생산 플로어' } as any) }) after(async () => { await ds?.destroy() }) beforeEach(async () => { await projectStructure(domainId, INSTANCE, model(), SOURCE) }) test('문서의 세 축이 행으로 선다', async () => { assert.equal(await ds.getRepository(TwinLocation).count(), 3) assert.equal(await ds.getRepository(TwinEquipment).count(), 3) assert.equal(await ds.getRepository(TwinOperation).count(), 1) }) test('설비→자리가 진짜 FK 로 이어진다 — 문자열 대조가 아니다', async () => { const eq = await ds.getRepository(TwinEquipment).findOne({ where: { equipmentId: 'cutter-1' }, relations: ['homeLocation'] }) assert.equal(eq?.homeLocation?.locationId, 'cut-station', 'FK 로 자리를 따라갈 수 있어야 한다') /* 자리 쪽에서도 같은 행이다 — 문자열이었으면 같은 값이지만 같은 행은 아니다. */ const loc = await ds.getRepository(TwinLocation).findOne({ where: { locationId: 'cut-station' } }) assert.equal(eq?.homeLocation?.id, loc?.id) }) test('자리→구역이 FK 로 이어진다 — 커널이 "밖" 이라 부른 참조가 호스트에서는 평범한 FK 다', async () => { const loc = await ds.getRepository(TwinLocation).findOne({ where: { locationId: 'cut-station' }, relations: ['area'] }) assert.equal(loc?.area?.areaId, 'a-floor') }) test('끊어진 참조는 이름을 대고 보고된다 — 막지 않되 침묵하지 않는다', async () => { const report = await projectStructure(domainId, INSTANCE, model(), SOURCE) assert.deepEqual(report.unresolvedHome, ['ghost-1→no-such-location']) assert.deepEqual(report.unresolvedArea, ['orphan-bin→no-such-area']) /* 행 자체는 남는다 — 참조 하나 때문에 설비가 통째로 사라지면 그게 더 나쁜 거짓말이다. */ const ghost = await ds.getRepository(TwinEquipment).findOne({ where: { equipmentId: 'ghost-1' }, relations: ['homeLocation'] }) assert.ok(ghost, '끊어진 참조를 가진 설비도 행으로 남는다') assert.equal(ghost?.homeLocation, null, '이을 수 없는 참조는 비운다(엉뚱한 자리에 붙이지 않는다)') }) test('전량 교체 — 두 번 돌려도 늘지 않는다', async () => { await projectStructure(domainId, INSTANCE, model(), SOURCE) await projectStructure(domainId, INSTANCE, model(), SOURCE) assert.equal(await ds.getRepository(TwinLocation).count(), 3) assert.equal(await ds.getRepository(TwinEquipment).count(), 3) }) test('원본에서 사라진 것은 행에서도 사라진다 — 캐시는 원본을 따라간다', async () => { const shrunk = model() shrunk.locations = shrunk.locations.filter(l => l.id !== 'weld-station') shrunk.equipment = shrunk.equipment.filter(m => m.id !== 'welder-1') await projectStructure(domainId, INSTANCE, shrunk, SOURCE) assert.equal(await ds.getRepository(TwinLocation).count(), 2) assert.equal(await ds.getRepository(TwinEquipment).count(), 2) assert.equal(await ds.getRepository(TwinLocation).countBy({ locationId: 'weld-station' } as any), 0) }) test('원본 표식 없는 행은 만들 수 없다 — 캐시라는 사실이 구조로 지켜진다', async () => { await assert.rejects( () => ds.getRepository(TwinLocation).save({ domain: { id: domainId }, locationId: 'no-origin', type: 'storage' /* sourceRef · ingestedAt 없음 */ } as any), /NOT NULL|constraint/i, '출처 없는 행이 들어가면 "원본에서 온 캐시" 라는 규율이 구조로는 안 지켜지는 것이다' ) }) test('소요시간은 ISO 8601 문자열 그대로 — 분으로 줄이면 초가 오류 없이 사라진다', async () => { const op = await ds.getRepository(TwinOperation).findOne({ where: { operationKey: 'cut' } }) assert.equal(op?.duration, 'PT1H10M30S') /* 변동성·파라미터는 도메인마다 형이 달라 원형 그대로 싣는다. */ assert.equal(op?.variability?.distribution, 'triangular') assert.deepEqual(op?.parameters, [{ id: 'yield', value: '0.995' }]) }) test('등록되지 않은 트윈에는 투영하지 않는다 — 주인 없는 행을 만들지 않는다', async () => { await assert.rejects(() => projectStructure(domainId, 'not-registered', model(), SOURCE), /not registered/) }) test('네 자원 명세가 함께 옮겨진다 — 선언한 것만 제약이 되므로 빠지면 화면이 거짓이 된다', async () => { /* * ISA-95 는 공정이 무엇을 필요로 하는지를 넷으로 말한다: 설비·인원·자산·자재. 셋이 투영에서 * 떨어져 있어서, 조회 화면이 "원본이 말한 값" 이라 적고도 넷 중 하나만 보였다 — 사람이 필요한 * 공정을 보고 **사람이 필요 없다고 결론 내게** 되어 있었다. */ const op = await ds.getRepository(TwinOperation).findOne({ where: { operationKey: 'cut' } }) assert.deepEqual(op?.personnelSpecification, [{ personnelClass: 'cutter-op', quantity: 1 }], '인원 명세가 행에 남아야 한다') assert.deepEqual(op?.physicalAssetSpecification, [{ assetClass: 'jig', quantity: 1 }], '자산 명세도 남아야 한다') assert.deepEqual(op?.materialSpecification, [{ materialDefinition: 'steel', use: 'consumed', quantity: 2 }], '자재 명세도 남아야 한다') }) test('이름이 행까지 온다 — 이름이 없으면 화면에 id 만 남는다', async () => { /* * 투영이 마스터의 `name` 을 버리고 있었다. 행에 이름이 없으면 어떤 화면도 이름을 만들 수 없어서 * 목록·표·관계 그림이 모두 `cut-station` 을 찍었다 — 표면마다 따로 보이지만 결함은 여기 하나다. */ const loc = await ds.getRepository(TwinLocation).findOne({ where: { domain: { id: domainId } as any, locationId: 'cut-station' } as any }) assert.equal(loc!.name, '프레임 절단', '자리의 이름이 행에 실려야 한다') const eq = await ds.getRepository(TwinEquipment).findOne({ where: { domain: { id: domainId } as any, equipmentId: 'cutter-1' } as any }) assert.equal(eq!.name, '절단기 1호', '설비의 이름이 행에 실려야 한다') /* 공정은 마스터가 `label` 로 말한다 — 행에서는 규약의 이름으로 둔다(어휘를 하나로). */ const op = await ds.getRepository(TwinOperation).findOne({ where: { domain: { id: domainId } as any, operationKey: 'cut' } as any }) assert.equal(op!.name, 'twin.tpl.x.cut', '공정의 이름이 행에 실려야 한다') /* **이름 없는 원본은 이름 없이 남는다** — id 를 이름으로 베끼면 "이름이 있다" 는 거짓이 되고, 화면은 이름 없음을 알아볼 수 없게 된다. */ const noname = await ds.getRepository(TwinLocation).findOne({ where: { domain: { id: domainId } as any, locationId: 'weld-station' } as any }) assert.equal(noname!.name ?? null, null, '원본이 이름을 말하지 않았으면 비어 있어야 한다') }) test('자원 속성이 행까지 온다 — 소비처가 실제로 읽는 사실이다(주행 속도)', async () => { /* * 모델에는 있는데 행에 자리가 없어 투영이 흘리고 있었다. 그러면 조회 화면은 "원본이 말한 값" 이라 * 적고도 속성을 못 보인다 — 이름이 같은 식으로 사라졌던 것과 같은 부류다. * * 단위를 값과 함께 담는지도 본다: 단위 없는 "1.2" 는 초속인지 시속인지 말하지 않는다. */ const eq = await ds.getRepository(TwinEquipment).findOne({ where: { domain: { id: domainId } as any, equipmentId: 'cutter-1' } as any }) const props = (eq!.properties ?? []) as any[] assert.equal(props.length, 1, '선언한 속성이 행에 실려야 한다') assert.equal(props[0].id, 'speed') assert.equal(props[0].value, '1.2') assert.equal(props[0].uom, 'MTS', '단위가 값과 함께 남아야 한다') /* 선언하지 않은 자원은 **비어 있다** — 빈 배열로 "선언했다" 고 말하지 않는다. */ const noneProps = await ds.getRepository(TwinEquipment).findOne({ where: { domain: { id: domainId } as any, equipmentId: 'welder-1' } as any }) assert.equal(noneProps!.properties ?? null, null) })