/* * 기존 트윈을 다른 현장으로 가져오기 — 판정이 잃을 것을 말하는지 본다. * * 엔진 본체는 DB 를 물고 있어 단위테스트로 부를 수 없다. 그래서 **결정만** 순수 모듈로 떼어 놓고 * 여기서 본다(엔진은 사실을 조회해 넘기는 일만 한다 — `ingest-space` 와 같은 구조). */ import { test } from 'node:test' import assert from 'node:assert/strict' import { judgeMoveTwinSpace, judgeDetachTwinSpace } from '../server/service/twin-space/move-space.ts' /** 잃을 것이 없는 가장 단순한 이전 — 같은 시간대, 구역 참조 없음, 옛 표현 없음. */ const CLEAN = { instanceId: 'wms-a', fromSpaceId: 'wms-a-site', toSpaceId: 'rosarito-site', toSpaceExists: true, fromTimezone: 'America/Los_Angeles', toTimezone: 'America/Los_Angeles' } test('잃을 것이 없으면 경고도 없다 — 정상 경로를 시끄럽게 만들지 않는다', () => { const j = judgeMoveTwinSpace(CLEAN) assert.equal(j.refusal, undefined) assert.deepEqual(j.warnings, []) }) test('없는 현장으로는 옮기지 않는다 — 알리지 않고 새 공간을 만들지 않는다', () => { const j = judgeMoveTwinSpace({ ...CLEAN, toSpaceExists: false }) assert.equal(j.refusal?.code, 'space-not-found') assert.equal(j.refusal?.params?.spaceId, 'rosarito-site') }) test('같은 현장으로 옮기는 것은 거절한다 — 할 일이 없는데 쓴 것처럼 답하지 않는다', () => { const j = judgeMoveTwinSpace({ ...CLEAN, toSpaceId: 'wms-a-site' }) assert.equal(j.refusal?.code, 'same-space') }) test('없는 트윈은 거절한다', () => { const j = judgeMoveTwinSpace({ ...CLEAN, instanceId: undefined }) assert.equal(j.refusal?.code, 'twin-not-found') }) test('벤치 사본은 옮기지 않는다 — 무엇의 측정인지 말할 수 없게 된다', () => { const j = judgeMoveTwinSpace({ ...CLEAN, purpose: 'bench' }) assert.equal(j.refusal?.code, 'bench-copy') }) test('현장이 없던 트윈도 옮길 수 있다 — 옛 트윈을 고칠 길을 막지 않는다', () => { const j = judgeMoveTwinSpace({ ...CLEAN, fromSpaceId: undefined, fromTimezone: undefined, toTimezone: undefined }) assert.equal(j.refusal, undefined) }) test('새 공간에 없는 구역을 가리키는 로케이션은 소속이 끊긴다고 말한다', () => { const j = judgeMoveTwinSpace({ ...CLEAN, areaRefs: ['bldg-1', 'floor-2', undefined, 'bldg-1', ''], toAreaIds: ['floor-2'] }) const w = j.warnings.find(x => x.code === 'areas-left-behind') assert.ok(w, '끊기는 소속을 말해야 한다') assert.equal(w.params?.count, 1, '중복·빈 값은 세지 않는다') assert.equal(w.params?.areas, 'bldg-1') assert.equal(j.refusal, undefined, '막지는 않는다 — 새 공간에서 구역을 다시 그리는 것이 정상이다') }) test('구역이 모두 새 공간에 있으면 그 경고는 없다', () => { const j = judgeMoveTwinSpace({ ...CLEAN, areaRefs: ['bldg-1'], toAreaIds: ['bldg-1', 'floor-2'] }) assert.equal(j.warnings.find(x => x.code === 'areas-left-behind'), undefined) }) test('시간대가 다르면 말한다 — 같은 저널이 다른 시각으로 읽힌다', () => { const j = judgeMoveTwinSpace({ ...CLEAN, fromTimezone: 'Asia/Seoul', toTimezone: 'America/Los_Angeles' }) const w = j.warnings.find(x => x.code === 'timezone-differs') assert.ok(w) assert.equal(w.params?.from, 'Asia/Seoul') assert.equal(w.params?.to, 'America/Los_Angeles') }) test('시간대를 선언하지 않은 공간도 다름으로 센다 — 모르는 것을 같다고 하지 않는다', () => { const j = judgeMoveTwinSpace({ ...CLEAN, fromTimezone: 'Asia/Seoul', toTimezone: undefined }) assert.ok(j.warnings.find(x => x.code === 'timezone-differs')) }) test('옛 공간의 표현은 남는다고 말한다 — 그 화면에서 이 트윈이 사라진다', () => { const j = judgeMoveTwinSpace({ ...CLEAN, fromRepresentationCount: 3 }) const w = j.warnings.find(x => x.code === 'representations-left-behind') assert.ok(w) assert.equal(w.params?.count, 3) assert.equal(w.params?.spaceId, 'wms-a-site') }) test('실행 중 + 시간대 변화면 재기동 뒤에 적용된다고 말한다', () => { const j = judgeMoveTwinSpace({ ...CLEAN, running: true, fromTimezone: 'Asia/Seoul', toTimezone: 'UTC' }) assert.ok(j.warnings.find(x => x.code === 'running')) }) test('실행 중이지만 시간대가 같으면 말할 것이 없다 — 없는 걱정을 만들지 않는다', () => { const j = judgeMoveTwinSpace({ ...CLEAN, running: true }) assert.equal(j.warnings.find(x => x.code === 'running'), undefined) }) test('거절이면 경고를 함께 내지 않는다 — 일어나지 않을 일을 설명하지 않는다', () => { const j = judgeMoveTwinSpace({ ...CLEAN, toSpaceExists: false, areaRefs: ['bldg-1'], toAreaIds: [], fromRepresentationCount: 2, fromTimezone: 'Asia/Seoul', toTimezone: 'UTC' }) assert.ok(j.refusal) assert.deepEqual(j.warnings, []) }) /* ── 현장에서 떼기 ─────────────────────────────────────────────────────────── */ test('현장이 없는 트윈은 뗄 것이 없다 — 아무 일도 없는데 했다고 답하지 않는다', () => { const j = judgeDetachTwinSpace({ instanceId: 'wms-a', fromSpaceId: undefined }) assert.equal(j.refusal?.code, 'no-space') }) test('벤치 사본은 떼지 않는다 — 무엇의 측정인지 말할 수 없게 된다', () => { const j = judgeDetachTwinSpace({ instanceId: 'bench-1', fromSpaceId: 'rosarito-site', purpose: 'bench' }) assert.equal(j.refusal?.code, 'bench-copy') }) test('떼면 볼 무대가 없어진다고 반드시 말한다 — 사용자가 고장으로 읽지 않게', () => { const j = judgeDetachTwinSpace({ instanceId: 'wms-a', fromSpaceId: 'rosarito-site' }) assert.equal(j.refusal, undefined) assert.ok(j.warnings.find(w => w.code === 'no-stage'), '무대가 없어진다는 말은 언제나 있다') }) test('옛 현장의 표현과 시간 기준을 말한다 — 남는 것을 밝힌다', () => { const j = judgeDetachTwinSpace({ instanceId: 'wms-a', fromSpaceId: 'rosarito-site', fromRepresentationCount: 2, fromTimezone: 'America/Los_Angeles' }) assert.equal(j.warnings.find(w => w.code === 'representations-left-behind')?.params?.count, 2) assert.equal(j.warnings.find(w => w.code === 'timebase-kept')?.params?.timezone, 'America/Los_Angeles') }) test('시간대를 선언하지 않은 현장이면 시간 기준을 말하지 않는다 — 없는 사실을 만들지 않는다', () => { const j = judgeDetachTwinSpace({ instanceId: 'wms-a', fromSpaceId: 'rosarito-site' }) assert.equal(j.warnings.find(w => w.code === 'timebase-kept'), undefined) })