Implement Phase 3A playlist management
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const {
|
||||
ELIGIBILITY_REASONS,
|
||||
evaluateEntryEligibility,
|
||||
resolveEntrySettings,
|
||||
} = require("../src/lifecycle");
|
||||
|
||||
const NOW = Date.parse("2035-05-06T07:08:09.000Z");
|
||||
|
||||
function readyAsset(overrides = {}) {
|
||||
return { status: "ready", expiration_at: null, ...overrides };
|
||||
}
|
||||
|
||||
function enabledEntry(overrides = {}) {
|
||||
return {
|
||||
enabled: 1,
|
||||
schedule_start_at: null,
|
||||
schedule_end_at: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("an enabled, ready, permanent, unscheduled entry is playable", () => {
|
||||
assert.deepEqual(
|
||||
evaluateEntryEligibility(enabledEntry(), readyAsset(), NOW),
|
||||
{ playable: true, reason: null },
|
||||
);
|
||||
});
|
||||
|
||||
test("disabled entries are not playable", () => {
|
||||
assert.deepEqual(
|
||||
evaluateEntryEligibility(enabledEntry({ enabled: 0 }), readyAsset(), NOW),
|
||||
{ playable: false, reason: ELIGIBILITY_REASONS.DISABLED },
|
||||
);
|
||||
});
|
||||
|
||||
test("schedule start is inclusive", () => {
|
||||
const entry = enabledEntry({ schedule_start_at: NOW });
|
||||
|
||||
assert.deepEqual(evaluateEntryEligibility(entry, readyAsset(), NOW - 1), {
|
||||
playable: false,
|
||||
reason: ELIGIBILITY_REASONS.SCHEDULED_FUTURE,
|
||||
});
|
||||
assert.deepEqual(evaluateEntryEligibility(entry, readyAsset(), NOW), {
|
||||
playable: true,
|
||||
reason: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("schedule end is exclusive", () => {
|
||||
const entry = enabledEntry({ schedule_end_at: NOW });
|
||||
|
||||
assert.deepEqual(evaluateEntryEligibility(entry, readyAsset(), NOW - 1), {
|
||||
playable: true,
|
||||
reason: null,
|
||||
});
|
||||
assert.deepEqual(evaluateEntryEligibility(entry, readyAsset(), NOW), {
|
||||
playable: false,
|
||||
reason: ELIGIBILITY_REASONS.SCHEDULED_ENDED,
|
||||
});
|
||||
});
|
||||
|
||||
test("start-only, end-only, and bounded schedules use UTC epoch milliseconds", () => {
|
||||
assert.equal(
|
||||
evaluateEntryEligibility(
|
||||
enabledEntry({ schedule_start_at: NOW - 1 }),
|
||||
readyAsset(),
|
||||
NOW,
|
||||
).playable,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
evaluateEntryEligibility(
|
||||
enabledEntry({ schedule_end_at: NOW + 1 }),
|
||||
readyAsset(),
|
||||
NOW,
|
||||
).playable,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
evaluateEntryEligibility(
|
||||
enabledEntry({ schedule_start_at: NOW, schedule_end_at: NOW + 1 }),
|
||||
readyAsset(),
|
||||
NOW,
|
||||
).playable,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("only ready assets are playable", () => {
|
||||
for (const status of ["processing", "failed", "deleting"]) {
|
||||
assert.deepEqual(
|
||||
evaluateEntryEligibility(enabledEntry(), readyAsset({ status }), NOW),
|
||||
{ playable: false, reason: ELIGIBILITY_REASONS.ASSET_NOT_READY },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("permanent assets stay playable and expiration is exclusive", () => {
|
||||
assert.equal(
|
||||
evaluateEntryEligibility(enabledEntry(), readyAsset(), NOW).playable,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
evaluateEntryEligibility(
|
||||
enabledEntry(),
|
||||
readyAsset({ expiration_at: NOW + 1 }),
|
||||
NOW,
|
||||
).playable,
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(
|
||||
evaluateEntryEligibility(
|
||||
enabledEntry(),
|
||||
readyAsset({ expiration_at: NOW }),
|
||||
NOW,
|
||||
),
|
||||
{ playable: false, reason: ELIGIBILITY_REASONS.ASSET_EXPIRED },
|
||||
);
|
||||
});
|
||||
|
||||
test("not-playable reasons follow the documented precedence", () => {
|
||||
const asset = readyAsset({ status: "failed", expiration_at: NOW });
|
||||
|
||||
assert.equal(
|
||||
evaluateEntryEligibility(
|
||||
enabledEntry({ enabled: 0, schedule_start_at: NOW + 1 }),
|
||||
asset,
|
||||
NOW,
|
||||
).reason,
|
||||
ELIGIBILITY_REASONS.DISABLED,
|
||||
);
|
||||
assert.equal(
|
||||
evaluateEntryEligibility(
|
||||
enabledEntry({ schedule_start_at: NOW + 1, schedule_end_at: NOW }),
|
||||
asset,
|
||||
NOW,
|
||||
).reason,
|
||||
ELIGIBILITY_REASONS.SCHEDULED_FUTURE,
|
||||
);
|
||||
assert.equal(
|
||||
evaluateEntryEligibility(enabledEntry({ schedule_end_at: NOW }), asset, NOW)
|
||||
.reason,
|
||||
ELIGIBILITY_REASONS.SCHEDULED_ENDED,
|
||||
);
|
||||
assert.equal(
|
||||
evaluateEntryEligibility(enabledEntry(), asset, NOW).reason,
|
||||
ELIGIBILITY_REASONS.ASSET_NOT_READY,
|
||||
);
|
||||
});
|
||||
|
||||
test("mapped camel-case domain values are supported", () => {
|
||||
assert.deepEqual(
|
||||
evaluateEntryEligibility(
|
||||
{ enabled: true, scheduleStartAt: NOW, scheduleEndAt: NOW + 1 },
|
||||
{ status: "ready", expirationAt: NOW + 1 },
|
||||
NOW,
|
||||
),
|
||||
{ playable: true, reason: null },
|
||||
);
|
||||
});
|
||||
|
||||
test("eligibility rejects invalid evaluation clocks", () => {
|
||||
for (const invalid of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1, "2035-01-01"]) {
|
||||
assert.throws(
|
||||
() => evaluateEntryEligibility(enabledEntry(), readyAsset(), invalid),
|
||||
/nowMs must be a non-negative, safe epoch-millisecond integer/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("resolved settings inherit global duration and presentation", () => {
|
||||
const settings = {
|
||||
default_duration_ms: 7_000,
|
||||
default_background_mode: "blurred",
|
||||
default_solid_background_color: "#123456",
|
||||
default_background_darkened: 1,
|
||||
default_blur_px: 20,
|
||||
};
|
||||
|
||||
assert.deepEqual(resolveEntrySettings({}, settings), {
|
||||
durationMs: 7_000,
|
||||
presentation: {
|
||||
backgroundMode: "blurred",
|
||||
backgroundColor: "#123456",
|
||||
backgroundDarkened: true,
|
||||
blurPx: 20,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("entry overrides are independent and blur always remains global", () => {
|
||||
const settings = {
|
||||
defaultDurationMs: 7_000,
|
||||
defaultBackgroundMode: "blurred",
|
||||
defaultSolidBackgroundColor: "#123456",
|
||||
defaultBackgroundDarkened: true,
|
||||
defaultBlurPx: 20,
|
||||
};
|
||||
const entry = {
|
||||
durationOverrideMs: 9_000,
|
||||
backgroundModeOverride: "solid",
|
||||
backgroundColorOverride: "#abcdef",
|
||||
backgroundDarkenedOverride: false,
|
||||
blurPx: 999,
|
||||
};
|
||||
|
||||
assert.deepEqual(resolveEntrySettings(entry, settings), {
|
||||
durationMs: 9_000,
|
||||
presentation: {
|
||||
backgroundMode: "solid",
|
||||
backgroundColor: "#abcdef",
|
||||
backgroundDarkened: false,
|
||||
blurPx: 20,
|
||||
},
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user