/* * ★ **접수 타입에 있는 칸은 매핑도 옮긴다** — 문턱에서 조용히 버려지던 자리. * * ── 오늘 이 부류가 일곱 번이었다 (2026-09-04) ────────────────────────────── * ActuationAck 이 세 칸을 버린다고 말한다 타입과 값 사이 * save 가 새 칸을 안 쓴다 칸과 쓰는 길 * 질의가 noteNext 를 안 낸다 저장과 화면 사이 * 커넥터가 등급 셋을 읽어 놓고 버린다 원본 쪽 * homeLocation vs homeLocationId 낱말 하나 * equipmentClassIds 칸이 접수면에 없다 ← 이 검사가 막는 것 * materialActual 안쪽을 안 본다 검사가 얕다 * * **일곱 다 오류가 안 나서** 검사로만 잡혔다. * * ── 이 자리가 특히 조용하다 ───────────────────────────────────────────────── * 접수 타입(`RefEquipment` …)에 칸이 있어도 매핑 함수가 안 옮기면 **원본이 채운 값이 문턱에서 * 사라진다.** 타입만 봐서는 안 보인다 — 타입은 「받는다」고 말하고 있으니까. * * 실제로 그렇게 났다: 설비 22대에 등급을 다 채웠는데 등급 목록만 서고 소속은 아무도 없었다. * 화면에서 등급을 골라도 설비가 안 나왔고, 거절도 경고도 없었다. * * ── 왜 원문을 읽나 ────────────────────────────────────────────────────────── * 매핑은 객체 리터럴이라 런타임에 「무엇을 옮기나」를 물을 수 없다(입력을 다 채워 넣어 답을 보는 * 방법이 있지만, 그러면 선택 필드마다 픽스처를 손으로 적어야 하고 그 손이 바로 빠뜨리는 자리다). * 원문에서 읽으면 **이름을 손으로 적지 않아** 타입이 늘 때 검사가 함께 따라간다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' const SRC = readFileSync(new URL('../server/service/reference/reference-master.ts', import.meta.url), 'utf8') .replace(/\/\*[\s\S]*?\*\//g, '') .replace(/\/\/[^\n]*/g, '') /** 접수 타입 하나의 칸 이름들 — `export interface X { … }` 의 깊이 1 필드. */ function intakeFields(name: string): string[] { const start = SRC.indexOf(`export interface ${name} {`) assert.ok(start >= 0, `접수 타입을 못 찾았다: ${name} — 이 검사가 따라가야 한다`) const body = SRC.slice(SRC.indexOf('{', start)) const end = body.indexOf('\n}') assert.ok(end > 0, `${name} 의 끝을 못 찾았다`) return [...new Set([...body.slice(0, end).matchAll(/^\s{2}([a-zA-Z][\w]*)\??:/gm)].map(m => m[1]))] } /** * 매핑 함수가 **읽는** 칸들 — `e.foo` · `a.foo` 처럼 원본 줄에서 꺼내는 이름. * * 옮긴 뒤 이름이 바뀌는 것도 있다(`homeLocationId → homeLocation`). 그래서 「나가는 이름」이 아니라 * **「읽는 이름」**을 센다 — 읽지 않으면 옮길 수도 없다. */ function readFields(varName: string): string[] { return [...new Set([...SRC.matchAll(new RegExp(`\\b${varName}\\.([a-zA-Z][\\w]*)`, 'g'))].map(m => m[1]))] } /** * 일부러 안 옮기는 칸 — 이유와 함께. * * 비어 있는 것이 정상이 아니다: 접수면이 받아 두고 커널에 안 넘기는 값이 있으면 **왜 받는지**가 * 적혀 있어야 한다. 안 적으면 다음 사람이 「원래 그런가 보다」로 넘긴다. */ const NOT_MAPPED: Record = { RefEquipment: { fields: [], why: '' }, RefAsset: { fields: [], why: '' }, RefPerson: { fields: [], why: '' } } const CASES = [ { intake: 'RefEquipment', reads: 'e' }, { intake: 'RefAsset', reads: 'a' }, { intake: 'RefPerson', reads: 'p' } ] as const test('★ 접수 타입의 칸을 매핑이 다 읽는다 — 안 읽으면 원본이 채운 값이 문턱에서 사라진다', () => { const dropped: string[] = [] for (const c of CASES) { const excused = new Set(NOT_MAPPED[c.intake]?.fields ?? []) const read = new Set(readFields(c.reads)) for (const f of intakeFields(c.intake)) { if (!read.has(f) && !excused.has(f)) dropped.push(`${c.intake}.${f}`) } } assert.deepEqual( dropped, [], '접수 타입은 받는다고 말하는데 매핑이 안 읽는다. 원본이 채운 값이 조용히 사라진다:\n ' + dropped.join('\n ') + '\n매핑에 넣거나, 일부러 안 넘긴다면 NOT_MAPPED 에 이유를 적는다.' ) }) test('★ 등급 소속 셋이 다 접수면을 지난다 — 이름으로 못 박는다', () => { /* * 위 검사는 목록 대조라 예외 표에 올리면 통과한다. 이 셋은 **소속이 없으면 등급으로 부르는 * 요구가 채워지지 않는** 자리여서 예외가 될 수 없다. */ for (const [type, field, reads] of [ ['RefEquipment', 'equipmentClassIds', 'e'], ['RefAsset', 'assetClassIds', 'a'], ['RefPerson', 'personnelClassIds', 'p'] ] as const) { assert.ok(intakeFields(type).includes(field), `${type} 에 ${field} 칸이 없다`) assert.ok(readFields(reads).includes(field), `매핑이 ${type}.${field} 를 안 읽는다 — 소속이 문턱에서 사라진다`) } }) test('★ 사람도 정체성 자리를 갖는다 — 그리고 그 자리는 개인정보가 아니다', () => { /* * ── 왜 열었나 (2026-09-05, 인티그레이션 레인이 물음) ──────────────────────── * 설비에서 이 자리를 만든 이유가 사람에게도 그대로 걸린다 — 지역 이름은 그 현장 안에서만 * 고유하다. `E-1001` 이 다른 공장에도 있으면 여러 현장을 함께 보는 순간 한쪽이 사라진다. * * ── 왜 이 검사에 규약을 함께 적나 ────────────────────────────────────────── * 설비와 사람이 갈리는 자리가 있다. 사람의 「전역에서 고유한 값」은 사원번호·카드번호처럼 * **그 사람에 관한 사실**이고, 저널은 되풀이 읽히고 미러로 복제되고 구독으로 방송된다 — * 한 번 들어가면 지워지지 않는다. * * 이 칸이 요구하는 것은 **현장을 앞에 붙여 전역에서 고유해진 이름**이다(`objectUri` 가 만든다). * 그 규약이 주석에만 있으면 다음 커넥터가 카드번호를 넣는다 — 그러니 소스에 그 문장이 실제로 * 있는지를 검사가 지킨다. */ assert.ok(intakeFields('RefPerson').includes('identity'), 'RefPerson 에 identity 칸이 없다') assert.ok(readFields('p').includes('identity'), '매핑이 사람의 identity 를 안 읽는다') /* 규약이 글로 남아 있나 — 지우면 이 줄이 빨개진다. */ const raw = readFileSync(new URL('../server/service/reference/reference-master.ts', import.meta.url), 'utf8') const start = raw.indexOf('export interface RefPerson') const personDoc = raw.slice(start, raw.indexOf('\n}', start)) assert.ok(personDoc.includes('개인정보'), '개인정보를 넣지 않는다는 규약이 사라졌다') assert.ok(personDoc.includes('objectUri'), '무엇을 넣어야 하는지(이름공간 아래 식별자)가 사라졌다') }) test('★ 정체성 낱말이 세 자리에서 같다 — 마스터가 내는 이름과 접수면이 받는 이름', () => { /* * 설비는 `identity` 로 바꿨는데 자산은 `gs1Id` 로 남아 있었다. 마스터는 둘 다 `identity` 로 * 내므로 자산 쪽은 **원본이 채우는 순간 조용히 사라졌다** — 값이 다 비어 있어 아직 잃은 것은 * 없었다(인티그레이션 레인 실측). * * 자리(`RefLocation`)는 `gs1Id` 가 맞다 — 거기는 실제로 SGLN 이 온다. */ assert.ok(intakeFields('RefEquipment').includes('identity'), '설비 정체성 이름이 바뀌었다') assert.ok(intakeFields('RefAsset').includes('identity'), '자산이 아직 마스터와 다른 이름을 쓴다') assert.ok(!intakeFields('RefAsset').includes('gs1Id'), '자산에 옛 이름이 남아 있다') assert.ok(intakeFields('RefLocation').includes('gs1Id'), '자리는 SGLN 이 오므로 그 이름이 맞다') }) test('★ 이 검사가 실제로 무언가를 잰다 — 견준 것이 0이면 빨강', () => { /* * 정규식이 모양을 못 따라가면 목록이 비고, 그러면 위 검사가 **초록인데 아무것도 안 재는 상태**가 * 된다. 오늘 이 저장소에서 그 부류를 여러 번 잡았다. */ for (const c of CASES) { const fields = intakeFields(c.intake) const read = readFields(c.reads) assert.ok(fields.length > 5, `${c.intake} 칸을 ${fields.length}개만 읽었다`) assert.ok(read.length > 5, `${c.reads}.* 를 ${read.length}개만 읽었다`) } /* 이유 없는 예외는 예외가 아니다 — 비어 있는 항목은 예외가 아니라 자리표시다. */ for (const [type, e] of Object.entries(NOT_MAPPED)) { if (!e.fields.length) continue assert.ok(e.why?.trim(), `${type} 의 예외에 이유가 없다`) } })