/* * ★ **밖을 가리키는 관계를 푸는 자리** — 선언은 있고 코드가 0곳이던 이음새. * * ── 무엇이 있었나 (2026-09-05 실측) ──────────────────────────────────────── * 계약이 `target: { kind: 'external', entity: … }` 를 선언하고 주석에 「해소는 호스트가 한다」고 * 적어 두었다. 그런데 `headless-twin/server` 어디에도 그것을 다루는 코드가 없었다(0곳). * * `space.area` 가 도는 것처럼 보인 이유는 이음새가 있어서가 아니라 **호스트에서 그것이 평범한 FK * 라서**다 — 선언이 길을 낸 것이 아니다. * * ── 왜 지금 채웠나 ───────────────────────────────────────────────────────── * 아침에는 「아무도 안 막혔으니 급하지 않다」로 적었고 **그 판단이 규모를 작게 잡은 것**이었다. * * 360 의 소비처는 무한히 늘어난다(사용자 방향). 그러면 각 제품의 자기 저장소가 관계의 원천이 된다. * 그때 선언은 「밖을 가리킨다」고 말하는데 푸는 길이 없으면 **제품마다 자기 길을 낸다** — 그것이 * 정확히 「무한히 갈린다」다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { clearExternalResolvers, externalIncoming, externalResolverEntities, registerExternalResolver, resolveExternal } from '../server/service/twin-model/external-resolver.js' const AREA = 'space.area' /* * 어느 트윈의 관계인지 — `resolve` 는 이것을 함께 받는다(2026-09-18). 밖의 id 는 도메인에서 유일하지 * 않아서(구역 id 는 공간 안에서만 유일), 문맥 없이 답하면 남의 공간 이름이 이 자리의 부모로 보인다. */ const CTX = { instanceId: 'probe-twin' } /* ── 못 풀면 못 푼다고 말한다 ────────────────────────────────────────────── */ test('★ 꽂힌 것이 없으면 「해소기가 없다」고 말한다 — 「없다」가 아니다', async () => { /* * 링크를 지우면 화면은 「그런 관계가 없다」로 읽는다. 실제로는 「아무도 답할 수 없다」다. * 사람이 할 일이 다르다 — 앞은 자료를 채울 일이고 뒤는 해소기를 꽂을 일이다. */ clearExternalResolvers() const got = await resolveExternal(AREA, 'd1', ['LINE-CAM'], CTX) assert.equal(got.unresolved, 'no-resolver') assert.equal(got.refs, undefined) }) test('★ 해소기가 터져도 360 을 죽이지 않는다 — 알리고 못 풀었다고 한다', async () => { clearExternalResolvers() const said: string[] = [] registerExternalResolver({ entity: AREA, resolve: async () => { throw new Error('DB down') } }) const got = await resolveExternal(AREA, 'd1', ['X'], CTX, m => said.push(m)) assert.equal(got.unresolved, 'failed') assert.ok(said.some(m => m.includes('DB down')), '사유를 삼켰다') }) /* ── 풀면 무엇인지 말한다 ────────────────────────────────────────────────── */ test('★ 이름과 갈 곳을 낸다 — 막다른 링크를 만들지 않으려고', async () => { clearExternalResolvers() registerExternalResolver({ entity: AREA, resolve: async (_d, ids) => ids.map(id => ({ id, name: `라인 ${id}`, href: `/areas/${id}` })) }) const got = await resolveExternal(AREA, 'd1', ['LINE-CAM'], CTX) assert.equal(got.refs?.[0].name, '라인 LINE-CAM') assert.equal(got.refs?.[0].href, '/areas/LINE-CAM') }) test('★ 없는 id 는 없다고 말한다 — 답에서 빼지 않는다', async () => { /* 빼면 화면은 그 참조가 원래 없었던 것으로 읽는다. 실제로는 가리키는데 대상이 없는 것이다. */ clearExternalResolvers() registerExternalResolver({ entity: AREA, resolve: async (_d, ids) => ids.map(id => ({ id, missing: true })) }) const got = await resolveExternal(AREA, 'd1', ['사라진구역'], CTX) assert.equal(got.refs?.length, 1) assert.equal(got.refs?.[0].missing, true) }) /* ── 들어오는 쪽 ─────────────────────────────────────────────────────────── */ test('★ 제품이 자기 저장소의 관계를 싣는다 — 커널은 그 표를 모른다', async () => { clearExternalResolvers() registerExternalResolver({ entity: 'mes.records', incoming: async () => [ { via: 'mes.rel.report', from: 'productionReports', tense: 'past', ids: ['PR-1', 'PR-2'], total: 2 }, { via: 'mes.rel.maintenance', from: 'maintenancePlans', tense: 'future', ids: ['MP-1'], total: 1 } ] }) const got = await externalIncoming('d1', 'mes-miratek', 'equipment', 'EQ-CT-MWELD-01') assert.equal(got.length, 2) assert.deepEqual(got.map(g => g.tense).sort(), ['future', 'past'], '시간축이 안 실렸다') }) test('★ 시간축이 실린다 — 「걸린 것이 열」이 두 뜻이 되는 자리', async () => { /* * 지난 일 아홉 · 앞으로 올 일 하나와 그 반대는 「이 설비를 세워도 되나」의 답이 정반대다. * 묶음이 스스로 말해야 화면이 가를 수 있다. */ clearExternalResolvers() registerExternalResolver({ entity: 'mes.records', incoming: async () => [{ via: 'mes.rel.report', from: 'x', tense: 'past', ids: ['a'], total: 9 }] }) const got = await externalIncoming('d1', 'i', 'equipment', 'E-1') assert.equal(got[0].tense, 'past') assert.equal(got[0].total, 9, '전체 수를 안 말한다 — 잘린 목록만으로는 규모를 모른다') }) test('빈 묶음은 싣지 않는다 — 그 제품이 이 축을 안 보는 것이고 할 말이 없다', async () => { clearExternalResolvers() registerExternalResolver({ entity: 'mes.records', incoming: async () => [{ via: 'x', from: 'y', ids: [], total: 0 }] }) assert.deepEqual(await externalIncoming('d1', 'i', 'equipment', 'E-1'), []) }) test('★ 한 제품이 터져도 나머지는 실린다', async () => { clearExternalResolvers() const said: string[] = [] registerExternalResolver({ entity: 'broken', incoming: async () => { throw new Error('boom') } }) registerExternalResolver({ entity: 'ok', incoming: async () => [{ via: 'v', from: 'f', ids: ['a'], total: 1 }] }) const got = await externalIncoming('d1', 'i', 'equipment', 'E-1', m => said.push(m)) assert.equal(got.length, 1, '하나가 터져서 나머지도 안 실렸다') assert.ok(said.some(m => m.includes('boom'))) }) /* ── 꽂는 규율 ───────────────────────────────────────────────────────────── */ test('★ 같은 대상을 두 제품이 답하려 하면 알린다 — 조용히 덮지 않는다', async () => { clearExternalResolvers() const said: string[] = [] registerExternalResolver({ entity: AREA, resolve: async () => [] }) registerExternalResolver({ entity: AREA, resolve: async () => [] }, m => said.push(m)) assert.ok(said.some(m => m.includes(AREA)), '덮어쓴 것을 안 알렸다') }) test('entity 없는 해소기는 안 꽂는다 — 아무 관계에도 안 붙는다', async () => { clearExternalResolvers() const said: string[] = [] registerExternalResolver({ entity: ' ' } as any, m => said.push(m)) assert.deepEqual(externalResolverEntities(), []) assert.ok(said.length, '조용히 넘겼다') }) test('★ 무엇을 풀 수 있나를 밖에서 잴 수 있다 — 「모른다」와 「없다」를 가르려고', async () => { clearExternalResolvers() registerExternalResolver({ entity: 'b', resolve: async () => [] }) registerExternalResolver({ entity: 'a', resolve: async () => [] }) assert.deepEqual(externalResolverEntities(), ['a', 'b']) }) test('★ 이 검사가 실제로 무언가를 잰다 — 견준 것이 0이면 빨강', async () => { clearExternalResolvers() assert.deepEqual(externalResolverEntities(), [], '지우기가 안 듣는다') registerExternalResolver({ entity: AREA, resolve: async (_d, ids) => ids.map(id => ({ id })) }) const got = await resolveExternal(AREA, 'd', ['X'], CTX) assert.equal(got.refs?.length, 1, '꽂았는데 안 불린다') })