import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { SLOTS_SCHEMA_VERSION, normalizeSlots, readSlots, slotOccurrences, coverageByDate, vacantSlots, nextFreeSlot, activeEditions, defaultSlotsPath, defaultRegisterPath, } from '../slots.mjs'; // N13 (A1-10): the week's production grid — configured publishing slots, what // covers them (queue + editions register) and what is still in flight. The core // is pure (dates in as strings, no clock) so vacancy is testable without // freezing time; only the thin readers touch the data dir. // Run `fn` with an isolated LINKEDIN_STUDIO_DATA root, seeded by `seed(dir)`. function withDataRoot(seed, fn) { const data = mkdtempSync(join(tmpdir(), 'lis-slots-')); const prev = process.env.LINKEDIN_STUDIO_DATA; process.env.LINKEDIN_STUDIO_DATA = data; try { if (seed) seed(data); return fn(data); } finally { if (prev === undefined) delete process.env.LINKEDIN_STUDIO_DATA; else process.env.LINKEDIN_STUDIO_DATA = prev; rmSync(data, { recursive: true, force: true }); } } function seedSlots(data, config) { mkdirSync(join(data, 'profile'), { recursive: true }); writeFileSync(join(data, 'profile', 'publishing-slots.json'), JSON.stringify(config)); } function seedQueue(data, entries) { mkdirSync(join(data, 'drafts'), { recursive: true }); writeFileSync(join(data, 'drafts', 'queue.json'), JSON.stringify({ version: 1, queue: entries })); } function seedRegister(data, editions) { mkdirSync(join(data, 'editions'), { recursive: true }); writeFileSync(join(data, 'editions', 'register.json'), JSON.stringify({ schemaVersion: 1, editions })); } const row = (over = {}) => ({ series: 'serien', editionId: '05', title: 'En utgave', path: '/series/serien', currentPhase: 'draft', nextAction: 'Step 4 — full draft', slot: null, startedAt: '2026-07-20T08:00:00.000Z', updatedAt: '2026-07-22T08:00:00.000Z', completedAt: null, status: 'active', ...over, }); const queueEntry = (over = {}) => ({ id: 'p1', scheduled_date: '2026-07-28', scheduled_time: '08:30', status: 'scheduled', ...over, }); describe('slots — config normalization (N13/A1-10)', () => { test('accepts full weekday names and abbreviations, any case', () => { const { slots } = normalizeSlots({ slots: [ { day: 'Tuesday', time: '08:30' }, { day: 'THU', time: '12:00', label: 'long-form' }, ], }); assert.deepEqual( slots, [ { day: 'tue', time: '08:30', label: '' }, { day: 'thu', time: '12:00', label: 'long-form' }, ], ); }); test('drops entries with an unknown day or a malformed time', () => { const { slots } = normalizeSlots({ slots: [ { day: 'funday', time: '08:30' }, { day: 'mon', time: '25:00' }, { day: 'mon', time: '8:30' }, { day: 'wed', time: '09:00' }, ], }); assert.deepEqual(slots, [{ day: 'wed', time: '09:00', label: '' }]); }); test('garbage in never throws — it yields an empty grid', () => { for (const bad of [null, undefined, 42, 'nope', {}, { slots: 'x' }]) { assert.deepEqual(normalizeSlots(bad).slots, [], `expected [] for ${JSON.stringify(bad)}`); } }); test('carries the timezone through as a label, defaulting to empty', () => { assert.equal(normalizeSlots({ timezone: 'Europe/Oslo', slots: [] }).timezone, 'Europe/Oslo'); assert.equal(normalizeSlots({ slots: [] }).timezone, ''); }); test('exposes the schema version it reads', () => { assert.equal(SLOTS_SCHEMA_VERSION, 1); }); }); describe('slots — occurrence expansion (N13/A1-10)', () => { const grid = [ { day: 'tue', time: '08:30', label: '' }, { day: 'thu', time: '12:00', label: 'long-form' }, ]; test('expands the weekly grid across the horizon, chronologically', () => { // 2026-07-27 is a Monday. const occ = slotOccurrences(grid, '2026-07-27', 14); assert.deepEqual( occ.map((o) => `${o.date} ${o.time}`), ['2026-07-28 08:30', '2026-07-30 12:00', '2026-08-04 08:30', '2026-08-06 12:00'], ); assert.equal(occ[1].label, 'long-form'); }); test('includes the start day itself — a slot today is still actionable', () => { const occ = slotOccurrences(grid, '2026-07-28', 1); assert.deepEqual(occ.map((o) => o.date), ['2026-07-28']); }); test('two slots on the same day come out in time order', () => { const occ = slotOccurrences( [{ day: 'tue', time: '16:00', label: '' }, { day: 'tue', time: '08:30', label: '' }], '2026-07-28', 1, ); assert.deepEqual(occ.map((o) => o.time), ['08:30', '16:00']); }); test('no slots, or a zero horizon, yields nothing', () => { assert.deepEqual(slotOccurrences([], '2026-07-27', 14), []); assert.deepEqual(slotOccurrences(grid, '2026-07-27', 0), []); }); }); describe('slots — coverage and vacancy (N13/A1-10)', () => { test('a scheduled queue entry covers its date', () => { const cover = coverageByDate([queueEntry()], []); assert.equal(cover.get('2026-07-28'), 1); }); test('a published entry still counts — the slot was used', () => { const cover = coverageByDate([queueEntry({ status: 'published' })], []); assert.equal(cover.get('2026-07-28'), 1); }); test('a cancelled entry does not cover — that slot fell vacant again', () => { const cover = coverageByDate([queueEntry({ status: 'cancelled' })], []); assert.equal(cover.get('2026-07-28'), undefined); }); test('an editions-register row covers the date of its slot, completed rows included', () => { const cover = coverageByDate([], [ row({ slot: '2026-07-30 12:00' }), row({ editionId: '06', slot: '2026-08-06 12:00', status: 'complete', completedAt: '2026-08-01T00:00:00.000Z' }), ]); assert.equal(cover.get('2026-07-30'), 1); assert.equal(cover.get('2026-08-06'), 1); }); test('rows without a claimed slot cover nothing', () => { assert.equal(coverageByDate([], [row({ slot: null })]).size, 0); }); test('vacancy is counted per date — two slots one day need two posts', () => { const occ = slotOccurrences( [{ day: 'tue', time: '08:30', label: '' }, { day: 'tue', time: '16:00', label: '' }], '2026-07-28', 1, ); const oneCovered = vacantSlots(occ, coverageByDate([queueEntry()], [])); assert.deepEqual(oneCovered.map((o) => o.time), ['16:00'], 'one post covers only the first slot that day'); const bothCovered = vacantSlots(occ, coverageByDate([queueEntry(), queueEntry({ id: 'p2' })], [])); assert.deepEqual(bothCovered, []); }); test('uncovered occurrences survive in chronological order', () => { const occ = slotOccurrences([{ day: 'tue', time: '08:30', label: '' }], '2026-07-27', 14); const vacant = vacantSlots(occ, coverageByDate([queueEntry()], [])); assert.deepEqual(vacant.map((o) => o.date), ['2026-08-04']); }); }); describe('slots — data-dir readers (N13/A1-10)', () => { test('readSlots reads the per-user config under profile/', () => { withDataRoot( (d) => seedSlots(d, { schemaVersion: 1, timezone: 'Europe/Oslo', slots: [{ day: 'thu', time: '12:00' }] }), () => { const { slots, timezone } = readSlots(); assert.equal(timezone, 'Europe/Oslo'); assert.deepEqual(slots, [{ day: 'thu', time: '12:00', label: '' }]); }, ); }); test('an absent config is silence, not an error — the un-configured user is never nagged', () => { withDataRoot(null, () => { assert.deepEqual(readSlots().slots, []); assert.equal(nextFreeSlot({ from: '2026-07-27', days: 14 }), null); }); }); test('a malformed config degrades to an empty grid instead of crashing session start', () => { withDataRoot( (d) => { mkdirSync(join(d, 'profile'), { recursive: true }); writeFileSync(join(d, 'profile', 'publishing-slots.json'), '{ not json'); }, () => assert.deepEqual(readSlots().slots, []), ); }); test('the config and register paths resolve under the per-user data root', () => { withDataRoot(null, (d) => { assert.equal(defaultSlotsPath(), join(d, 'profile', 'publishing-slots.json')); assert.equal(defaultRegisterPath(), join(d, 'editions', 'register.json')); }); }); test('reading vacancy never creates the queue file — a read-only check writes nothing', () => { withDataRoot( (d) => seedSlots(d, { slots: [{ day: 'tue', time: '08:30' }] }), (d) => { nextFreeSlot({ from: '2026-07-27', days: 14 }); assert.equal( existsSync(join(d, 'drafts', 'queue.json')), false, 'the vacancy read must not materialize queue.json', ); }, ); }); }); describe('slots — nextFreeSlot end to end (N13/A1-10)', () => { test('returns the earliest uncovered slot, skipping what the queue and register cover', () => { withDataRoot( (d) => { seedSlots(d, { slots: [{ day: 'tue', time: '08:30' }, { day: 'thu', time: '12:00', label: 'long-form' }] }); seedQueue(d, [queueEntry()]); // covers Tue 2026-07-28 seedRegister(d, [row({ slot: '2026-07-30 12:00' })]); // covers Thu 2026-07-30 }, () => { const next = nextFreeSlot({ from: '2026-07-27', days: 14 }); assert.equal(next.date, '2026-08-04'); assert.equal(next.time, '08:30'); }, ); }); test('null when every slot in the horizon is covered', () => { withDataRoot( (d) => { seedSlots(d, { slots: [{ day: 'tue', time: '08:30' }] }); seedQueue(d, [queueEntry()]); }, () => assert.equal(nextFreeSlot({ from: '2026-07-27', days: 7 }), null), ); }); }); describe('slots — editions in flight (N13/A1-11 surfacing)', () => { test('lists active rows only, oldest series first, with whole days in flight', () => { withDataRoot( (d) => seedRegister(d, [ row({ series: 'b-serien', editionId: '02' }), row({ series: 'a-serien', editionId: '07', startedAt: '2026-07-24T00:00:00.000Z' }), row({ series: 'a-serien', editionId: '01', status: 'complete', completedAt: '2026-07-21T00:00:00.000Z' }), ]), () => { const rows = activeEditions({ now: '2026-07-27T00:00:00.000Z' }); assert.deepEqual( rows.map((r) => `${r.series}#${r.editionId}`), ['a-serien#07', 'b-serien#02'], 'complete rows drop out of the in-flight view', ); assert.equal(rows[0].daysInFlight, 3); // Started 2026-07-20 08:00Z, now 2026-07-27 00:00Z = 6.67 days — floored, // like every other elapsed-day report in the hook layer. assert.equal(rows[1].daysInFlight, 6); }, ); }); test('an absent register is an empty view, never a crash', () => { withDataRoot(null, () => assert.deepEqual(activeEditions({ now: '2026-07-27T00:00:00.000Z' }), [])); }); });