/* * 공간 ↔ 트윈 ↔ 가시화 정합성 — **순수**. 세 축이 서로 어긋난 자리를 찾는다. * * ── 왜 있나 (2026-08-13) ──────────────────────────────────────────────────── * 이 셋은 서로를 id 로만 가리킨다. 참조 무결성을 DB 가 지켜 주지 않는 지점이 많다: * 공간·구역은 공간에 매이고, 로케이션·설비는 트윈에 매이고, 폴리곤은 **양쪽을 가리킨다.** * 그래서 트윈을 옮기거나 떼거나(2026-08-13 추가) 원본을 다시 읽으면, 아무 오류 없이 **가리킬 것이 * 없는 도형**과 **소속 없는 로케이션**이 남는다. 증상은 "지도가 비었다" 뿐이고 원인은 화면에 없다. * * 그 어긋남을 사람이 눈으로 맞추고 있었다(spaceId 를 목록에서 대조). 기계가 대신 본다. * * ── 무엇을 사실로 보나 ───────────────────────────────────────────────────── * 판정은 **조회 결과만** 받는다(DB 를 물지 않는다). 그래서 단위테스트로 전부 부를 수 있고, 호출측은 * 사실을 모으는 일만 한다. 각 발견은 언어 중립 코드 + 파라미터이고, 화면이 사람 말을 만든다. * * ── 심각도를 가른다 ──────────────────────────────────────────────────────── * `broken` 은 무언가 이미 보이지 않는 상태다(가리킬 대상이 없다). `warn` 은 아직 보이지만 뜻이 * 흐려진 상태다(소속 없음·대표 없음). 섞으면 사람이 무엇을 먼저 볼지 정할 수 없다. */ export type SpaceIntegrityCode = /** 트윈이 없는 공간을 가리킨다 — 그 트윈은 무대가 사라졌다. */ | 'twin-space-missing' /** 레지스트리와 model 의 공간이 다르다 — 어느 쪽이 사실인지 알 수 없다. */ | 'twin-space-mismatch' /** 폴리곤이 이 공간에 없는 구역을 가리킨다. */ | 'polygon-area-missing' /** 폴리곤이 이 공간에 사는 어느 트윈에도 없는 로케이션·설비를 가리킨다. */ | 'polygon-target-missing' /** 로케이션이 이 공간에 없는 구역에 소속되어 있다. */ | 'location-area-missing' /** 대표 표현이 이 공간의 표현이 아니다. */ | 'primary-representation-missing' /** 보드 표현이 보드를 가리키지 않는다(또는 없는 보드를 가리킨다). */ | 'board-representation-unbound' /** 표현이 하나도 없는 공간에 트윈이 산다 — 볼 화면이 없다. */ | 'space-without-representation' /** 어느 공간에도 속하지 않은 트윈 — 뗀 결과일 수 있다(사실이면 정상). */ | 'twin-without-space' /** * 이 트윈이 **담을 줄 모르는 어휘**를 받고 있다 — 리비전은 오르는데 상태가 채워지지 않는다. * * 커널은 모르는 `eventType` 을 버리고(전방 호환) 종류별로 센다(`state.unhandled`). 그 수가 0이 * 아니면 둘 중 하나다: 커넥터가 엉뚱한 트윈에 붙었거나(예: `energy.*` 가 창고 트윈으로), 커널이 * 낡아 새 어휘를 모른다. 둘 다 사람이 손봐야 하는 것이고, 알리지 않고 두면 「정상인데 빈」 트윈이 된다. */ | 'twin-unhandled-vocabulary' export type SpaceIntegritySeverity = 'broken' | 'warn' export interface SpaceIntegrityFinding { code: SpaceIntegrityCode severity: SpaceIntegritySeverity /** 어디의 문제인가 — 화면이 그 자리로 데려갈 수 있게. */ spaceId?: string instanceId?: string representationId?: string params?: Record /** 영어 canonical — 로그·폴백. */ message: string } /* ── 입력(조회 결과) ──────────────────────────────────────────────────────── */ export interface IntegritySpace { spaceId: string primaryRepresentationId?: string | null } export interface IntegrityInstance { instanceId: string /** 레지스트리 컬럼. */ spaceId?: string | null /** 저장된 model 의 공간 — 레지스트리와 같아야 한다. */ modelSpaceId?: string | null purpose?: string | null /** 이 트윈의 로케이션 id 들. */ locationIds?: readonly string[] /** 이 트윈의 설비 id 들. */ equipmentIds?: readonly string[] /** 로케이션의 소속 구역 id 들(중복 가능). */ areaRefs?: readonly (string | undefined | null)[] /** * 커널이 담을 줄 몰라 버린 사건들 — 실행 중인 트윈의 상태에서 온다(`state.unhandled`). * * 정지한 트윈에서는 알 수 없으므로 `undefined` 다 — **없는 것과 모르는 것을 구별한다**(빈 배열을 * 넣으면 「받은 것이 없다」는 사실 주장이 되고, 그것은 우리가 확인한 바가 아니다). */ unhandled?: readonly { eventType: string; count: number }[] } export interface IntegrityRepresentation { id: string spaceId: string type: string /** 보드 표현이면 보드 id. */ boardId?: string | null /** 폴리곤 바인딩들. */ polygons?: readonly IntegrityPolygon[] } export interface IntegrityPolygon { id?: string /** 논리 구역 바인딩(그룹). */ groupId?: string | null /** 개체 바인딩 — 로케이션 또는 설비. */ targetKind?: 'location' | 'equipment' | null targetId?: string | null } export interface SpaceIntegrityInput { spaces: readonly IntegritySpace[] instances: readonly IntegrityInstance[] representations: readonly IntegrityRepresentation[] /** 공간별 구역 id 들. */ areasBySpace: Readonly> /** 존재하는 보드 id 들 — 보드 표현이 실제 보드를 가리키는지 본다. */ boardIds?: readonly string[] } /** 세 축을 맞대어 어긋난 자리를 모두 낸다. 비어 있으면 정합하다. */ export function checkSpaceIntegrity(input: SpaceIntegrityInput): SpaceIntegrityFinding[] { const out: SpaceIntegrityFinding[] = [] const spaceIds = new Set(input.spaces.map(s => s.spaceId)) const areasOf = (spaceId: string) => new Set(input.areasBySpace[spaceId] ?? []) const repsOf = (spaceId: string) => input.representations.filter(r => r.spaceId === spaceId) const boards = new Set(input.boardIds ?? []) /* 벤치 사본은 측정용 사본이라 이 검사의 대상이 아니다 — 사본의 공간은 잠깐 살고 지워진다. */ const instances = input.instances.filter(i => i.purpose !== 'bench') /* ── 트윈 → 공간 ─────────────────────────────────────────────────────── */ for (const t of instances) { const sid = (t.spaceId ?? '').trim() const msid = (t.modelSpaceId ?? '').trim() if (!sid) { out.push({ code: 'twin-without-space', severity: 'warn', instanceId: t.instanceId, message: `twin "${t.instanceId}" is on no site — it has no map or board to be drawn on` }) } else if (!spaceIds.has(sid)) { out.push({ code: 'twin-space-missing', severity: 'broken', instanceId: t.instanceId, spaceId: sid, message: `twin "${t.instanceId}" points at site "${sid}" which does not exist` }) } /* 두 곳이 어긋나면 어느 쪽이 사실인지 알 수 없다 — 보드는 model 을, 목록은 컬럼을 읽는다. */ if (sid !== msid) { out.push({ code: 'twin-space-mismatch', severity: 'broken', instanceId: t.instanceId, spaceId: sid || undefined, params: { registry: sid || '—', model: msid || '—' }, message: `twin "${t.instanceId}" registry site "${sid || 'none'}" differs from its model site "${msid || 'none'}"` }) } /* 로케이션의 소속 구역은 **트윈이 속한 공간**의 것이어야 한다. */ if (sid && spaceIds.has(sid)) { const have = areasOf(sid) const refs = [...new Set((t.areaRefs ?? []).filter((x): x is string => !!x && !!x.trim()))] const missing = refs.filter(r => !have.has(r)).sort() if (missing.length) { out.push({ code: 'location-area-missing', severity: 'warn', instanceId: t.instanceId, spaceId: sid, params: { count: missing.length, areas: missing.slice(0, 5).join(', ') }, message: `${missing.length} area(s) referenced by locations of "${t.instanceId}" do not exist in site "${sid}": ${missing .slice(0, 5) .join(', ')}` }) } } /* * 어휘가 맞는가 — 저널이 들어오는데 상태가 채워지지 않는 경우를 잡는다. * * `undefined` 는 「모른다」다(정지한 트윈). 세지 않는다 — 확인하지 않은 것을 정합으로도, 결함으로도 * 세지 않는 것이 이 하네스의 규율이다. */ if (t.unhandled?.length) { const total = t.unhandled.reduce((n, u) => n + u.count, 0) const kinds = [...t.unhandled].sort((a, b) => b.count - a.count) out.push({ code: 'twin-unhandled-vocabulary', severity: 'warn', instanceId: t.instanceId, spaceId: sid || undefined, params: { events: total, kinds: kinds.length, types: kinds.slice(0, 3).map(u => u.eventType).join(', ') }, message: `twin "${t.instanceId}" received ${total} event(s) of ${kinds.length} type(s) its kernel does not handle: ${kinds .slice(0, 3) .map(u => u.eventType) .join(', ')}` }) } } /* ── 공간 → 가시화 ───────────────────────────────────────────────────── */ for (const s of input.spaces) { const reps = repsOf(s.spaceId) const livesHere = instances.filter(t => (t.spaceId ?? '') === s.spaceId) /* 트윈이 사는데 표현이 없으면 볼 화면이 없다. 트윈이 없는 빈 현장은 정상이므로 세지 않는다. */ if (livesHere.length && !reps.length) { out.push({ code: 'space-without-representation', severity: 'warn', spaceId: s.spaceId, params: { twins: livesHere.length }, message: `site "${s.spaceId}" has ${livesHere.length} twin(s) but no representation — there is nothing to draw them on` }) } const primary = (s.primaryRepresentationId ?? '').trim() if (primary && !reps.some(r => r.id === primary)) { out.push({ code: 'primary-representation-missing', severity: 'broken', spaceId: s.spaceId, params: { representationId: primary }, message: `site "${s.spaceId}" points at primary representation "${primary}" which is not one of its representations` }) } } /* ── 가시화 → 공간·트윈 ─────────────────────────────────────────────── */ for (const rep of input.representations) { const have = areasOf(rep.spaceId) /* 이 공간에 사는 트윈들의 개체 id — 폴리곤이 가리킬 수 있는 전부. */ const here = instances.filter(t => (t.spaceId ?? '') === rep.spaceId) const locationIds = new Set(here.flatMap(t => [...(t.locationIds ?? [])])) const equipmentIds = new Set(here.flatMap(t => [...(t.equipmentIds ?? [])])) if (rep.type === 'board' && !(rep.boardId && boards.has(rep.boardId))) { out.push({ code: 'board-representation-unbound', severity: 'broken', spaceId: rep.spaceId, representationId: rep.id, params: { boardId: rep.boardId ?? '—' }, message: `board representation "${rep.id}" of site "${rep.spaceId}" does not point at an existing board (${rep.boardId ?? 'none'})` }) } for (const p of rep.polygons ?? []) { const gid = (p.groupId ?? '').trim() if (gid && !have.has(gid)) { out.push({ code: 'polygon-area-missing', severity: 'broken', spaceId: rep.spaceId, representationId: rep.id, params: { areaId: gid, polygonId: p.id ?? '—' }, message: `a polygon of representation "${rep.id}" binds area "${gid}" which does not exist in site "${rep.spaceId}"` }) } const tid = (p.targetId ?? '').trim() if (tid && p.targetKind) { const pool = p.targetKind === 'equipment' ? equipmentIds : locationIds if (!pool.has(tid)) { out.push({ code: 'polygon-target-missing', severity: 'broken', spaceId: rep.spaceId, representationId: rep.id, params: { kind: p.targetKind, targetId: tid, polygonId: p.id ?? '—' }, message: `a polygon of representation "${rep.id}" binds ${p.targetKind} "${tid}", which no twin on site "${rep.spaceId}" has` }) } } } } return out } /** 심각한 것부터 — 화면이 정렬을 다시 정하지 않게 한 곳에서 정한다. */ export function sortFindings(findings: readonly SpaceIntegrityFinding[]): SpaceIntegrityFinding[] { const rank = (s: SpaceIntegritySeverity) => (s === 'broken' ? 0 : 1) return [...findings].sort( (a, b) => rank(a.severity) - rank(b.severity) || a.code.localeCompare(b.code) || (a.spaceId ?? '').localeCompare(b.spaceId ?? '') ) }