Files
cinny/src/app/state/toast.test.ts
T

171 lines
6.2 KiB
TypeScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createStore } from 'jotai';
import { toastQueueAtom, dismissToastAtom, ToastNotif, createDownloadToast } from './toast';
// The queue lives in an unexported baseAtom; we drive the two write-only setters
// (toastQueueAtom append + null no-op guard, dismissToastAtom remove-by-id)
// through a jotai store and read back via toastQueueAtom's getter.
const makeToast = (id: string, sticky?: boolean): ToastNotif => ({
id,
displayName: `name-${id}`,
body: `body-${id}`,
roomName: `room-${id}`,
roomId: `!${id}:server`,
...(sticky ? { sticky: true } : {}),
});
test('starts empty', () => {
const store = createStore();
assert.deepEqual(store.get(toastQueueAtom), []);
});
test('toastQueueAtom appends in order', () => {
const store = createStore();
const a = makeToast('a');
const b = makeToast('b');
store.set(toastQueueAtom, a);
store.set(toastQueueAtom, b);
assert.deepEqual(
store.get(toastQueueAtom).map((t) => t.id),
['a', 'b'],
);
assert.equal(store.get(toastQueueAtom)[0], a);
});
test('toastQueueAtom ignores null (no-op guard)', () => {
const store = createStore();
store.set(toastQueueAtom, makeToast('a'));
store.set(toastQueueAtom, null);
assert.deepEqual(
store.get(toastQueueAtom).map((t) => t.id),
['a'],
);
});
test('toastQueueAtom allows duplicate ids (no dedupe)', () => {
const store = createStore();
store.set(toastQueueAtom, makeToast('a'));
store.set(toastQueueAtom, makeToast('a'));
assert.equal(store.get(toastQueueAtom).length, 2);
});
test('dismissToastAtom removes the matching id only', () => {
const store = createStore();
store.set(toastQueueAtom, makeToast('a'));
store.set(toastQueueAtom, makeToast('b'));
store.set(toastQueueAtom, makeToast('c'));
store.set(dismissToastAtom, 'b');
assert.deepEqual(
store.get(toastQueueAtom).map((t) => t.id),
['a', 'c'],
);
});
test('dismissToastAtom removes every entry sharing the id', () => {
const store = createStore();
store.set(toastQueueAtom, makeToast('a'));
store.set(toastQueueAtom, makeToast('a'));
store.set(toastQueueAtom, makeToast('b'));
store.set(dismissToastAtom, 'a');
assert.deepEqual(
store.get(toastQueueAtom).map((t) => t.id),
['b'],
);
});
test('dismissToastAtom for an unknown id is a no-op', () => {
const store = createStore();
store.set(toastQueueAtom, makeToast('a'));
store.set(dismissToastAtom, 'missing');
assert.deepEqual(
store.get(toastQueueAtom).map((t) => t.id),
['a'],
);
});
test('toastQueueAtom caps at 5, dropping the oldest non-sticky', () => {
const store = createStore();
// Append 7 transient toasts; the queue should keep only the newest 5.
for (let i = 0; i < 7; i += 1) store.set(toastQueueAtom, makeToast(`t${i}`));
assert.deepEqual(
store.get(toastQueueAtom).map((t) => t.id),
['t2', 't3', 't4', 't5', 't6'],
);
});
test('toastQueueAtom never drops a sticky toast, even over cap', () => {
const store = createStore();
store.set(toastQueueAtom, makeToast('sticky-old', true));
for (let i = 0; i < 7; i += 1) store.set(toastQueueAtom, makeToast(`t${i}`));
const ids = store.get(toastQueueAtom).map((t) => t.id);
// The sticky action toast survives; the oldest non-sticky ones are dropped.
assert.ok(ids.includes('sticky-old'));
assert.ok(ids.includes('t6')); // newest kept
assert.ok(!ids.includes('t0')); // oldest non-sticky dropped
assert.equal(ids.length, 5);
});
test('toastQueueAtom queue of all-sticky toasts is allowed to exceed the cap', () => {
const store = createStore();
for (let i = 0; i < 7; i += 1) store.set(toastQueueAtom, makeToast(`s${i}`, true));
// Nothing droppable → all 7 retained rather than silently losing action toasts.
assert.equal(store.get(toastQueueAtom).length, 7);
});
test('toastQueueAtom keeps a new transient toast even when the cap is full of stickies', () => {
const store = createStore();
// Fill the cap with sticky action toasts, then a normal message toast arrives.
for (let i = 0; i < 5; i += 1) store.set(toastQueueAtom, makeToast(`s${i}`, true));
store.set(toastQueueAtom, makeToast('fresh'));
const ids = store.get(toastQueueAtom).map((t) => t.id);
// The newest is never the one dropped — the queue stretches instead of eating it.
assert.ok(ids.includes('fresh'));
assert.equal(ids.length, 6);
});
// --- #80: eviction prefers toasts that have already been visible a while ---
test('toastQueueAtom prefers evicting an old-enough toast over a fresh one when both exist', (t) => {
t.mock.timers.enable({ apis: ['Date'] });
const store = createStore();
store.set(toastQueueAtom, makeToast('old')); // t = 0
t.mock.timers.tick(2000); // 'old' is now well past the 1.5s min-visible mark
store.set(toastQueueAtom, makeToast('mid')); // t = 2000, too young to prefer
for (let i = 0; i < 4; i += 1) store.set(toastQueueAtom, makeToast(`n${i}`)); // fills to cap + 1
const ids = store.get(toastQueueAtom).map((toast) => toast.id);
// 'old' has been visible long enough to be preferred for eviction over the
// still-young 'mid', even though 'old' isn't the only non-sticky candidate.
assert.ok(!ids.includes('old'));
assert.ok(ids.includes('mid'));
assert.equal(ids.length, 5);
});
test('toastQueueAtom falls back to oldest-first when no toast is old enough yet', () => {
const store = createStore();
// All appended back-to-back within the same tick — none has reached the
// 1.5s min-visible mark, so eviction falls back to plain oldest-first.
for (let i = 0; i < 7; i += 1) store.set(toastQueueAtom, makeToast(`t${i}`));
assert.deepEqual(
store.get(toastQueueAtom).map((toast) => toast.id),
['t2', 't3', 't4', 't5', 't6'],
);
});
test('createDownloadToast: filename in body, no room navigation, unique ids', () => {
const a = createDownloadToast('photo.jpg');
assert.equal(a.displayName, 'Downloaded');
assert.equal(a.body, 'photo.jpg');
// roomId empty + an onClick present → clicking dismisses without navigating to a room.
assert.equal(a.roomId, '');
assert.equal(a.roomName, '');
assert.equal(typeof a.onClick, 'function');
const b = createDownloadToast('photo.jpg');
assert.notEqual(a.id, b.id);
});