/* * 선언 적합성(투영 편) — **모델이 말한 사실은 행에 남고, 행에 남은 것은 화면까지 간다.** * * ── 왜 필요한가 ───────────────────────────────────────────────────────────── * 같은 이름의 하네스가 두 벌 있다. 커널 편은 *선언 ⊆ 상태* 를 지키고(kernel/declaration-in-state), * 경계 편은 *원본 → 모델* 을 지킨다(declaration-reaches-model). 그런데 사실이 새던 구간은 **셋**이었고, * 남은 하나가 이것이다: **모델 → DB 행 → 조회 응답.** * * 여기서 잃은 것들: 자리·설비의 이름(화면이 `cut-station` 을 찍었다) · GS1 참조 · 자원 속성 · * 시험 명세 참조 · 시험 결과. 전부 **투영 함수의 한 줄이 빠진** 것이었고, 그때는 사람이 하나씩 찾았다. * * 타입 검사는 못 잡는다: 컬럼이 거의 다 `nullable` 이라 **안 채우는 것이 합법**이다. * * ── 무엇이 기준인가 ───────────────────────────────────────────────────────── * **엔티티의 컬럼**이다(TypeORM 메타데이터에서 실행 중에 읽는다 — 손으로 적지 않는다). 컬럼이 있다는 * 것은 "이 사실이 여기 앉는다" 는 선언이고, 앉을 자리를 만들어 두고 안 채우면 그 컬럼은 영원히 빈다. * * 그래서 컬럼마다 셋을 요구한다: * ① 픽스처가 모델에 그 값을 싣는다(값을 줄 방법을 모르면 **검사가 실패한다** — 알리지 않고 건너뛰면 * 그 컬럼은 아무도 안 보는 컬럼이 된다) * ② 투영을 지나 행에 남는다 * ③ 조회 응답에 나온다(화면이 읽는 자리) * * 기반 컬럼(id·관계·감사·출처)은 모델이 말하는 사실이 아니므로 이유와 함께 뺀다. */ 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, getRepository } = 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 { 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 let q: any let ctx: any const INSTANCE = 'projection-probe' /** * 모델이 말하는 사실이 **아닌** 컬럼 — 이유와 함께. * * 비어 있으면 안 된다: 여기 적지 않으면 검사가 그 컬럼에 값을 요구하고, 그것은 틀린 요구다. * 반대로 여기에 슬쩍 올리면 감시가 사라지므로 **이유를 함께 적는다**(리뷰가 볼 자리다). */ const NOT_FROM_MODEL: Record = { id: '행의 열쇠 — 모델이 정하지 않는다(DB 가 발급한다)', locationId: '개체 식별자 — 대조의 열쇠라 값 비교에서 뺀다(존재는 조회로 이미 확인한다)', equipmentId: '같음', operationKey: '같음', sourceRef: '어디서 왔나 — 투영이 남기는 근거다(모델의 사실이 아니라 옮긴 행위의 기록)', ingestedAt: '언제 옮겼나 — 같은 이유', seq: '문서의 차례 — 투영이 넣는 내부 컬럼이다(원본이 말한 값이 아니다)' } /** 컬럼별 값 — 이름으로 준다. 여기 없는 컬럼이 검사 대상에 들면 하네스가 실패한다. */ const VALUE: Record = { name: '절단 스테이션', description: '강판을 자르는 자리', type: 'cut-station', kind: 'cutter', capacity: 40, parallelism: 2, level: 'WorkCenter', gs1Id: 'urn:epc:id:sgln:0614141.00001.0', /* * 설비의 정체성 — **GS1 키가 아닌 것이 정상이다.** 실측한 값은 인버터의 하드웨어 식별자다 * (`ES100000007d8d061d-1`). 자리의 `gs1Id`(SGLN)와 다른 축이라 이름도 다르다. */ identity: 'ES100000007d8d061d-1', homeLocation: 'cut-station', mtbfMs: 600000, mttrMs: 60000, properties: [{ id: 'speed', value: '1.2', dataType: 'xs:double', uom: 'MTS' }], testSpecificationIds: ['cut-cert'], testResults: [{ specId: 'cut-cert', result: 'pass', expiresAt: '2099-01-01T00:00:00.000Z' }], intent: 'process', locationType: 'cut-station', resourceType: 'cutter', duration: 'PT25M', variability: { distribution: 'triangular', min: 'PT20M', max: 'PT35M', mode: 'PT24M' }, parameters: [{ id: 'yield', value: '0.98' }], personnelSpecification: [{ personnelClass: 'cutter-op', quantity: 1 }], equipmentSpecification: [{ equipmentClass: 'cutter', quantity: 1 }], physicalAssetSpecification: [{ assetClass: 'euro-pallet', quantity: 1 }], materialSpecification: [{ material: 'steel', quantity: 1 }], label: '프레임 절단' } interface Case { axis: 'locations' | 'equipment' | 'operations' entity: any /** 행에서 그 개체를 찾는 컬럼 — 조회가 쓰는 것과 같은 이름이어야 한다(§ROW_BACKED). */ idColumn: string id: string } const CASES: Case[] = [ { axis: 'locations', entity: TwinLocation, idColumn: 'locationId', id: 'cut-station' }, { axis: 'equipment', entity: TwinEquipment, idColumn: 'equipmentId', id: 'cutter-1' }, { axis: 'operations', entity: TwinOperation, idColumn: 'operationKey', id: 'cut' } ] /** * 그 엔티티에서 **모델이 채워야 하는 컬럼** — TypeORM 메타데이터에서 읽는다(손으로 적지 않는다). * 관계·관계 id·감사 컬럼은 기반이므로 뺀다(그 판정은 조회가 쓰는 규칙과 같은 근거를 쓴다). */ function watchedColumns(entity: any): string[] { const meta = getRepository(entity).metadata const infra = new Set(['createdAt', 'updatedAt', 'creator', 'updater', 'creatorId', 'updaterId']) for (const rel of meta.relations) { infra.add(rel.propertyName) for (const jc of rel.joinColumns) infra.add(jc.propertyName) } for (const rid of meta.relationIds) infra.add(rid.propertyName) return meta.columns .map((c: any) => c.propertyName as string) .filter((n: string) => !infra.has(n) && !(n in NOT_FROM_MODEL)) .sort() } /** 모든 감시 컬럼에 값을 실은 마스터. */ function master(): any { const fill = (c: Case): Record => { const o: Record = {} for (const col of watchedColumns(c.entity)) { assert.ok( col in VALUE, `픽스처가 "${c.axis}.${col}" 에 실을 값을 모른다 — VALUE 에 하나 적어라(모델의 사실이 아니면 NOT_FROM_MODEL 에 이유와 함께).\n` + '알리지 않고 건너뛰면 그 컬럼은 아무도 안 보는 컬럼이 되고, 그것이 이 하네스가 막으려는 실패다.' ) o[col] = VALUE[col] } return o } const loc = fill(CASES[0]) const eq = fill(CASES[1]) const op = fill(CASES[2]) return { source: INSTANCE, system: 'mes', siteName: '투영 공장', space: { spaceId: 'projection-site', width: 60, depth: 40, unit: 'm' }, locations: [ { id: CASES[0].id, role: 'location', ...loc }, { id: 'fg-store', type: 'fg-store', role: 'location', name: '완제품', capacity: 100 } ], /* 경계에서 이름이 바뀌는 것은 하나다(`homeLocationId → homeLocation`) — 경계 편 하네스가 지킨다. */ equipment: [{ id: CASES[1].id, ...eq, homeLocationId: CASES[0].id }], testSpecifications: [{ id: 'cut-cert', name: '절단 자격' }], personnelClasses: [{ id: 'cutter-op', description: 'twin.tpl.test.class.cutter-op' }], assetClasses: [{ id: 'euro-pallet' }], operations: [{ key: CASES[2].id, ...op }], productionSpec: { companyPrefix: '0614141', binding: { steel: '077101' }, definition: { materials: [{ id: 'steel' }], routes: [{ key: 'r', steps: [CASES[2].id] }], recipes: [{ key: 'rc', label: '프레임', route: 'r', inputs: [{ material: 'steel', qty: 1 }], outputs: [{ material: 'steel', qty: 1 }] }] } } } } 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 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('감시할 컬럼이 비어 있지 않다 — 비면 이 하네스는 아무것도 지키지 않는다', () => { for (const c of CASES) { const cols = watchedColumns(c.entity) assert.ok(cols.length > 2, `${c.axis}: 감시 컬럼을 못 읽었다(${cols.length}개) — 메타데이터 읽기가 낡았다`) } }) for (const c of CASES) { test(`${c.axis}: 모델이 말한 사실이 행에 남는다 — 투영이 버리지 않는다`, async () => { const row: any = await getRepository(c.entity).findOne({ where: { domain: { id: domainId } as any, [c.idColumn]: c.id } as any }) assert.ok(row, `${c.axis} "${c.id}" 행이 없다 — 사실 하나가 아니라 개체가 사라졌다`) const lost = watchedColumns(c.entity).filter(col => row[col] === null || row[col] === undefined) assert.deepEqual( lost, [], `${c.axis}: 모델은 말했는데 행에 없는 사실 [${lost}] — 투영이 버렸다.\n` + '컬럼이 있다는 것은 "이 사실이 여기 앉는다" 는 선언이다 — 앉을 자리를 두고 안 채우면 영원히 빈다.' ) }) test(`${c.axis}: 행에 남은 사실이 화면까지 간다 — 조회가 감추지 않는다`, async () => { const it = await q.twinModelItem(INSTANCE, c.axis, c.id, ctx) assert.ok(it?.declared, `${c.axis} "${c.id}" 항목이 열리지 않았다`) assert.equal(it.provenance?.basis, 'rows', '행에서 답해야 이 검사가 그 구간을 지킨다') const hidden = watchedColumns(c.entity).filter(col => it.declared[col] === undefined) assert.deepEqual( hidden, [], `${c.axis}: 행에는 있는데 조회 응답에 없는 사실 [${hidden}] — 화면은 그것을 물을 방법이 없다.` ) }) } /* * ★ 어떤 속성이 **효과가 있나** — 열린 것과 닫힌 것을 화면이 구별할 수 있게. * * 속성은 열려 있다(아무 사실이나 실어 보낼 수 있다). 그런데 값이 계산을 바꾸려면 그 규칙을 코드가 * 알아야 하고, 그것은 닫혀 있다. 그 구분이 화면에 없으면 사용자는 속성을 넣고 나서 **아무 일도 안 * 일어나는 이유**를 알 수 없다. 그래서 서버가 효과 등록부를 함께 낸다. * * 이 검사가 지키는 규율: **읽는 코드가 있는 것만 등록한다.** 지어낸 효과를 올리면 화면이 거짓을 말한다. */ test('설비 항목이 효과 등록부를 함께 낸다 — 속도는 효과가 있고 나머지는 표시뿐이다', async () => { const it = await q.twinModelItem(INSTANCE, 'equipment', 'cutter-1', ctx) assert.ok(it?.propertyEffects, '등록부가 없으면 화면은 효과 있는 속성을 구별할 수 없다') assert.equal(it.propertyEffects.speed, 'twin.propeffect.speed', '속도는 주행 시간 계산이 읽는다') assert.equal('maxLoadKg' in it.propertyEffects, false, '읽는 코드가 없는 속성을 등록하면 화면이 거짓을 말한다') }) test('등록부의 모든 항목이 읽는 자리를 밝힌다 — 지어낸 효과를 막는다', () => { const { PROPERTY_EFFECTS } = req('../dist-server/engine/property-effects.js') assert.ok(PROPERTY_EFFECTS.length > 0, '등록부가 비면 아무것도 지키지 않는다') for (const e of PROPERTY_EFFECTS) { assert.ok(e.id && e.axes?.length, `${e.id}: 어느 축의 속성인지 밝혀야 한다`) assert.match(e.effect, /^twin\.propeffect\./, `${e.id}: 효과 문구는 i18n 키다(서버는 사람 말을 만들지 않는다)`) assert.ok(e.readBy && e.readBy.length > 10, `${e.id}: 실제로 읽는 자리를 적어야 한다 — 없으면 지어낸 효과다`) } }) test('등록되지 않은 속성은 등록부에 없다 — 화면이 "표시만 됩니다" 를 말할 근거가 된다', () => { const { propertyEffectsOf } = req('../dist-server/engine/property-effects.js') const eq = propertyEffectsOf('equipment') /* * 지금 트레일러 템플릿에는 효과 없는 속성이 선언돼 있지 않다(속도 하나뿐이다). 그래서 화면에서 * "표시만 됩니다" 를 눈으로 볼 수 없다 — 그 갈래를 여기서 지킨다: 등록부에 없으면 화면이 그렇게 말한다. */ assert.equal(eq.speed, 'twin.propeffect.speed') for (const unknown of ['maxLoadKg', 'skill', 'nominalSpeed']) { assert.equal(unknown in eq, false, `읽는 코드가 없는 "${unknown}" 이 등록되면 화면이 거짓을 말한다`) } /* 축이 다르면 효과도 다르다 — 품목 정의의 환산 계수를 설비에 말하지 않는다. */ assert.equal('perBaseUnit' in eq, false) assert.equal(propertyEffectsOf('materialDefinitions').perBaseUnit, 'twin.propeffect.perBaseUnit') })