Files
openwork/apps/app/tests/session-title-marquee.test.tsx

193 lines
6.3 KiB
TypeScript

/** @jsxImportSource react */
import { describe, expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import {
resolveSessionTitleHiddenEdges,
resolveSessionTitleTooltip,
SessionTitle,
} from "../src/react-app/domains/session/sidebar/session-title";
import {
createSessionTitleMarqueeController,
SESSION_TITLE_HOVER_DELAY_MS,
SESSION_TITLE_SPEED_PX_PER_SECOND,
type SessionTitleMarqueeState,
} from "../src/react-app/domains/session/sidebar/session-title-marquee";
type TimerCallback = () => void;
function setup(widths: { viewport: number; text: number }, reducedMotion = false) {
const states: SessionTitleMarqueeState[] = [];
const timers = new Map<number, TimerCallback>();
let timerId = 0;
let motionReduced = reducedMotion;
const viewport = { clientWidth: widths.viewport, scrollWidth: widths.viewport };
const text = { clientWidth: widths.text, scrollWidth: widths.text };
const controller = createSessionTitleMarqueeController({
getReducedMotion: () => motionReduced,
getText: () => text,
getViewport: () => viewport,
onChange: (state) => states.push(state),
setTimer: (callback, delayMs) => {
expect(delayMs).toBe(SESSION_TITLE_HOVER_DELAY_MS);
timerId += 1;
timers.set(timerId, callback);
return timerId;
},
clearTimer: (id) => timers.delete(id),
});
return {
controller,
latest: () => states.at(-1),
pendingTimers: () => timers.size,
runTimer: () => {
const entry = timers.entries().next().value;
if (!entry) return;
const [id, callback] = entry;
timers.delete(id);
callback();
},
setReducedMotion: (value: boolean) => {
motionReduced = value;
},
text,
viewport,
};
}
describe("session title marquee", () => {
test("fades only the edges with hidden title text", () => {
expect(resolveSessionTitleHiddenEdges({
moving: false,
overflowing: false,
transitioning: false,
})).toBe("none");
expect(resolveSessionTitleHiddenEdges({
moving: false,
overflowing: true,
transitioning: false,
})).toBe("end");
expect(resolveSessionTitleHiddenEdges({
moving: true,
overflowing: true,
transitioning: true,
})).toBe("both");
expect(resolveSessionTitleHiddenEdges({
moving: true,
overflowing: true,
transitioning: false,
})).toBe("start");
});
test("never moves a fitting title", () => {
const subject = setup({ viewport: 160, text: 140 });
subject.controller.setIntent("focus");
expect(subject.latest()).toEqual({
durationMs: 180,
moving: false,
offsetPx: 0,
overflowing: false,
});
});
test("delays pointer intent and moves overflow once at a distance-based speed", () => {
const subject = setup({ viewport: 120, text: 200 });
subject.controller.setIntent("hover");
expect(subject.latest()?.moving).toBe(false);
expect(subject.pendingTimers()).toBe(1);
subject.runTimer();
expect(subject.latest()).toEqual({
durationMs: (80 / SESSION_TITLE_SPEED_PX_PER_SECOND) * 1_000,
moving: true,
offsetPx: 80,
overflowing: true,
});
});
test("keyboard focus activates immediately and exit returns smoothly to rest", () => {
const subject = setup({ viewport: 100, text: 164 });
subject.controller.setIntent("focus");
expect(subject.latest()?.moving).toBe(true);
expect(subject.pendingTimers()).toBe(0);
subject.controller.setIntent(null);
expect(subject.latest()).toMatchObject({ durationMs: 180, moving: false, offsetPx: 0 });
});
test("cleanup cancels delayed activation", () => {
const subject = setup({ viewport: 100, text: 164 });
subject.controller.setIntent("hover");
subject.controller.destroy();
expect(subject.pendingTimers()).toBe(0);
subject.runTimer();
expect(subject.latest()?.moving).toBe(false);
});
test("recalculates for title, resize, nesting, and hover-action spacing", () => {
const subject = setup({ viewport: 140, text: 220 });
subject.controller.setIntent("focus");
expect(subject.latest()?.offsetPx).toBe(80);
subject.text.scrollWidth = 252;
subject.controller.measure();
expect(subject.latest()?.offsetPx).toBe(112);
subject.text.scrollWidth = 220;
subject.viewport.clientWidth = 110;
subject.controller.measure();
expect(subject.latest()).toMatchObject({ offsetPx: 110, durationMs: 3438, moving: true });
subject.viewport.clientWidth = 230;
subject.controller.measure();
expect(subject.latest()).toMatchObject({ offsetPx: 0, moving: false, overflowing: false });
});
test("reduced motion stays truncated and measurable without moving", () => {
const subject = setup({ viewport: 100, text: 180 }, true);
subject.controller.setIntent("focus");
expect(subject.latest()).toMatchObject({ moving: false, overflowing: true });
subject.setReducedMotion(false);
subject.controller.measure();
expect(subject.latest()).toMatchObject({ moving: true, offsetPx: 80 });
});
test("keeps the full accessible row name and fitting-title tooltip", () => {
const markup = renderToStaticMarkup(
<button aria-label="A complete title, unread">
<SessionTitle intent={null} title="A complete title" tooltip="A complete title — Workspace" />
</button>,
);
expect(markup).toContain('aria-label="A complete title, unread"');
expect(markup).toContain('data-session-title-hidden-edges="none"');
expect(markup).toContain(
'aria-hidden="true" class="inline-block" data-session-title-text="true" title="A complete title — Workspace"',
);
expect(markup).not.toContain("ow-session-title-moving");
});
test("suppresses the native tooltip while overflow is revealed", () => {
expect(resolveSessionTitleTooltip({
overflowing: true,
reducedMotion: false,
tooltip: "A complete title — Workspace",
})).toBeUndefined();
expect(resolveSessionTitleTooltip({
overflowing: false,
reducedMotion: false,
tooltip: "A complete title — Workspace",
})).toBe("A complete title — Workspace");
expect(resolveSessionTitleTooltip({
overflowing: true,
reducedMotion: true,
tooltip: "A complete title — Workspace",
})).toBe("A complete title — Workspace");
});
});