// Admin → Teams → Voice channels, the decisions (TEAMS.md §7.3, phase 9). // // These mirror server rules and do not replace them: the server refuses to enable // voice while the bot cannot act, and the reconciler applies the threshold and the // grace window, whether or not this file ever ran. What is asserted here is that // the SCREEN agrees with those answers instead of offering a control that will // fail, or describing a state the deployment is not in. // // The one that matters most is `statusSummary`'s "off" branch. Switching voice off // suspends the reconciler in both directions and deliberately leaves existing // channels standing — a checkbox must not delete structure in somebody's guild — // and an operator who reads "off" as "nothing is provisioned" would never go // looking for the channels that are still there. import { test } from 'node:test' import assert from 'node:assert/strict' import { stateLabel, enableBlockedReason, roleHeadroom, removalCountdown, parseStaffRoles, formatStaffRoles, statusSummary, } from '../src/lib/teamVoice.js' test('every state the server can report has wording', () => { for (const state of ['none', 'active', 'pending_removal', 'error']) { assert.notEqual(stateLabel(state), state) } }) test('an unknown state falls back to itself rather than rendering blank', () => { assert.equal(stateLabel('something-new'), 'something-new') }) // ── The enable gate ──────────────────────────────────────────────────────── test('a ready bot blocks nothing', () => { assert.equal(enableBlockedReason({ ready: true, connected: true, missingPermissions: [] }), null) }) test('a disconnected bot and a bot missing a permission read differently', () => { const disconnected = enableBlockedReason({ ready: false, connected: false, reason: 'the bot is not connected to Discord' }) const missing = enableBlockedReason({ ready: false, connected: true, missingPermissions: ['Manage Roles'] }) assert.match(disconnected, /not connected/) assert.match(missing, /Manage Roles/) // An operator fixes these in two completely different places, so collapsing // them into one message would send half of them to the wrong one. assert.notEqual(disconnected, missing) }) test('an absent preflight blocks rather than silently allowing', () => { assert.ok(enableBlockedReason(null)) assert.ok(enableBlockedReason(undefined)) }) // ── The role ceiling ─────────────────────────────────────────────────────── test('headroom is counted against the guild-wide cap', () => { const h = roleHeadroom({ roleCount: 200, roleCap: 250 }) assert.equal(h.free, 50) assert.equal(h.tight, false) assert.equal(h.exhausted, false) }) test('a nearly full guild is flagged before the create fails, not after', () => { // The whole reason this is in the panel: access is a per-Team role, so the cap // limits how many TEAMS can have voice, and an operator with sixty guilds needs // to know that before the sixtieth silently errors. const h = roleHeadroom({ roleCount: 240, roleCap: 250 }) assert.equal(h.tight, true) assert.equal(h.exhausted, false) }) test('a full guild is exhausted, and never reports negative headroom', () => { const h = roleHeadroom({ roleCount: 260, roleCap: 250 }) assert.equal(h.free, 0) assert.equal(h.exhausted, true) }) test('no preflight means no claim about headroom', () => { assert.equal(roleHeadroom(null), null) assert.equal(roleHeadroom({}), null) }) // ── The grace window ─────────────────────────────────────────────────────── test('a row that is not scheduled has no countdown', () => { assert.equal(removalCountdown({ state: 'active', removeAfter: null }), null) }) test('a running window reads in days', () => { const now = new Date('2026-08-19T00:00:00Z') const text = removalCountdown({ state: 'pending_removal', removeAfter: '2026-08-24T00:00:00Z' }, now) assert.equal(text, 'in 5 days') }) test('under a day reads in hours rather than rounding to zero days', () => { const now = new Date('2026-08-19T00:00:00Z') const text = removalCountdown({ state: 'pending_removal', removeAfter: '2026-08-19T06:00:00Z' }, now) assert.equal(text, 'in 6 hours') }) test('an expired window says the next pass will act, not "in 0 days"', () => { const now = new Date('2026-08-19T00:00:00Z') const text = removalCountdown({ state: 'pending_removal', removeAfter: '2026-08-18T00:00:00Z' }, now) assert.match(text, /next pass/) }) // ── Staff roles ──────────────────────────────────────────────────────────── test('staff roles parse from the comma-separated ids a person actually pastes', () => { const { roles, invalid } = parseStaffRoles(' 123456789012345678 , 987654321098765432 ') assert.deepEqual(roles, ['123456789012345678', '987654321098765432']) assert.deepEqual(invalid, []) }) test('a typo is REPORTED, never quietly dropped', () => { const { invalid } = parseStaffRoles('123456789012345678, @Moderators') assert.deepEqual(invalid, ['@Moderators']) }) test('an empty field is a legitimate answer and not an error', () => { const { roles, invalid } = parseStaffRoles('') assert.deepEqual(roles, []) assert.deepEqual(invalid, []) }) test('roles round-trip through the field', () => { const { roles } = parseStaffRoles(formatStaffRoles(['111111111111111111', '222222222222222222'])) assert.deepEqual(roles, ['111111111111111111', '222222222222222222']) }) // ── The status line ──────────────────────────────────────────────────────── test('off with channels still standing says so — the surprising case', () => { const text = statusSummary({ enabled: false }, [{ channelRef: '900' }, { channelRef: '901' }]) assert.match(text, /^Off\./) assert.match(text, /2 channels remain/) }) test('off with nothing provisioned does not invent a warning', () => { const text = statusSummary({ enabled: false }, []) assert.match(text, /No channels are provisioned/) }) test('on states the threshold in the words the setting uses', () => { const text = statusSummary({ enabled: true, minMembers: 5 }, [{ channelRef: '900' }]) assert.match(text, /at least 5 members/) assert.match(text, /1 provisioned/) }) test('a threshold of one is not pluralised', () => { assert.match(statusSummary({ enabled: true, minMembers: 1 }, []), /at least 1 member get/) })