commit 1635ca7f3bfe77231a6c665ddfc3f803fa02b10a
parent 2f215eb731fc785551a5ce100c0cc5c69ae70ef6
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 11 Sep 2026 11:45:43 -0400
common: the sync scheduler's two answers, pinned against each other
S2's tests. `common/jobs/syncScheduler.ts` had no unit test at all — its only
coverage was `editor/e2e/scheduler.spec.ts` driving the live tick — so this
file is new and covers the pre-model rules it now has to preserve as well as
the ones the model adds.
Plan test 6 is the second case: a focus channel one minute overdue comes back
before a low channel a day overdue. The concurrency cap slices the due list
from the front, so the ordering IS the policy.
The rest are the invariants that are easy to break later. Most-overdue-first
still decides WITHIN a tier. Ties all the way down keep the input order, so
the alphabetical tiebreak `listChannelConfigs` supplies is untouched. An
empty document returns exactly what the function returned before the model
existed. Both presets: `overrides:{sync:"paused"}` on a normal base skips
silently, `overrides:{sync:"normal"}` on a paused base still syncs. Focus
does not rescue a channel paused for sync — paused wins over focus, per
operation. The legacy `excludeFromSync` flag still skips, which is the thing
S5 is allowed to delete and nothing before it is.
The last pair is the one that costs something to get wrong: the set
`buildScheduleView` marks `autoSyncEligible` and the set `selectDueChannels`
returns are asserted to be the same set, over a corpus carrying every skip
reason at once. A console that disagrees with the scheduler is a bug report
nobody can reproduce.
Models are built through `sanitizeChannelPriority`, never by hand, so a test
can only assert over a document the writer could actually produce.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
1 file changed, 255 insertions(+), 0 deletions(-)
diff --git a/common/jobs/syncScheduler.test.ts b/common/jobs/syncScheduler.test.ts
@@ -0,0 +1,255 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import type { ChannelConfig } from "../lib/channelConfig";
+import {
+ sanitizeChannelPriority,
+ type ChannelPriority,
+} from "../lib/channelPriority";
+import { defaultSyncScheduler } from "../lib/settings";
+import type { SyncSchedulerSettings } from "../lib/settings";
+import { emptySchedulerState } from "./syncSchedulerState";
+import {
+ buildScheduleView,
+ selectDueChannels,
+ type ChannelEntry,
+} from "./syncScheduler";
+
+// Run with:
+// pnpm --filter yt-dlp-transcript-common exec tsx --test jobs/syncScheduler.test.ts
+//
+// THE SYNC HALF OF plans/channel-priority.md (S2). `selectDueChannels` and
+// `buildScheduleView` are the two places the model reaches the cron sync, and
+// the pair must agree: the console cannot show a channel as eligible that the
+// scheduler will not schedule.
+//
+// Everything here is pure — no server, no settings file. The model is built
+// through `sanitizeChannelPriority` rather than by hand so a test can only
+// assert over a document the writer could actually produce.
+
+const HOUR = 60 * 60_000;
+const DAY = 24 * HOUR;
+const NOW = Date.parse("2026-09-11T12:00:00.000Z");
+
+function scheduler(
+ over: Partial<SyncSchedulerSettings> = {},
+): SyncSchedulerSettings {
+ return {
+ ...defaultSyncScheduler(),
+ enabled: true,
+ defaultIntervalMinutes: 60,
+ quietHoursStart: null,
+ quietHoursEnd: null,
+ ...over,
+ };
+}
+
+// A channel that IS due: last synced `overdueMs` past its 60-minute interval.
+function channel(
+ slug: string,
+ overdueMs: number,
+ over: Partial<ChannelConfig> = {},
+): ChannelEntry {
+ return {
+ slug,
+ config: {
+ handling: "youtube",
+ name: slug,
+ url: `https://www.youtube.com/@${slug}/videos`,
+ lastSyncedAt: new Date(NOW - HOUR - overdueMs).toISOString(),
+ ...over,
+ },
+ };
+}
+
+function model(doc: unknown): ChannelPriority {
+ return sanitizeChannelPriority(doc);
+}
+
+const NO_MODEL = sanitizeChannelPriority(undefined);
+
+function due(
+ channels: ChannelEntry[],
+ priority: ChannelPriority = NO_MODEL,
+ focusSlugs: string[] = [],
+) {
+ return selectDueChannels({
+ channels,
+ scheduler: scheduler(),
+ state: emptySchedulerState(),
+ activeSlugs: new Set<string>(),
+ now: NOW,
+ priority,
+ focusSlugs,
+ });
+}
+
+test("with no priority document the order is most-overdue-first, as before", () => {
+ const r = due([
+ channel("a", 1 * 60_000),
+ channel("b", 1 * DAY),
+ channel("c", 1 * HOUR),
+ ]);
+ assert.deepEqual(r.due, ["b", "c", "a"]);
+ assert.deepEqual(r.skipped, []);
+});
+
+test("a focus channel one minute overdue outranks a low channel a day overdue", () => {
+ // Plan test 6. The concurrency cap slices this list from the front, so the
+ // ordering IS the policy: whoever is first gets the tick's slots.
+ const r = due(
+ [channel("low-one", 1 * DAY), channel("focused", 1 * 60_000)],
+ model({ channels: { "low-one": { tier: "low" } } }),
+ ["focused"],
+ );
+ assert.deepEqual(r.due, ["focused", "low-one"]);
+});
+
+test("tier orders before overdue, and rank orders inside a tier", () => {
+ const r = due(
+ [
+ channel("low-stale", 10 * DAY),
+ channel("normal-fresh", 1 * 60_000),
+ channel("focus-rank-2", 1 * 60_000),
+ channel("focus-rank-1", 1 * 60_000),
+ channel("focus-unranked", 5 * DAY),
+ ],
+ model({
+ channels: {
+ "low-stale": { tier: "low" },
+ "focus-rank-1": { tier: "normal", rank: 1 },
+ "focus-rank-2": { tier: "normal", rank: 2 },
+ },
+ }),
+ ["focus-rank-1", "focus-rank-2", "focus-unranked"],
+ );
+ // focus (rank 1, rank 2, then unranked however stale) -> normal -> low.
+ assert.deepEqual(r.due, [
+ "focus-rank-1",
+ "focus-rank-2",
+ "focus-unranked",
+ "normal-fresh",
+ "low-stale",
+ ]);
+});
+
+test("most-overdue-first survives WITHIN a tier", () => {
+ const r = due(
+ [channel("n-fresh", 1 * 60_000), channel("n-stale", 1 * DAY)],
+ model({ channels: {} }),
+ );
+ assert.deepEqual(r.due, ["n-stale", "n-fresh"]);
+});
+
+test("ties all the way down keep the input order", () => {
+ // `listChannelConfigs` hands this function slug order and the sort is stable,
+ // so the pre-model tiebreak (alphabetical) is preserved by not touching it.
+ const r = due([channel("b", 1 * HOUR), channel("a", 1 * HOUR)]);
+ assert.deepEqual(r.due, ["b", "a"]);
+});
+
+test("a sync override of paused skips while the base tier stays normal", () => {
+ // The migrated `excludeFromSync` shape: stop syncing, keep every other lane.
+ const priority = model({
+ channels: { parked: { tier: "normal", overrides: { sync: "paused" } } },
+ });
+ assert.equal(priority.channels.parked.tier, "normal");
+ const r = due([channel("parked", 1 * DAY), channel("live", 1 * HOUR)], priority);
+ assert.deepEqual(r.due, ["live"]);
+ // Silently, like every other configuration gate in the loop — the run log is
+ // for holds worth reading (backoff, already running), not for "this channel
+ // does not auto-sync".
+ assert.deepEqual(r.skipped, []);
+});
+
+test("a base-paused channel with a sync override of normal still syncs", () => {
+ // The "sync only" preset (the operator's omnibased case): paused everywhere
+ // the auto lanes look, still keeping its playlist and metadata current.
+ const priority = model({
+ channels: { omnibased: { tier: "paused", overrides: { sync: "normal" } } },
+ });
+ assert.equal(priority.channels.omnibased.overrides?.sync, "normal");
+ const r = due([channel("omnibased", 1 * HOUR)], priority);
+ assert.deepEqual(r.due, ["omnibased"]);
+});
+
+test("a base-paused channel with no sync override never becomes due", () => {
+ const r = due(
+ [channel("off", 10 * DAY)],
+ model({ channels: { off: { tier: "paused" } } }),
+ );
+ assert.deepEqual(r.due, []);
+ assert.deepEqual(r.skipped, []);
+});
+
+test("focus does not rescue a channel paused for sync", () => {
+ // Focus wins over the stored tier; paused wins over focus, per operation.
+ const r = due(
+ [channel("held", 1 * DAY), channel("other", 1 * HOUR)],
+ model({ channels: { held: { tier: "paused" } } }),
+ ["held"],
+ );
+ assert.deepEqual(r.due, ["other"]);
+});
+
+test("the legacy excludeFromSync flag still skips, beside the new predicate", () => {
+ // S5 deletes this flag. Until its migration has run, it is the only thing
+ // some channels carry, so both skips have to stand.
+ const r = due(
+ [
+ channel("legacy", 1 * DAY, { excludeFromSync: true }),
+ channel("modern", 1 * HOUR),
+ ],
+ model({ channels: {} }),
+ );
+ assert.deepEqual(r.due, ["modern"]);
+ assert.deepEqual(r.skipped, []);
+});
+
+test("the schedule projection agrees with the scheduler about who is skipped", () => {
+ const channels = [
+ channel("normal", 1 * HOUR),
+ channel("sync-paused", 1 * DAY),
+ channel("base-paused", 1 * DAY),
+ channel("sync-only", 1 * HOUR),
+ channel("legacy", 1 * DAY, { excludeFromSync: true }),
+ channel("no-url", 1 * DAY, { url: undefined }),
+ channel("interval-off", 1 * DAY, { syncIntervalMinutes: 0 }),
+ ];
+ const priority = model({
+ channels: {
+ "sync-paused": { tier: "normal", overrides: { sync: "paused" } },
+ "base-paused": { tier: "paused" },
+ "sync-only": { tier: "paused", overrides: { sync: "normal" } },
+ },
+ });
+ const eligible = buildScheduleView({
+ channels,
+ scheduler: scheduler(),
+ state: emptySchedulerState(),
+ now: NOW,
+ priority,
+ })
+ .filter((v) => v.autoSyncEligible)
+ .map((v) => v.slug);
+ assert.deepEqual(eligible, ["normal", "sync-only"]);
+ // Every channel here is overdue if it is eligible at all, so the two answers
+ // are the same set — which is the invariant the console depends on.
+ assert.deepEqual([...due(channels, priority).due].sort(), [...eligible].sort());
+});
+
+test("the projection is unchanged by an empty priority document", () => {
+ const view = buildScheduleView({
+ channels: [channel("a", 1 * HOUR), channel("b", 1 * DAY, { excludeFromSync: true })],
+ scheduler: scheduler(),
+ state: emptySchedulerState(),
+ now: NOW,
+ priority: NO_MODEL,
+ });
+ assert.deepEqual(
+ view.map((v) => [v.slug, v.autoSyncEligible]),
+ [
+ ["a", true],
+ ["b", false],
+ ],
+ );
+});