/* * ★ 주의 신호 요약 — 신호가 많은 트윈의 전송량. * * ── 계기 ──────────────────────────────────────────────────────────────────── * 축 선택으로 리프레시를 80KB → 2KB 로 줄였는데 **신호가 많은 트윈은 거기서 막혔다**: * `hatio-yard` 는 신호 397건이 108KB 라 5배 감소에 그쳤다. 그런데 화면이 그 397건으로 하는 일은 * 둘뿐이다 — 레일에 20건만 그리고, 자리 7곳의 색을 뽑는다. * * ── 이 테스트가 지키는 것 ─────────────────────────────────────────────────── * ① **자리 색은 전량을 근거로** 계산된다. 상위 N 으로 줄이면 20위 밖의 신호를 가진 자리가 색을 잃는다. * ② **알리지 않고 자르지 않는다.** 전체 개수를 함께 보내야 레일이 "N건 더" 를 말할 수 있다. * ③ **순서가 화면과 같다.** 서버가 고른 20건이 화면이 고를 20건과 달라지면 아무 데서도 안 드러난다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' import { digestAttentions, compareAttentions, mergeLensAttentions } from '../server/engine/attention-digest.ts' const attn = (id: string, severity: string, locationId?: string, state?: string) => ({ id, severity, state, anchor: locationId ? { locationId } : undefined }) test('상위 N 만 남기고 전체 개수를 함께 낸다 — 알리지 않고 자르지 않는다', () => { const all = Array.from({ length: 50 }, (_, i) => attn(`a${i}`, 'low', `loc-${i % 3}`)) const d = digestAttentions(all, 20) assert.equal(d.attentions.length, 20) assert.equal(d.attentionTotal, 50, '전체 개수가 없으면 화면이 "괜찮아졌다" 로 읽는다') }) test('자리 색은 전량을 근거로 처리한다 — 상위 N 밖의 자리도 색을 갖는다', () => { /* 앞 20건은 loc-hot 에, 21번째 한 건만 loc-cold 에 — 잘린 뒤에 계산하면 loc-cold 가 색을 잃는다. */ const all = [...Array.from({ length: 20 }, (_, i) => attn(`hot${i}`, 'high', 'loc-hot')), attn('cold', 'critical', 'loc-cold')] const d = digestAttentions(all, 20) assert.equal(d.attentions.length, 20) assert.equal(d.severityByLocation['loc-cold'], 'critical', '상위 N 밖의 신호도 자리 색에는 반영돼야 한다') assert.equal(d.severityByLocation['loc-hot'], 'high') }) test('자리마다 가장 급한 심각도가 남는다', () => { const d = digestAttentions([attn('a', 'low', 'n1'), attn('b', 'critical', 'n1'), attn('c', 'medium', 'n1')]) assert.equal(d.severityByLocation['n1'], 'critical') }) test('순서는 화면과 같다 — 확인된 것은 아래로, 그다음 심각도순', () => { const all = [attn('acked-crit', 'critical', 'n1', 'acknowledged'), attn('low', 'low', 'n2'), attn('high', 'high', 'n3')] const d = digestAttentions(all, 2) assert.deepEqual(d.attentions.map(a => a.id), ['high', 'low'], '확인된 critical 보다 확인 안 된 것이 먼저다') assert.equal(d.attentionTotal, 3) }) test('한도를 지정하지 않으면 자르지 않는다 — 이 값을 모르는 소비처가 일부만 받으면 안 된다', () => { const all = Array.from({ length: 30 }, (_, i) => attn(`a${i}`, 'low')) assert.equal(digestAttentions(all).attentions.length, 30) assert.equal(digestAttentions(all, 0).attentions.length, 30) assert.equal(digestAttentions(all, -5).attentions.length, 30) }) test('자리에 붙지 않은 신호는 색 요약에 들어가지 않는다 — 없는 자리를 만들지 않는다', () => { const d = digestAttentions([attn('orphan', 'critical'), attn('anchored', 'low', 'n1')]) assert.deepEqual(Object.keys(d.severityByLocation), ['n1']) assert.equal(d.attentionTotal, 2, '색에 안 들어가도 개수에는 든다') }) test('신호가 없으면 빈 요약 — 없는 것을 만들지 않는다', () => { for (const v of [undefined, null, []]) { const d = digestAttentions(v as any, 20) assert.deepEqual(d.attentions, []) assert.equal(d.attentionTotal, 0) assert.deepEqual(d.severityByLocation, {}) } }) test('정렬 함수는 원본을 바꾸지 않는다 — 스냅샷을 제자리에서 흔들면 다른 소비처가 영향받는다', () => { const all = [attn('low', 'low'), attn('crit', 'critical')] const before = all.map(a => a.id) digestAttentions(all, 1) assert.deepEqual(all.map(a => a.id), before) assert.equal(compareAttentions(all[1], all[0]) < 0, true, 'critical 이 앞이다') }) /* * ── 여러 렌즈를 한 현장으로 모으기 (2026-08-05) ──────────────────────────── * 한 공장에 WMS·MES·YMS 가 함께 있으므로 "이 현장에서 봐야 할 것" 은 렌즈 하나로 답할 수 없다. * 예전에는 화면이 인스턴스마다 물어 손으로 합쳤고, 그러면 정렬 규칙이 화면마다 갈린다. * * 모으면서 알리지 않고 틀릴 수 있는 자리 셋을 여기서 못 박는다. */ test('★ 어느 트윈에서 온 신호인지 붙는다 — 조치는 결국 그 트윈에 보내야 한다', () => { const d = mergeLensAttentions([ { instanceId: 'wms', attentions: [attn('a', 'high', 'dock-1')] }, { instanceId: 'yms', attentions: [attn('b', 'low', 'gate-1')] } ]) assert.deepEqual(d.attentions.map(a => [a.id, (a as any).instanceId]), [['a', 'wms'], ['b', 'yms']]) }) test('★ 순서는 렌즈 순서가 아니라 급한 순서다 — 뒤 렌즈의 심각한 신호가 밀려나면 안 된다', () => { const d = mergeLensAttentions( [ { instanceId: 'wms', attentions: [attn('low-1', 'low'), attn('low-2', 'low')] }, { instanceId: 'mes', attentions: [attn('crit', 'critical')] } ], 1 ) assert.deepEqual(d.attentions.map(a => a.id), ['crit'], '상위 1건은 뒤 렌즈의 critical 이어야 한다') assert.equal(d.attentionTotal, 3) }) test('★ 두 렌즈가 같은 대상을 지목하면 더 급한 쪽이 색을 정한다 — 하나의 현실이므로', () => { const d = mergeLensAttentions([ { instanceId: 'wms', attentions: [attn('a', 'low', 'dock-1')] }, { instanceId: 'yms', attentions: [attn('b', 'critical', 'dock-1')] } ]) assert.equal(d.severityByLocation['dock-1'], 'critical') /* 렌즈 순서를 뒤집어도 같아야 한다 — 색이 순서에 따라 달라지면 화면이 새로고침마다 바뀐다. */ const flipped = mergeLensAttentions([ { instanceId: 'yms', attentions: [attn('b', 'critical', 'dock-1')] }, { instanceId: 'wms', attentions: [attn('a', 'low', 'dock-1')] } ]) assert.equal(flipped.severityByLocation['dock-1'], 'critical') }) test('★ 신호가 없는 렌즈는 아무것도 보태지 않는다 — 빈 렌즈가 개수를 늘리지 않게', () => { const d = mergeLensAttentions([ { instanceId: 'wms', attentions: [attn('a', 'low')] }, { instanceId: 'mes', attentions: [] }, { instanceId: 'yms', attentions: undefined } ]) assert.equal(d.attentionTotal, 1) assert.deepEqual(d.attentions.map(a => a.id), ['a']) }) test('★ 렌즈가 하나도 없으면 빈 요약 — 공간에 배치된 트윈이 없을 때', () => { const d = mergeLensAttentions([], 20) assert.deepEqual(d.attentions, []) assert.equal(d.attentionTotal, 0) assert.deepEqual(d.severityByLocation, {}) }) /* * ── 자를 수 있는 문만 자른다 (2026-09-19, 수석 아키텍트 판정) ──────────────── * * 목록 문(`twinAttentions`)에 `limit` 이 있었다. 자르면서 **몇 건을 잘랐는지는 답에 실을 자리가 * 없었다** — 답이 맨 목록이라서다. 그러면 스무 건을 받은 화면은 그것이 전부인지 이백 중 스물인지 * 구별할 수 없고, 그 구별이 없으면 「괜찮아졌다」로 읽힌다(위 ②와 같은 자리). * * 그래서 자르는 일은 **자른 수를 말할 수 있는 문**에만 둔다. 이 검사는 그 규칙을 소스에서 센다 — * 인자를 되돌려 놓으면 빨강이 된다. */ test('★ 목록 문은 자르지 않는다 — 자르는 인자는 전체 개수를 함께 내는 문에만 있다', async () => { const source = await readFile(new URL('../server/service/twin-attention/twin-attention-query.ts', import.meta.url), 'utf-8') const listAt = source.indexOf('async twinAttentions(') const digestAt = source.indexOf('async twinAttentionDigest(') assert.ok(listAt >= 0 && digestAt > listAt, '두 문을 못 찾았다 — 이 검사는 아무것도 지키지 않는다') const list = source.slice(listAt, digestAt) const digest = source.slice(digestAt) assert.ok(!/@Arg\(\s*'limit'/.test(list), '목록 문에 자르는 인자가 있다 — 자른 수를 답에 실을 자리가 없다') assert.ok(/@Arg\(\s*'limit'/.test(digest), '자르는 문이 없어졌다 — 화면이 상위 N 을 받을 길이 없다') assert.ok(/digestAttentions\(/.test(digest), '자르는 문은 전체 개수를 함께 내는 함수를 지나야 한다') })