/* * ★ 트윈이 첫날의 이름에 갇혀 있었다 — 바꿀 문이 없었다. * * 이름은 처음 세울 때 한 번 정해지고(`provision`) 다시 읽기는 건드리지 않는다. 그 자체는 안전하다 — * 사람이 붙인 이름을 연결이 덮어쓰면 안 된다. 그런데 그래서 첫날 이름에서 벗어날 길이 없었다. * * 실제로 그런 일이 났다(2026-08-25 · 태양광 발전소). 커넥터가 처음 아는 이름이 연결된 시스템의 * 코드뿐이었다. * * 현장 이름 호현에너지 * 트윈 이름 ES202307202202 ← 화면의 트윈 목록에 이렇게 보였다 * * 뒤에 사람이 부르는 이름이 어디 있는지 알아내 커넥터를 고쳤는데, 트윈 이름은 그대로였다. 남은 * 유일한 길이 트윈을 지우고 다시 세우는 것이었고, 그러면 그날 실제 발전소에서 받은 246건이 * 사라진다 — **이름 하나 때문에 사실을 버리는 선택**이다. * * 연결된 시스템을 따라가는 트윈에서는 이름이 나중에 좋아진다. 커넥터가 나아질 때마다 나는 일이다. * * 이 테스트는 소스를 전수 조회해 규율을 지킨다(실제 뮤테이션은 DB 와 도메인 맥락이 필요하다). */ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { join } from 'node:path' const FILE = join(import.meta.dirname, '..', 'server', 'service', 'twin-lifecycle', 'twin-lifecycle-mutation.ts') const src = readFileSync(FILE, 'utf8') const body = (() => { const at = src.indexOf('async renameTwinInstance') assert.ok(at > 0, '이름을 바꾸는 문이 있어야 한다') /* * **주석을 걷고 본다.** 이 조각은 다음 문(`deleteTwinInstance`)의 머리 주석까지 품는데, 그 주석은 * 삭제가 왜 `TwinEngine.remove` 를 부르는지 설명한다. 걷지 않으면 그 산문이 세어져, 이름 바꾸기가 * 런타임을 건드린다고 읽힌다 — 실제로 그렇게 빨개졌다. 세는 것은 코드지 그 옆의 글이 아니다. */ return src .slice(at, src.indexOf('async deleteTwinInstance')) .replace(/\/\*[\s\S]*?\*\//g, '') .replace(/\/\/[^\n]*/g, '') })() test('★ 이름을 바꾸는 문이 있다 — 없어서 지우고 다시 세우는 것이 유일한 길이었다', () => { assert.ok(/@Mutation/.test(src.slice(0, src.indexOf('async renameTwinInstance')))) assert.ok(/@Arg\('instanceId'\)/.test(body)) assert.ok(/@Arg\('name'\)/.test(body)) }) test('★ 빈 이름은 받지 않는다 — 지우는 것과 바꾸는 것은 다른 일이다', () => { assert.ok(/\.trim\(\)/.test(body), '앞뒤 공백은 이름이 아니다') assert.ok(/if \(!next\) throw/.test(body), '빈 값이면 거절한다') /* 빈 이름을 받아 두면 그 트윈은 목록에서 식별자만 남는데, 그것을 「바꿨다」로 보고하면 거짓이다. */ assert.ok(/left as it was/.test(body), '무엇도 바뀌지 않았다는 것을 말한다') }) test('★ 남의 도메인 트윈은 바꾸지 못한다 — 테넌트 격리', () => { assert.ok(/where: \{ domain: \{ id: domainId \}, instanceId \}/.test(body), '도메인으로 좁혀 찾는다') assert.ok(/unknown twin/.test(body), '없으면 이유를 말한다') }) test('바꾸지 않은 경우를 바꿨다고 말하지 않는다', () => { assert.ok(/changed: false/.test(body), '같은 이름이면 changed 를 false 로 낸다') assert.ok(/previous/.test(body), '무엇에서 무엇으로 바뀌었는지 함께 낸다') }) test('바꾼 사람을 남긴다 — 이름은 사람이 하는 선언이다', () => { assert.ok(/updater: context\.state\.user/.test(body)) }) test('이름만 바꾼다 — 트윈을 멈추거나 다시 읽지 않는다', () => { /* * 도는 런타임은 식별자로 동작하고 이름을 쓰지 않는다. 정지·기동을 끼우면 그 사이의 사실이 어디로 * 갔는지 설명해야 하는데, 이름 때문에 그 값을 치를 이유가 없다. */ assert.ok(!/TwinEngine\.(stop|start|remove|resync)/.test(body), '런타임을 건드리지 않는다') assert.ok(!/adoptStructure|reprovision/.test(body), '구조를 다시 읽지 않는다') }) test('연결된 시스템이 이름을 덮어쓰지는 않는다 — 다시 읽기는 그대로다', () => { /* * 「사람이 손대지 않았으면 따라간다」가 옳아 보이지만, 그러려면 「손댔다」는 표시를 새로 만들어야 * 한다. 이름 하나 때문에 지기에 무거운 빚이므로 사람이 바꾸는 길만 낸다. 그 결정을 여기서 고정한다. * * 이름을 정하는 자리는 **처음 세울 때 하나**다. 그 수가 늘면 다시 읽기가 이름을 다시 정하기 * 시작했다는 뜻이고, 그때는 이 결정을 다시 해야 한다. */ const engine = readFileSync(join(import.meta.dirname, '..', 'server', 'engine', 'twin-engine.ts'), 'utf8') const places = engine.match(/name: master\.siteName/g) ?? [] assert.equal(places.length, 1, `이름을 정하는 자리가 ${places.length}곳이다 — 처음 세울 때 하나여야 한다`) })