Files
cinny/src/app/components/seasonal/seasonSchedule.test.ts
T
jaredandClaude Opus 4.8 d416c62b4c fix(seasonal): auto theme re-evaluates over time; auto clears chat background
- The "auto" seasonal theme was computed once at mount, so a long-lived session
  never crossed a season/holiday-window boundary. SeasonalEffect now re-evaluates
  on an hourly ticker (auto mode only) AND refreshes on entering auto — the
  interval only runs while auto, so a stale mount-time timestamp would otherwise
  resurface on a pinned/off → auto switch (the exact frozen-at-mount bug, caught
  in review). The decision is extracted to a pure resolveSeasonTheme(override,
  now) in seasonSchedule.ts (removing an unsafe cast) and unit-tested.

- Selecting seasonal "auto" while a chat background was set was a silent no-op:
  the seasonal picker only cleared the background for a *specific* theme, and the
  overlay is suppressed while a background is set. Now any active seasonal mode
  ("auto" included) clears the background; only "off" leaves it — symmetric with
  the background picker (which sets seasonal "off"). The overlay guard stays as a
  backstop for legacy persisted state.

Bug-hunt findings from LOTUS_TODO. Three review passes (the 2nd caught the
switch-into-auto staleness); +2 unit tests. Gate-green (tsc, eslint, prettier,
922 tests, build).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 17:34:51 -04:00

92 lines
3.7 KiB
TypeScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
getActiveSeason,
resolveSeasonTheme,
SEASON_SCHEDULE,
SEASON_DATE_RANGES,
} from './seasonSchedule';
import { SeasonTheme } from './types';
// Date(year, monthIndex0, day)
const on = (monthIndex0: number, day: number): Date => new Date(2026, monthIndex0, day);
test('each theme activates on a representative day in its window', () => {
const cases: Array<[Date, SeasonTheme]> = [
[on(11, 31), 'newyear'], // Dec 31
[on(0, 1), 'newyear'], // Jan 1
[on(0, 25), 'lunar'], // Jan 25
[on(1, 3), 'lunar'], // Feb 3
[on(1, 12), 'valentines'], // Feb 12
[on(2, 16), 'stpatricks'], // Mar 16
[on(3, 1), 'aprilfools'], // Apr 1
[on(3, 21), 'earthday'], // Apr 21
[on(8, 12), 'arcade'], // Sep 12
[on(8, 25), 'autumn'], // Sep 25
[on(9, 20), 'halloween'], // Oct 20
[on(10, 1), 'halloween'], // Nov 1
[on(11, 15), 'christmas'], // Dec 15
];
for (const [date, expected] of cases) {
assert.equal(getActiveSeason(date), expected, `${date.toDateString()} -> ${expected}`);
}
});
test('priority order resolves overlapping windows (Deep Space outranks Autumn)', () => {
// Oct 4-10 is inside Autumn's Oct<=14 window too; Deep Space comes first.
assert.equal(getActiveSeason(on(9, 5)), 'deepspace'); // Oct 5
// Oct 12 is past Deep Space -> falls through to Autumn.
assert.equal(getActiveSeason(on(9, 12)), 'autumn');
});
test('New Year outranks Lunar New Year on Jan 1-2', () => {
assert.equal(getActiveSeason(on(0, 1)), 'newyear');
// Jan 22+ is past New Year -> Lunar.
assert.equal(getActiveSeason(on(0, 22)), 'lunar');
});
test('returns null on an off-season day', () => {
assert.equal(getActiveSeason(on(5, 15)), null); // Jun 15
assert.equal(getActiveSeason(on(6, 4)), null); // Jul 4
});
test('window boundaries are inclusive at both ends', () => {
assert.equal(getActiveSeason(on(1, 10)), 'valentines'); // Feb 10 start
assert.equal(getActiveSeason(on(1, 15)), 'valentines'); // Feb 15 end
assert.equal(getActiveSeason(on(1, 16)), null); // Feb 16 just after
});
test('resolveSeasonTheme: off → null, pinned → that theme, auto → active season', () => {
const halloweenTs = on(9, 20).getTime(); // Oct 20 → halloween season
const offSeasonTs = on(5, 15).getTime(); // Jun 15 → no season
// 'off' never renders, regardless of date.
assert.equal(resolveSeasonTheme('off', halloweenTs), null);
// A pinned theme renders regardless of date (even off-season).
assert.equal(resolveSeasonTheme('christmas', offSeasonTs), 'christmas');
// 'auto' tracks the active season for the given instant.
assert.equal(resolveSeasonTheme('auto', halloweenTs), 'halloween');
assert.equal(resolveSeasonTheme('auto', offSeasonTs), null);
});
test('resolveSeasonTheme: auto re-evaluates as `now` advances across a boundary', () => {
// The same 'auto' override yields different themes at different instants — this
// is what the SeasonalEffect ticker relies on (incl. the switch-into-auto case
// where `now` must be current, not a stale mount value).
assert.equal(resolveSeasonTheme('auto', on(9, 20).getTime()), 'halloween'); // Oct 20
assert.equal(resolveSeasonTheme('auto', on(11, 15).getTime()), 'christmas'); // Dec 15
assert.equal(resolveSeasonTheme('auto', on(6, 4).getTime()), null); // Jul 4
});
test('SEASON_DATE_RANGES has a label for every scheduled theme', () => {
assert.equal(SEASON_SCHEDULE.length, 11);
const themes = SEASON_SCHEDULE.map((e) => e.theme);
assert.equal(new Set(themes).size, 11); // unique
for (const t of themes) {
assert.ok(
typeof SEASON_DATE_RANGES[t] === 'string' && SEASON_DATE_RANGES[t].length > 0,
`missing date range for ${t}`,
);
}
});