mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 07:26:30 +00:00
Add a dayTintProvider plugin hook
Lets a plugin colour-code days in the Plan sidebar. trip-segments can already announce "Kanazawa begins" in the day plan, but it can't make day 12 look like it belongs to Kanazawa. dayScheduleProvider can't cover this: it renders a fixed row, and it's capped at 60 items per provider, so one item per day would tint the first 60 days of a six-month trip and silently stop. This hook is bounded by day count instead. GET /api/day-tints/:tripId returns per-day tones for three regions of a day card - the number badge (also the mobile day chip), the header row, and the expanded activity list. `tone` is a shorthand filling every region a contribution doesn't name; unnamed regions render as they do today. Tones only, never hex, so the host can pick the alpha per theme and per region. One contribution per day, resolved whole: first entry wins within a provider, first granted provider wins across plugins.
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* DayTintsController (#plugins): the per-day colour plugins put behind the day cards
|
||||
* via the dayTintProvider hook. Same gate + fail-safe + server-side normalization as
|
||||
* the day-schedule hook, plus the rule this hook exists to make deterministic: a day
|
||||
* takes exactly one tint, so a contested day must resolve the same way every request
|
||||
* or the card flickers between two plugins' colours.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { canAccessTrip, pluginsEnabled, tripDays } = vi.hoisted(() => ({
|
||||
canAccessTrip: vi.fn((tripId: number, userId: number) => (tripId === 1 && userId === 5 ? { id: 1 } : undefined)),
|
||||
pluginsEnabled: vi.fn(() => true),
|
||||
tripDays: { value: [{ id: 10 }, { id: 11 }] as Array<{ id: number }> },
|
||||
}));
|
||||
vi.mock('../../../src/db/database', () => ({
|
||||
db: { prepare: () => ({ all: () => tripDays.value }) },
|
||||
canAccessTrip,
|
||||
}));
|
||||
import { db as dbConn } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
|
||||
|
||||
import { DayTintsController } from '../../../src/nest/plugins/day-tints.controller';
|
||||
import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any;
|
||||
function controller(invoke: (id: string) => unknown, providers = ['p1']) {
|
||||
const runtime = {
|
||||
providersOf: vi.fn(() => providers),
|
||||
invokeHook: vi.fn(async (id: string) => invoke(id)),
|
||||
} as unknown as PluginRuntimeService;
|
||||
return { c: new DayTintsController(runtime, new DatabaseService(dbConn)), runtime };
|
||||
}
|
||||
const tint = (over: Record<string, unknown> = {}) => ({ dayId: 10, tone: 'success', ...over });
|
||||
|
||||
describe('DayTintsController', () => {
|
||||
beforeEach(() => { pluginsEnabled.mockReturnValue(true); canAccessTrip.mockReturnValue({ id: 1 } as never); });
|
||||
|
||||
it('gates: disabled / no user / non-member all return [] (no plugin calls on the first)', async () => {
|
||||
pluginsEnabled.mockReturnValue(false);
|
||||
const x = controller(() => [tint()]);
|
||||
expect(await x.c.get('1', req(5))).toEqual({ tints: [] });
|
||||
expect(x.runtime.providersOf).not.toHaveBeenCalled();
|
||||
pluginsEnabled.mockReturnValue(true);
|
||||
|
||||
expect((await controller(() => [tint()]).c.get('1', req(undefined))).tints).toEqual([]);
|
||||
canAccessTrip.mockReturnValue(undefined as never);
|
||||
expect((await controller(() => [tint()]).c.get('1', req(5))).tints).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps a valid tint, stamps pluginId, and caps the label', async () => {
|
||||
const { c } = controller(() => [tint({ label: 'x'.repeat(200) })]);
|
||||
const out = (await c.get('1', req(5))).tints;
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]).toMatchObject({ pluginId: 'p1', dayId: 10 });
|
||||
expect(out[0].label).toHaveLength(60);
|
||||
});
|
||||
|
||||
it('spreads the `tone` shorthand across all three regions', async () => {
|
||||
const { c } = controller(() => [tint({ tone: 'warn' })]);
|
||||
const out = (await c.get('1', req(5))).tints;
|
||||
expect(out[0]).toMatchObject({ badgeTone: 'warn', headerTone: 'warn', activityTone: 'warn' });
|
||||
});
|
||||
|
||||
it('lets a named region override the shorthand', async () => {
|
||||
const { c } = controller(() => [tint({ tone: 'warn', badgeTone: 'danger' })]);
|
||||
const out = (await c.get('1', req(5))).tints;
|
||||
expect(out[0]).toMatchObject({ badgeTone: 'danger', headerTone: 'warn', activityTone: 'warn' });
|
||||
});
|
||||
|
||||
it('tints ONLY the named regions when there is no shorthand', async () => {
|
||||
// The point of the split: mark the leg on the badge, leave the dense activity
|
||||
// list untouched. An absent region must stay absent, not fall back to a colour.
|
||||
const { c } = controller(() => [{ dayId: 10, badgeTone: 'success' }]);
|
||||
const out = (await c.get('1', req(5))).tints;
|
||||
expect(out[0]).toEqual({ pluginId: 'p1', dayId: 10, badgeTone: 'success' });
|
||||
expect(out[0].headerTone).toBeUndefined();
|
||||
expect(out[0].activityTone).toBeUndefined();
|
||||
});
|
||||
|
||||
it('degrades a bogus tone to default rather than dropping the region', async () => {
|
||||
const { c } = controller(() => [
|
||||
tint({ tone: 'chartreuse' }),
|
||||
{ dayId: 11, headerTone: 42 },
|
||||
]);
|
||||
const out = (await c.get('1', req(5))).tints;
|
||||
expect(out[0]).toMatchObject({ badgeTone: 'default', headerTone: 'default', activityTone: 'default' });
|
||||
// A named-but-bogus region is still a request to tint it; unnamed ones stay off.
|
||||
expect(out[1]).toEqual({ pluginId: 'p1', dayId: 11, headerTone: 'default' });
|
||||
});
|
||||
|
||||
it('treats an entry with no tone at all as "tint this day", default everywhere', async () => {
|
||||
const { c } = controller(() => [{ dayId: 10 }, { dayId: 11, tone: undefined }]);
|
||||
const out = (await c.get('1', req(5))).tints;
|
||||
for (const t of out) expect(t).toMatchObject({ badgeTone: 'default', headerTone: 'default', activityTone: 'default' });
|
||||
expect(out[0].label).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops tints on another trip's day, on a non-numeric day, and non-objects", async () => {
|
||||
const { c } = controller(() => [
|
||||
tint({ dayId: 999 }), // not a day of this trip
|
||||
tint({ dayId: 'x' }), // non-numeric day
|
||||
tint({ dayId: null }),
|
||||
null, // non-object
|
||||
tint({ dayId: 11 }),
|
||||
]);
|
||||
const out = (await c.get('1', req(5))).tints;
|
||||
expect(out.map(t => t.dayId)).toEqual([11]);
|
||||
});
|
||||
|
||||
it('takes a provider\'s FIRST answer for a day it tints twice', async () => {
|
||||
const { c } = controller(() => [
|
||||
tint({ dayId: 10, tone: 'success', label: 'first' }),
|
||||
tint({ dayId: 10, tone: 'danger', label: 'second' }),
|
||||
]);
|
||||
const out = (await c.get('1', req(5))).tints;
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]).toMatchObject({ badgeTone: 'success', label: 'first' });
|
||||
});
|
||||
|
||||
it('resolves a contested day WHOLE — the loser cannot fill in the winner\'s empty regions', async () => {
|
||||
const { c } = controller(
|
||||
(id) => (id === 'first'
|
||||
? [{ dayId: 10, badgeTone: 'success' }] // badge only
|
||||
: [{ dayId: 10, tone: 'danger' }, { dayId: 11, tone: 'warn' }]), // would fill all three
|
||||
['first', 'second'],
|
||||
);
|
||||
const out = (await c.get('1', req(5))).tints;
|
||||
// Day 10 is `first`'s badge-only contribution — `second` contributes nothing to
|
||||
// it, so two plugins can never each own part of one card. Day 11 is uncontested.
|
||||
expect(out).toEqual([
|
||||
{ pluginId: 'first', dayId: 10, badgeTone: 'success' },
|
||||
{ pluginId: 'second', dayId: 11, badgeTone: 'warn', headerTone: 'warn', activityTone: 'warn' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips a failing provider without losing the healthy one', async () => {
|
||||
const { c } = controller(
|
||||
(id) => (id === 'bad' ? (() => { throw new Error('boom'); })() : [tint()]),
|
||||
['bad', 'good'],
|
||||
);
|
||||
const out = (await c.get('1', req(5))).tints;
|
||||
expect(out.map(t => t.pluginId)).toEqual(['good']);
|
||||
});
|
||||
|
||||
it('bounds work on an all-invalid oversized payload', async () => {
|
||||
const huge = Array.from({ length: 100_000 }, () => ({ dayId: 999, tone: 'success' })); // foreign day -> all dropped
|
||||
const { c } = controller(() => huge);
|
||||
expect((await c.get('1', req(5))).tints).toEqual([]);
|
||||
});
|
||||
|
||||
it('slices the raw array at 2000 before validating it', async () => {
|
||||
// 2000 junk entries, then a legitimate one — past the slice, so it never lands.
|
||||
const raw = [...Array.from({ length: 2000 }, () => ({ dayId: 999 })), tint({ dayId: 11 })];
|
||||
const { c } = controller(() => raw);
|
||||
expect((await c.get('1', req(5))).tints).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores a non-array payload', async () => {
|
||||
const { c } = controller(() => ({ tints: [tint()] }));
|
||||
expect((await c.get('1', req(5))).tints).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips the day lookup entirely when no provider is active', async () => {
|
||||
const { c, runtime } = controller(() => [tint()], []);
|
||||
expect((await c.get('1', req(5))).tints).toEqual([]);
|
||||
expect(runtime.invokeHook).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -74,6 +74,20 @@ describe('providersOf enforces the hook:* grant', () => {
|
||||
expect(s.providersOf('dayScheduleProvider')).toEqual(['times']);
|
||||
});
|
||||
|
||||
it('maps dayTintProvider to hook:day-tint-provider', () => {
|
||||
const s = makeSupervisor();
|
||||
put(s, 'segments', 'active', ['dayTintProvider'], ['hook:day-tint-provider']);
|
||||
put(s, 'ungranted', 'active', ['dayTintProvider'], ['hook:day-schedule-provider']); // wrong grant
|
||||
expect(s.providersOf('dayTintProvider')).toEqual(['segments']);
|
||||
});
|
||||
|
||||
it('returns tint providers in insertion order — the controller lets the first win a contested day', () => {
|
||||
const s = makeSupervisor();
|
||||
put(s, 'first', 'active', ['dayTintProvider'], ['hook:day-tint-provider']);
|
||||
put(s, 'second', 'active', ['dayTintProvider'], ['hook:day-tint-provider']);
|
||||
expect(s.providersOf('dayTintProvider')).toEqual(['first', 'second']);
|
||||
});
|
||||
|
||||
it('maps notificationChannel to hook:notification-channel', () => {
|
||||
const s = makeSupervisor();
|
||||
put(s, 'gotify', 'active', ['notificationChannel'], ['hook:notification-channel']);
|
||||
|
||||
Reference in New Issue
Block a user