PR 3 of the schedule-types feature — see
``memory/project_auto_sync_schedule_types.md``. Backend
``next_run_at`` + ``weekly_time`` trigger handler landed in PRs 1-2.
This PR exposes them in the Auto-Sync manager so users can finally
schedule playlists by day-of-week + time instead of only hourly
intervals.
**UI layout:**
The Auto-Sync modal grows a ``Weekly Board`` tab between
``Hourly Board`` (renamed from ``Schedule Board``) and
``Automation Pipelines``. Same sidebar (mirrored playlists grouped
by source, with filter). Main panel is 7 day columns Mon-Sun
instead of 10 hour buckets. Drag a playlist onto a day column →
creates a single-day weekly schedule at the default time
(09:00 in the browser's IANA tz from
``Intl.DateTimeFormat().resolvedOptions().timeZone``). Click any
scheduled card → opens an editor popover for time, multi-day
toggles, tz override, and unschedule.
Multi-day schedules render under every matching column (Mon-Wed-Fri
schedule appears as three cards, one per column) — matches how
users think about "this playlist runs on Mon AND Wed AND Fri".
**Mutual exclusion:** one schedule per playlist. The save path on
either tab deletes any existing schedule of the OTHER kind before
installing the new one. Backend can technically run both as two
separate automation rows, but two cards under the same playlist
would surprise users and the engine has no merge semantic for
"daily-and-hourly".
**Pure-function helpers** (testable via node:test, matching the
existing ``tests/static/test_auto_sync.mjs`` pattern):
- ``detectBrowserTimezone()`` — Intl tz with UTC fallback for
browsers where Intl is absent.
- ``autoSyncWeeklyTrigger({time, days, tz})`` — defensive payload
builder: garbage time → 09:00, unrecognised days dropped,
missing tz → browser tz.
- ``autoSyncWeeklyFromTrigger(config)`` — inverse parser with
the same defensive shape. Empty days expands to every weekday
(matches ``next_run_at`` engine semantic). Returns null for
non-object configs so ``buildAutoSyncScheduleState`` can route
broken rows to automationPipelines instead of silently
bucketing them as every-day weekly.
- ``autoSyncWeeklyLabel(parsed)`` — sorted "Mon, Wed, Fri @
09:00" / collapses to "Daily @ HH:MM" for full-week / "Unscheduled"
for null. Canonical Mon-Sun ordering regardless of input order.
**Tests:** 26 new node:test cases across ``detectBrowserTimezone``
x1, ``autoSyncWeeklyTrigger`` x6, ``autoSyncWeeklyFromTrigger`` x6,
``autoSyncWeeklyLabel`` x5, and ``buildAutoSyncScheduleState``
weekly bucketing x5 (covering owned weekly_time → weeklySchedules,
hourly stays in playlistSchedules, non-owned falls through to
automationPipelines, legacy-named auto-sync rows still recognised,
garbage trigger_config falls through). All 62 node:test cases pass;
261 across the automation pytest suite still green (zero regression
on PRs 1-2's plumbing). Python wrapper at
``tests/test_auto_sync_js.py`` shells out cleanly.
**CSS** (themed to the existing Auto-Sync gradient + accent
variables):
- 7-column grid for the weekly board, narrower than the 10
hour-bucket layout.
- Editor popover with backdrop-blur, accent-tinted save / delete
buttons, hover states that pick up the user's accent color.
- ``scheduled-elsewhere`` state for playlists with an hourly
schedule visible on the weekly board (dashed border + opacity)
so the user knows a drop will replace, not stack.
**WHATS_NEW entry** under 2.6.3 unreleased — first user-visible
slice of the schedule-types feature.
PR 4 (Monthly UI tab) deferred until weekly proves wanted.
686 lines
27 KiB
JavaScript
686 lines
27 KiB
JavaScript
// Tests for the pure-function helpers in `webui/static/auto-sync.js`.
|
|
// Run via:
|
|
//
|
|
// node --test tests/static/test_auto_sync.mjs
|
|
//
|
|
// The pytest wrapper at `tests/test_auto_sync_js.py` shells out to
|
|
// `node --test` and surfaces the result inside the regular pytest run.
|
|
//
|
|
// The module is loaded into a sandboxed `vm` context with stubs for
|
|
// the few globals it relies on (`_autoParseUTC` for the timezone-aware
|
|
// next_run label). No DOM — just the calculation contract.
|
|
|
|
import { test, describe, before } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import vm from 'node:vm';
|
|
import { readFileSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname, resolve } from 'node:path';
|
|
|
|
// Values returned from the sandboxed VM context are cross-realm — their
|
|
// prototype chain differs from the test realm's, so deepStrictEqual on
|
|
// raw VM objects fails even when shape and values match. JSON-round-trip
|
|
// to compare structural equality only.
|
|
function deepShapeEqual(actual, expected, msg) {
|
|
assert.deepEqual(
|
|
JSON.parse(JSON.stringify(actual)),
|
|
JSON.parse(JSON.stringify(expected)),
|
|
msg,
|
|
);
|
|
}
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const AUTOSYNC_PATH = resolve(__dirname, '..', '..', 'webui', 'static', 'auto-sync.js');
|
|
|
|
let AUTOSYNC_SOURCE;
|
|
before(() => {
|
|
AUTOSYNC_SOURCE = readFileSync(AUTOSYNC_PATH, 'utf8');
|
|
});
|
|
|
|
// Match the actual implementation in stats-automations.js so the
|
|
// timezone-bug fix is exercised end-to-end through auto-sync.js.
|
|
function realAutoParseUTC(ts) {
|
|
if (!ts) return NaN;
|
|
if (/[Zz]$/.test(ts) || /[+-]\d{2}:\d{2}$/.test(ts)) return new Date(ts).getTime();
|
|
return new Date(ts + 'Z').getTime();
|
|
}
|
|
|
|
function makeSandbox() {
|
|
const sandbox = {
|
|
window: {},
|
|
document: { getElementById: () => null, body: {} },
|
|
console: { debug: () => {}, error: () => {}, log: () => {} },
|
|
fetch: async () => { throw new Error('fetch not stubbed for this test'); },
|
|
// Globals that auto-sync.js expects to find in the window namespace
|
|
_autoParseUTC: realAutoParseUTC,
|
|
_autoFormatTrigger: () => 'trigger',
|
|
_esc: (s) => String(s),
|
|
_escAttr: (s) => String(s),
|
|
showToast: () => {},
|
|
showConfirmDialog: async () => true,
|
|
loadMirroredPlaylists: () => {},
|
|
updateMirroredCardPhase: () => {},
|
|
openMirroredPlaylistModal: () => {},
|
|
closeMirroredModal: () => {},
|
|
youtubePlaylistStates: {},
|
|
setInterval: () => 0,
|
|
clearInterval: () => {},
|
|
};
|
|
vm.createContext(sandbox);
|
|
vm.runInContext(AUTOSYNC_SOURCE, sandbox);
|
|
return sandbox;
|
|
}
|
|
|
|
// =========================================================================
|
|
// autoSyncTriggerForHours / autoSyncHoursFromTrigger — round-trip
|
|
// =========================================================================
|
|
|
|
describe('autoSyncTriggerForHours', () => {
|
|
test('sub-day intervals become hours', () => {
|
|
const sb = makeSandbox();
|
|
deepShapeEqual(sb.autoSyncTriggerForHours(1), { interval: 1, unit: 'hours' });
|
|
deepShapeEqual(sb.autoSyncTriggerForHours(12), { interval: 12, unit: 'hours' });
|
|
});
|
|
|
|
test('whole-day multiples become days', () => {
|
|
const sb = makeSandbox();
|
|
deepShapeEqual(sb.autoSyncTriggerForHours(24), { interval: 1, unit: 'days' });
|
|
deepShapeEqual(sb.autoSyncTriggerForHours(48), { interval: 2, unit: 'days' });
|
|
deepShapeEqual(sb.autoSyncTriggerForHours(168), { interval: 7, unit: 'days' });
|
|
});
|
|
|
|
test('non-day-multiple > 24 stays as hours', () => {
|
|
const sb = makeSandbox();
|
|
// 36h doesn't divide evenly into days, stay as hours
|
|
deepShapeEqual(sb.autoSyncTriggerForHours(36), { interval: 36, unit: 'hours' });
|
|
});
|
|
|
|
test('invalid input defaults to 24 hours', () => {
|
|
const sb = makeSandbox();
|
|
// Per autoSyncTriggerForHours: `parseInt(undefined, 10) || 24` → 24, becomes 1 day
|
|
deepShapeEqual(sb.autoSyncTriggerForHours(undefined), { interval: 1, unit: 'days' });
|
|
});
|
|
});
|
|
|
|
describe('autoSyncHoursFromTrigger', () => {
|
|
test('hours unit returned directly', () => {
|
|
const sb = makeSandbox();
|
|
assert.equal(sb.autoSyncHoursFromTrigger({ interval: 6, unit: 'hours' }), 6);
|
|
});
|
|
|
|
test('days unit multiplied by 24', () => {
|
|
const sb = makeSandbox();
|
|
assert.equal(sb.autoSyncHoursFromTrigger({ interval: 3, unit: 'days' }), 72);
|
|
});
|
|
|
|
test('weeks unit multiplied by 168', () => {
|
|
const sb = makeSandbox();
|
|
assert.equal(sb.autoSyncHoursFromTrigger({ interval: 1, unit: 'weeks' }), 168);
|
|
});
|
|
|
|
test('minutes unit rounds up to at least 1 hour', () => {
|
|
const sb = makeSandbox();
|
|
assert.equal(sb.autoSyncHoursFromTrigger({ interval: 30, unit: 'minutes' }), 1);
|
|
assert.equal(sb.autoSyncHoursFromTrigger({ interval: 90, unit: 'minutes' }), 2);
|
|
});
|
|
|
|
test('zero or missing interval returns null', () => {
|
|
const sb = makeSandbox();
|
|
assert.equal(sb.autoSyncHoursFromTrigger({}), null);
|
|
assert.equal(sb.autoSyncHoursFromTrigger({ interval: 0 }), null);
|
|
});
|
|
|
|
test('round-trip with autoSyncTriggerForHours preserves hour count', () => {
|
|
const sb = makeSandbox();
|
|
for (const hours of [1, 4, 12, 24, 48, 168]) {
|
|
const config = sb.autoSyncTriggerForHours(hours);
|
|
assert.equal(sb.autoSyncHoursFromTrigger(config), hours, `round-trip ${hours}`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// Label helpers
|
|
// =========================================================================
|
|
|
|
describe('autoSyncBucketLabel', () => {
|
|
test('weekly bucket', () => {
|
|
const sb = makeSandbox();
|
|
assert.equal(sb.autoSyncBucketLabel(168), 'Weekly');
|
|
});
|
|
|
|
test('day-multiple buckets', () => {
|
|
const sb = makeSandbox();
|
|
assert.equal(sb.autoSyncBucketLabel(24), '1d');
|
|
assert.equal(sb.autoSyncBucketLabel(48), '2d');
|
|
});
|
|
|
|
test('sub-day buckets', () => {
|
|
const sb = makeSandbox();
|
|
assert.equal(sb.autoSyncBucketLabel(1), '1h');
|
|
assert.equal(sb.autoSyncBucketLabel(12), '12h');
|
|
});
|
|
});
|
|
|
|
describe('autoSyncIntervalLabel', () => {
|
|
test('pluralization', () => {
|
|
const sb = makeSandbox();
|
|
assert.equal(sb.autoSyncIntervalLabel(1), 'Every 1 hour');
|
|
assert.equal(sb.autoSyncIntervalLabel(2), 'Every 2 hours');
|
|
assert.equal(sb.autoSyncIntervalLabel(24), 'Every 1 day');
|
|
assert.equal(sb.autoSyncIntervalLabel(48), 'Every 2 days');
|
|
assert.equal(sb.autoSyncIntervalLabel(168), 'Every week');
|
|
});
|
|
});
|
|
|
|
describe('autoSyncSourceLabel', () => {
|
|
test('known sources mapped', () => {
|
|
const sb = makeSandbox();
|
|
assert.equal(sb.autoSyncSourceLabel('spotify'), 'Spotify');
|
|
assert.equal(sb.autoSyncSourceLabel('youtube'), 'YouTube');
|
|
});
|
|
|
|
test('unknown source returns the raw key', () => {
|
|
const sb = makeSandbox();
|
|
assert.equal(sb.autoSyncSourceLabel('newthing'), 'newthing');
|
|
});
|
|
|
|
test('falsy source returns "Other"', () => {
|
|
const sb = makeSandbox();
|
|
assert.equal(sb.autoSyncSourceLabel(''), 'Other');
|
|
assert.equal(sb.autoSyncSourceLabel(null), 'Other');
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// Schedulability and ownership predicates
|
|
// =========================================================================
|
|
|
|
describe('autoSyncCanSchedulePlaylist', () => {
|
|
test('blocks file and beatport sources', () => {
|
|
const sb = makeSandbox();
|
|
assert.equal(sb.autoSyncCanSchedulePlaylist({ source: 'file' }), false);
|
|
assert.equal(sb.autoSyncCanSchedulePlaylist({ source: 'beatport' }), false);
|
|
});
|
|
|
|
test('allows refreshable sources', () => {
|
|
const sb = makeSandbox();
|
|
assert.equal(sb.autoSyncCanSchedulePlaylist({ source: 'spotify' }), true);
|
|
assert.equal(sb.autoSyncCanSchedulePlaylist({ source: 'youtube' }), true);
|
|
});
|
|
|
|
test('null/undefined playlist returns falsy', () => {
|
|
const sb = makeSandbox();
|
|
assert.ok(!sb.autoSyncCanSchedulePlaylist(null));
|
|
assert.ok(!sb.autoSyncCanSchedulePlaylist(undefined));
|
|
});
|
|
});
|
|
|
|
describe('autoSyncIsPipelineAutomation', () => {
|
|
test('matches playlist_pipeline action type', () => {
|
|
const sb = makeSandbox();
|
|
assert.equal(sb.autoSyncIsPipelineAutomation({ action_type: 'playlist_pipeline' }), true);
|
|
assert.equal(sb.autoSyncIsPipelineAutomation({ action_type: 'process_wishlist' }), false);
|
|
});
|
|
});
|
|
|
|
describe('autoSyncPlaylistIdFromAutomation', () => {
|
|
test('extracts numeric playlist_id', () => {
|
|
const sb = makeSandbox();
|
|
const auto = { action_type: 'playlist_pipeline', action_config: { playlist_id: '42' } };
|
|
assert.equal(sb.autoSyncPlaylistIdFromAutomation(auto), 42);
|
|
});
|
|
|
|
test('returns null when all=true (catch-all pipeline)', () => {
|
|
const sb = makeSandbox();
|
|
const auto = { action_type: 'playlist_pipeline', action_config: { all: true } };
|
|
assert.equal(sb.autoSyncPlaylistIdFromAutomation(auto), null);
|
|
});
|
|
|
|
test('returns null for non-pipeline automations', () => {
|
|
const sb = makeSandbox();
|
|
assert.equal(sb.autoSyncPlaylistIdFromAutomation({ action_type: 'other' }), null);
|
|
});
|
|
|
|
test('returns null when playlist_id missing', () => {
|
|
const sb = makeSandbox();
|
|
const auto = { action_type: 'playlist_pipeline', action_config: {} };
|
|
assert.equal(sb.autoSyncPlaylistIdFromAutomation(auto), null);
|
|
});
|
|
});
|
|
|
|
describe('autoSyncIsScheduleOwned', () => {
|
|
test('owned_by="auto_sync" wins over name/group', () => {
|
|
const sb = makeSandbox();
|
|
assert.equal(sb.autoSyncIsScheduleOwned({
|
|
owned_by: 'auto_sync', name: 'Whatever', group_name: 'unrelated',
|
|
}), true);
|
|
});
|
|
|
|
test('legacy name-prefix still recognized', () => {
|
|
const sb = makeSandbox();
|
|
assert.equal(sb.autoSyncIsScheduleOwned({ name: 'Auto-Sync: Discover Weekly' }), true);
|
|
});
|
|
|
|
test('legacy group_name still recognized', () => {
|
|
const sb = makeSandbox();
|
|
assert.equal(sb.autoSyncIsScheduleOwned({ group_name: 'Playlist Auto-Sync' }), true);
|
|
});
|
|
|
|
test('automation with no owned_by and no legacy markers returns false', () => {
|
|
const sb = makeSandbox();
|
|
assert.equal(sb.autoSyncIsScheduleOwned({ name: 'My Custom Pipeline' }), false);
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// State partitioning
|
|
// =========================================================================
|
|
|
|
describe('buildAutoSyncScheduleState', () => {
|
|
test('partitions board-owned schedules from custom pipelines', () => {
|
|
const sb = makeSandbox();
|
|
const playlists = [{ id: 1, name: 'Discover Weekly' }, { id: 2, name: 'Top Hits' }];
|
|
const automations = [
|
|
{
|
|
id: 10, action_type: 'playlist_pipeline', trigger_type: 'schedule',
|
|
trigger_config: { interval: 1, unit: 'hours' },
|
|
action_config: { playlist_id: '1' },
|
|
owned_by: 'auto_sync',
|
|
enabled: 1,
|
|
},
|
|
{
|
|
id: 11, action_type: 'playlist_pipeline', trigger_type: 'schedule',
|
|
trigger_config: { interval: 1, unit: 'days' },
|
|
action_config: { playlist_id: '99' }, // not in playlists, but custom-owned
|
|
enabled: 1,
|
|
// no owned_by → custom pipeline
|
|
},
|
|
];
|
|
const state = sb.buildAutoSyncScheduleState(playlists, automations);
|
|
assert.equal(Object.keys(state.playlistSchedules).length, 1);
|
|
assert.equal(state.playlistSchedules[1].automation_id, 10);
|
|
assert.equal(state.playlistSchedules[1].hours, 1);
|
|
assert.equal(state.automationPipelines.length, 1);
|
|
assert.equal(state.automationPipelines[0].id, 11);
|
|
});
|
|
|
|
test('non-pipeline automations are ignored entirely', () => {
|
|
const sb = makeSandbox();
|
|
const automations = [
|
|
{ id: 20, action_type: 'process_wishlist', trigger_type: 'schedule' },
|
|
];
|
|
const state = sb.buildAutoSyncScheduleState([], automations);
|
|
deepShapeEqual(state.playlistSchedules, {});
|
|
deepShapeEqual(state.automationPipelines, []);
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// Timezone-aware countdown — the headline bug this branch fixed
|
|
// =========================================================================
|
|
|
|
describe('autoSyncNextRunLabel', () => {
|
|
test('empty string for missing input', () => {
|
|
const sb = makeSandbox();
|
|
assert.equal(sb.autoSyncNextRunLabel(''), '');
|
|
assert.equal(sb.autoSyncNextRunLabel(null), '');
|
|
});
|
|
|
|
test('naive UTC string is parsed as UTC, not local', () => {
|
|
const sb = makeSandbox();
|
|
// Pick a time exactly one hour from now in UTC. If the parser
|
|
// mistakenly treats the bare timestamp as LOCAL it would land
|
|
// wildly far from 1h on machines in non-UTC timezones —
|
|
// that's exactly the bug Cin's review flagged.
|
|
const future = new Date(Date.now() + 60 * 60 * 1000);
|
|
const iso = future.toISOString().slice(0, 19).replace('T', ' ');
|
|
const label = sb.autoSyncNextRunLabel(iso);
|
|
// Allow either "next in 60m" (right at the boundary) or "next in 1h"
|
|
assert.ok(/^next in (60m|1h)$/.test(label), `expected ~1h, got "${label}"`);
|
|
});
|
|
|
|
test('"due now" when timestamp is in the past', () => {
|
|
const sb = makeSandbox();
|
|
const past = new Date(Date.now() - 60 * 1000).toISOString().slice(0, 19).replace('T', ' ');
|
|
assert.equal(sb.autoSyncNextRunLabel(past), 'due now');
|
|
});
|
|
|
|
test('day-scale label when timestamp is days out', () => {
|
|
const sb = makeSandbox();
|
|
const future = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000);
|
|
const iso = future.toISOString().slice(0, 19).replace('T', ' ');
|
|
const label = sb.autoSyncNextRunLabel(iso);
|
|
assert.match(label, /^next in \dd$/);
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// getMirroredSourceRef — playlist source URL resolution
|
|
// =========================================================================
|
|
|
|
describe('getMirroredSourceRef', () => {
|
|
test('explicit source_ref wins', () => {
|
|
const sb = makeSandbox();
|
|
assert.equal(
|
|
sb.getMirroredSourceRef({ source_ref: 'https://example.com/x' }),
|
|
'https://example.com/x',
|
|
);
|
|
});
|
|
|
|
test('falls back to description URL for spotify_public', () => {
|
|
const sb = makeSandbox();
|
|
const p = {
|
|
source: 'spotify_public',
|
|
description: 'https://open.spotify.com/playlist/abc',
|
|
};
|
|
assert.equal(sb.getMirroredSourceRef(p), 'https://open.spotify.com/playlist/abc');
|
|
});
|
|
|
|
test('non-URL description ignored, falls through to source_playlist_id', () => {
|
|
const sb = makeSandbox();
|
|
const p = {
|
|
source: 'spotify_public',
|
|
description: 'just a note about this playlist',
|
|
source_playlist_id: 'abc123',
|
|
};
|
|
assert.equal(sb.getMirroredSourceRef(p), 'abc123');
|
|
});
|
|
|
|
test('empty playlist returns empty string', () => {
|
|
const sb = makeSandbox();
|
|
assert.equal(sb.getMirroredSourceRef({}), '');
|
|
});
|
|
});
|
|
|
|
|
|
// =========================================================================
|
|
// Weekly schedule helpers — PR 3 of the schedule-types feature.
|
|
// =========================================================================
|
|
|
|
|
|
describe('detectBrowserTimezone', () => {
|
|
test('returns IANA tz from Intl in the test runtime', () => {
|
|
const sb = makeSandbox();
|
|
// Node runs with a system tz; the resolved value must be a
|
|
// non-empty string (any IANA name is acceptable here).
|
|
const tz = sb.detectBrowserTimezone();
|
|
assert.equal(typeof tz, 'string');
|
|
assert.ok(tz.length > 0);
|
|
});
|
|
});
|
|
|
|
|
|
describe('autoSyncWeeklyTrigger', () => {
|
|
test('builds a clean payload from picker input', () => {
|
|
const sb = makeSandbox();
|
|
const result = sb.autoSyncWeeklyTrigger({
|
|
time: '09:00',
|
|
days: ['mon', 'wed', 'fri'],
|
|
tz: 'America/Los_Angeles',
|
|
});
|
|
deepShapeEqual(result, {
|
|
time: '09:00',
|
|
days: ['mon', 'wed', 'fri'],
|
|
tz: 'America/Los_Angeles',
|
|
});
|
|
});
|
|
|
|
test('garbage time string defaults to 09:00', () => {
|
|
const sb = makeSandbox();
|
|
const result = sb.autoSyncWeeklyTrigger({
|
|
time: 'lol', days: ['mon'], tz: 'UTC',
|
|
});
|
|
assert.equal(result.time, '09:00');
|
|
});
|
|
|
|
test('unrecognised weekday abbreviations dropped from payload', () => {
|
|
const sb = makeSandbox();
|
|
const result = sb.autoSyncWeeklyTrigger({
|
|
time: '09:00',
|
|
days: ['mon', 'garbage', 'wed', 'mond'],
|
|
tz: 'UTC',
|
|
});
|
|
deepShapeEqual(result.days, ['mon', 'wed']);
|
|
});
|
|
|
|
test('missing tz falls back to browser-detected default', () => {
|
|
const sb = makeSandbox();
|
|
const result = sb.autoSyncWeeklyTrigger({
|
|
time: '09:00', days: ['mon'],
|
|
});
|
|
assert.equal(typeof result.tz, 'string');
|
|
assert.ok(result.tz.length > 0);
|
|
});
|
|
|
|
test('empty argument object produces all-defaults payload', () => {
|
|
const sb = makeSandbox();
|
|
const result = sb.autoSyncWeeklyTrigger({});
|
|
assert.equal(result.time, '09:00');
|
|
deepShapeEqual(result.days, []);
|
|
assert.equal(typeof result.tz, 'string');
|
|
});
|
|
|
|
test('non-array days param defaults to empty', () => {
|
|
const sb = makeSandbox();
|
|
const result = sb.autoSyncWeeklyTrigger({
|
|
time: '09:00', days: 'mon', tz: 'UTC',
|
|
});
|
|
deepShapeEqual(result.days, []);
|
|
});
|
|
});
|
|
|
|
|
|
describe('autoSyncWeeklyFromTrigger', () => {
|
|
test('round-trips with autoSyncWeeklyTrigger when days non-empty', () => {
|
|
const sb = makeSandbox();
|
|
const cfg = sb.autoSyncWeeklyTrigger({
|
|
time: '09:00', days: ['mon', 'wed'], tz: 'UTC',
|
|
});
|
|
const parsed = sb.autoSyncWeeklyFromTrigger(cfg);
|
|
deepShapeEqual(parsed, {
|
|
time: '09:00', days: ['mon', 'wed'], tz: 'UTC',
|
|
});
|
|
});
|
|
|
|
test('empty days list expands to every weekday', () => {
|
|
const sb = makeSandbox();
|
|
// Matches the next_run_at convention: empty days = every day.
|
|
// UI needs the expanded form so the schedule renders under all
|
|
// 7 day columns instead of looking unscheduled.
|
|
const parsed = sb.autoSyncWeeklyFromTrigger({
|
|
time: '14:00', days: [], tz: 'UTC',
|
|
});
|
|
deepShapeEqual(parsed.days,
|
|
['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);
|
|
});
|
|
|
|
test('uppercased / mixed-case day abbreviations normalised', () => {
|
|
const sb = makeSandbox();
|
|
const parsed = sb.autoSyncWeeklyFromTrigger({
|
|
time: '09:00', days: ['MON', 'Wed'], tz: 'UTC',
|
|
});
|
|
deepShapeEqual(parsed.days, ['mon', 'wed']);
|
|
});
|
|
|
|
test('null / undefined config returns null', () => {
|
|
const sb = makeSandbox();
|
|
assert.equal(sb.autoSyncWeeklyFromTrigger(null), null);
|
|
assert.equal(sb.autoSyncWeeklyFromTrigger(undefined), null);
|
|
});
|
|
|
|
test('garbage time falls back to 09:00', () => {
|
|
const sb = makeSandbox();
|
|
const parsed = sb.autoSyncWeeklyFromTrigger({
|
|
time: 'garbage', days: ['mon'], tz: 'UTC',
|
|
});
|
|
assert.equal(parsed.time, '09:00');
|
|
});
|
|
|
|
test('missing tz defaults to UTC (not browser tz)', () => {
|
|
// Trigger configs persisted in the DB without a tz field came
|
|
// from the legacy engine path that used server-local naive
|
|
// ``datetime.now()``. PR 2 routes those through the engine's
|
|
// ``_default_tz``, NOT the browser's. So parse-back must surface
|
|
// a stable default (UTC) — the UI should NOT silently rewrite
|
|
// an existing row's tz when the user opens the editor.
|
|
const sb = makeSandbox();
|
|
const parsed = sb.autoSyncWeeklyFromTrigger({
|
|
time: '09:00', days: ['mon'],
|
|
});
|
|
assert.equal(parsed.tz, 'UTC');
|
|
});
|
|
});
|
|
|
|
|
|
describe('buildAutoSyncScheduleState — weekly_time bucketing', () => {
|
|
test('weekly_time owned automation lands in weeklySchedules', () => {
|
|
const sb = makeSandbox();
|
|
const playlists = [{ id: 7, name: 'Daily Mix', source: 'spotify' }];
|
|
const automations = [{
|
|
id: 42,
|
|
name: 'Auto-Sync: Daily Mix',
|
|
enabled: true,
|
|
owned_by: 'auto_sync',
|
|
action_type: 'playlist_pipeline',
|
|
action_config: { playlist_id: '7', all: false },
|
|
trigger_type: 'weekly_time',
|
|
trigger_config: { time: '09:00', days: ['mon', 'wed', 'fri'], tz: 'America/Los_Angeles' },
|
|
next_run: '2026-06-01 16:00:00',
|
|
}];
|
|
const state = sb.buildAutoSyncScheduleState(playlists, automations);
|
|
assert.ok(state.weeklySchedules);
|
|
const sched = state.weeklySchedules[7];
|
|
assert.ok(sched, 'weekly schedule must surface in state.weeklySchedules[playlistId]');
|
|
assert.equal(sched.automation_id, 42);
|
|
assert.equal(sched.time, '09:00');
|
|
deepShapeEqual(sched.days, ['mon', 'wed', 'fri']);
|
|
assert.equal(sched.tz, 'America/Los_Angeles');
|
|
// And NOT in playlistSchedules (mutual exclusion at the bucket level).
|
|
assert.equal(state.playlistSchedules[7], undefined);
|
|
});
|
|
|
|
test('schedule (interval) automation stays in playlistSchedules', () => {
|
|
const sb = makeSandbox();
|
|
const playlists = [{ id: 7, name: 'Daily Mix', source: 'spotify' }];
|
|
const automations = [{
|
|
id: 42,
|
|
owned_by: 'auto_sync',
|
|
action_type: 'playlist_pipeline',
|
|
action_config: { playlist_id: '7', all: false },
|
|
trigger_type: 'schedule',
|
|
trigger_config: { interval: 6, unit: 'hours' },
|
|
enabled: true,
|
|
}];
|
|
const state = sb.buildAutoSyncScheduleState(playlists, automations);
|
|
assert.ok(state.playlistSchedules[7]);
|
|
assert.equal(state.weeklySchedules[7], undefined);
|
|
});
|
|
|
|
test('non-owned weekly_time automation falls through to automationPipelines', () => {
|
|
// Backward-compat: a hand-created weekly_time automation
|
|
// NOT owned by auto_sync must NOT hijack the Weekly Board
|
|
// — it stays as a regular automation pipeline visible on
|
|
// the Automation Pipelines tab.
|
|
const sb = makeSandbox();
|
|
const playlists = [{ id: 7, name: 'Daily Mix', source: 'spotify' }];
|
|
const automations = [{
|
|
id: 99,
|
|
name: 'My Custom Weekly Thing',
|
|
// No owned_by, no Auto-Sync: prefix, no Playlist Auto-Sync group.
|
|
action_type: 'playlist_pipeline',
|
|
action_config: { playlist_id: '7', all: false },
|
|
trigger_type: 'weekly_time',
|
|
trigger_config: { time: '09:00', days: ['mon'], tz: 'UTC' },
|
|
enabled: true,
|
|
}];
|
|
const state = sb.buildAutoSyncScheduleState(playlists, automations);
|
|
assert.equal(state.weeklySchedules[7], undefined);
|
|
assert.equal(state.playlistSchedules[7], undefined);
|
|
assert.equal(state.automationPipelines.length, 1);
|
|
assert.equal(state.automationPipelines[0].id, 99);
|
|
});
|
|
|
|
test('legacy-named (Auto-Sync: prefix) weekly_time still recognised', () => {
|
|
// Rows pre-dating the owned_by column should still be picked
|
|
// up by the legacy name/group fallback.
|
|
const sb = makeSandbox();
|
|
const playlists = [{ id: 7, name: 'Daily Mix', source: 'spotify' }];
|
|
const automations = [{
|
|
id: 99,
|
|
name: 'Auto-Sync: Daily Mix', // legacy convention
|
|
group_name: 'Playlist Auto-Sync',
|
|
action_type: 'playlist_pipeline',
|
|
action_config: { playlist_id: '7', all: false },
|
|
trigger_type: 'weekly_time',
|
|
trigger_config: { time: '09:00', days: ['mon'], tz: 'UTC' },
|
|
enabled: true,
|
|
}];
|
|
const state = sb.buildAutoSyncScheduleState(playlists, automations);
|
|
assert.ok(state.weeklySchedules[7], 'legacy-named auto-sync row should bucket weekly');
|
|
});
|
|
|
|
test('garbage weekly_time config falls through to automationPipelines', () => {
|
|
// Defensive — a hand-edited row with malformed trigger_config
|
|
// should NOT crash state-build. autoSyncWeeklyFromTrigger
|
|
// returns null for non-object configs; the bucket logic
|
|
// routes nulls to automationPipelines as the catch-all.
|
|
const sb = makeSandbox();
|
|
const playlists = [{ id: 7, name: 'Daily Mix', source: 'spotify' }];
|
|
const automations = [{
|
|
id: 42,
|
|
owned_by: 'auto_sync',
|
|
action_type: 'playlist_pipeline',
|
|
action_config: { playlist_id: '7', all: false },
|
|
trigger_type: 'weekly_time',
|
|
trigger_config: null,
|
|
enabled: true,
|
|
}];
|
|
const state = sb.buildAutoSyncScheduleState(playlists, automations);
|
|
assert.equal(state.weeklySchedules[7], undefined);
|
|
assert.equal(state.automationPipelines.length, 1);
|
|
});
|
|
});
|
|
|
|
|
|
describe('autoSyncWeeklyLabel', () => {
|
|
test('multi-day schedule renders ordered day list with time', () => {
|
|
const sb = makeSandbox();
|
|
// Input intentionally in non-canonical order to verify sort.
|
|
const label = sb.autoSyncWeeklyLabel({
|
|
time: '09:00', days: ['fri', 'mon', 'wed'], tz: 'UTC',
|
|
});
|
|
assert.equal(label, 'Mon, Wed, Fri @ 09:00');
|
|
});
|
|
|
|
test('full-week schedule collapses to Daily', () => {
|
|
const sb = makeSandbox();
|
|
const label = sb.autoSyncWeeklyLabel({
|
|
time: '14:30',
|
|
days: ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'],
|
|
tz: 'UTC',
|
|
});
|
|
assert.equal(label, 'Daily @ 14:30');
|
|
});
|
|
|
|
test('single-day schedule shows just that day', () => {
|
|
const sb = makeSandbox();
|
|
const label = sb.autoSyncWeeklyLabel({
|
|
time: '20:00', days: ['sun'], tz: 'UTC',
|
|
});
|
|
assert.equal(label, 'Sun @ 20:00');
|
|
});
|
|
|
|
test('null parsed value returns Unscheduled', () => {
|
|
const sb = makeSandbox();
|
|
assert.equal(sb.autoSyncWeeklyLabel(null), 'Unscheduled');
|
|
});
|
|
|
|
test('empty days array treated as daily (matches engine semantic)', () => {
|
|
const sb = makeSandbox();
|
|
const label = sb.autoSyncWeeklyLabel({
|
|
time: '09:00', days: [], tz: 'UTC',
|
|
});
|
|
assert.equal(label, 'Daily @ 09:00');
|
|
});
|
|
});
|