test(ui): consolidate navigation/scroll/format matrices

This commit is contained in:
Peter Steinberger
2026-02-21 22:35:10 +00:00
parent 0bd9f0d4ac
commit 1baac3e31d
3 changed files with 250 additions and 271 deletions

View File

@@ -61,45 +61,39 @@ function createScrollEvent(scrollHeight: number, scrollTop: number, clientHeight
/* ------------------------------------------------------------------ */
describe("handleChatScroll", () => {
it("sets chatUserNearBottom=true when within the 450px threshold", () => {
const { host } = createScrollHost({});
// distanceFromBottom = 2000 - 1600 - 400 = 0 → clearly near bottom
const event = createScrollEvent(2000, 1600, 400);
handleChatScroll(host, event);
expect(host.chatUserNearBottom).toBe(true);
});
it("sets chatUserNearBottom=true when distance is just under threshold", () => {
const { host } = createScrollHost({});
// distanceFromBottom = 2000 - 1151 - 400 = 449 → just under threshold
const event = createScrollEvent(2000, 1151, 400);
handleChatScroll(host, event);
expect(host.chatUserNearBottom).toBe(true);
});
it("sets chatUserNearBottom=false when distance is exactly at threshold", () => {
const { host } = createScrollHost({});
// distanceFromBottom = 2000 - 1150 - 400 = 450 → at threshold (uses strict <)
const event = createScrollEvent(2000, 1150, 400);
handleChatScroll(host, event);
expect(host.chatUserNearBottom).toBe(false);
});
it("sets chatUserNearBottom=false when scrolled well above threshold", () => {
const { host } = createScrollHost({});
// distanceFromBottom = 2000 - 500 - 400 = 1100 → way above threshold
const event = createScrollEvent(2000, 500, 400);
handleChatScroll(host, event);
expect(host.chatUserNearBottom).toBe(false);
});
it("sets chatUserNearBottom=false when user scrolled up past one long message (>200px <450px)", () => {
const { host } = createScrollHost({});
// distanceFromBottom = 2000 - 1250 - 400 = 350 → old threshold would say "near", new says "near"
// distanceFromBottom = 2000 - 1100 - 400 = 500 → old threshold would say "not near", new also "not near"
const event = createScrollEvent(2000, 1100, 400);
handleChatScroll(host, event);
expect(host.chatUserNearBottom).toBe(false);
it("updates near-bottom state across threshold boundaries", () => {
const cases = [
{
name: "clearly near bottom",
event: createScrollEvent(2000, 1600, 400),
expected: true,
},
{
name: "just under threshold",
event: createScrollEvent(2000, 1151, 400),
expected: true,
},
{
name: "exactly at threshold",
event: createScrollEvent(2000, 1150, 400),
expected: false,
},
{
name: "well above threshold",
event: createScrollEvent(2000, 500, 400),
expected: false,
},
{
name: "scrolled up beyond long message",
event: createScrollEvent(2000, 1100, 400),
expected: false,
},
] as const;
for (const testCase of cases) {
const { host } = createScrollHost({});
handleChatScroll(host, testCase.event);
expect(host.chatUserNearBottom, testCase.name).toBe(testCase.expected);
}
});
});
@@ -121,85 +115,67 @@ describe("scheduleChatScroll", () => {
vi.restoreAllMocks();
});
it("scrolls to bottom when user is near bottom (no force)", async () => {
const { host, container } = createScrollHost({
scrollHeight: 2000,
scrollTop: 1600,
clientHeight: 400,
});
// distanceFromBottom = 2000 - 1600 - 400 = 0 → near bottom
host.chatUserNearBottom = true;
it("respects near-bottom, force, and initial-load behavior", async () => {
const cases = [
{
name: "near-bottom auto-scroll",
scrollTop: 1600,
chatUserNearBottom: true,
chatHasAutoScrolled: false,
force: false,
expectedScrollsToBottom: true,
expectedNewMessagesBelow: false,
},
{
name: "scrolled-up no-force",
scrollTop: 500,
chatUserNearBottom: false,
chatHasAutoScrolled: false,
force: false,
expectedScrollsToBottom: false,
expectedNewMessagesBelow: true,
},
{
name: "scrolled-up force after initial load",
scrollTop: 500,
chatUserNearBottom: false,
chatHasAutoScrolled: true,
force: true,
expectedScrollsToBottom: false,
expectedNewMessagesBelow: true,
},
{
name: "scrolled-up force on initial load",
scrollTop: 500,
chatUserNearBottom: false,
chatHasAutoScrolled: false,
force: true,
expectedScrollsToBottom: true,
expectedNewMessagesBelow: false,
},
] as const;
scheduleChatScroll(host);
await host.updateComplete;
for (const testCase of cases) {
const { host, container } = createScrollHost({
scrollHeight: 2000,
scrollTop: testCase.scrollTop,
clientHeight: 400,
});
host.chatUserNearBottom = testCase.chatUserNearBottom;
host.chatHasAutoScrolled = testCase.chatHasAutoScrolled;
host.chatNewMessagesBelow = false;
const originalScrollTop = container.scrollTop;
expect(container.scrollTop).toBe(container.scrollHeight);
});
scheduleChatScroll(host, testCase.force);
await host.updateComplete;
it("does NOT scroll when user is scrolled up and no force", async () => {
const { host, container } = createScrollHost({
scrollHeight: 2000,
scrollTop: 500,
clientHeight: 400,
});
// distanceFromBottom = 2000 - 500 - 400 = 1100 → not near bottom
host.chatUserNearBottom = false;
const originalScrollTop = container.scrollTop;
scheduleChatScroll(host);
await host.updateComplete;
expect(container.scrollTop).toBe(originalScrollTop);
});
it("does NOT scroll with force=true when user has explicitly scrolled up", async () => {
const { host, container } = createScrollHost({
scrollHeight: 2000,
scrollTop: 500,
clientHeight: 400,
});
// User has scrolled up — chatUserNearBottom is false
host.chatUserNearBottom = false;
host.chatHasAutoScrolled = true; // Already past initial load
const originalScrollTop = container.scrollTop;
scheduleChatScroll(host, true);
await host.updateComplete;
// force=true should still NOT override explicit user scroll-up after initial load
expect(container.scrollTop).toBe(originalScrollTop);
});
it("DOES scroll with force=true on initial load (chatHasAutoScrolled=false)", async () => {
const { host, container } = createScrollHost({
scrollHeight: 2000,
scrollTop: 500,
clientHeight: 400,
});
host.chatUserNearBottom = false;
host.chatHasAutoScrolled = false; // Initial load
scheduleChatScroll(host, true);
await host.updateComplete;
// On initial load, force should work regardless
expect(container.scrollTop).toBe(container.scrollHeight);
});
it("sets chatNewMessagesBelow when not scrolling due to user position", async () => {
const { host } = createScrollHost({
scrollHeight: 2000,
scrollTop: 500,
clientHeight: 400,
});
host.chatUserNearBottom = false;
host.chatHasAutoScrolled = true;
host.chatNewMessagesBelow = false;
scheduleChatScroll(host);
await host.updateComplete;
expect(host.chatNewMessagesBelow).toBe(true);
if (testCase.expectedScrollsToBottom) {
expect(container.scrollTop, testCase.name).toBe(container.scrollHeight);
} else {
expect(container.scrollTop, testCase.name).toBe(originalScrollTop);
}
expect(host.chatNewMessagesBelow, testCase.name).toBe(testCase.expectedNewMessagesBelow);
}
});
});

View File

@@ -2,70 +2,75 @@ import { describe, expect, it } from "vitest";
import { formatRelativeTimestamp, stripThinkingTags } from "./format.ts";
describe("formatAgo", () => {
it("returns 'in <1m' for timestamps less than 60s in the future", () => {
expect(formatRelativeTimestamp(Date.now() + 30_000)).toBe("in <1m");
});
it("returns 'Xm from now' for future timestamps", () => {
expect(formatRelativeTimestamp(Date.now() + 5 * 60_000)).toBe("in 5m");
});
it("returns 'Xh from now' for future timestamps", () => {
expect(formatRelativeTimestamp(Date.now() + 3 * 60 * 60_000)).toBe("in 3h");
});
it("returns 'Xd from now' for future timestamps beyond 48h", () => {
expect(formatRelativeTimestamp(Date.now() + 3 * 24 * 60 * 60_000)).toBe("in 3d");
});
it("returns 'Xs ago' for recent past timestamps", () => {
expect(formatRelativeTimestamp(Date.now() - 10_000)).toBe("just now");
});
it("returns 'Xm ago' for past timestamps", () => {
expect(formatRelativeTimestamp(Date.now() - 5 * 60_000)).toBe("5m ago");
});
it("returns 'n/a' for null/undefined", () => {
expect(formatRelativeTimestamp(null)).toBe("n/a");
expect(formatRelativeTimestamp(undefined)).toBe("n/a");
it("formats relative timestamps across future/past/null cases", () => {
const now = Date.now();
const cases = [
{ name: "<1m future", input: now + 30_000, expected: "in <1m" },
{ name: "minutes future", input: now + 5 * 60_000, expected: "in 5m" },
{ name: "hours future", input: now + 3 * 60 * 60_000, expected: "in 3h" },
{ name: "days future", input: now + 3 * 24 * 60 * 60_000, expected: "in 3d" },
{ name: "recent past", input: now - 10_000, expected: "just now" },
{ name: "minutes past", input: now - 5 * 60_000, expected: "5m ago" },
{ name: "null", input: null, expected: "n/a" },
{ name: "undefined", input: undefined, expected: "n/a" },
] as const;
for (const testCase of cases) {
expect(formatRelativeTimestamp(testCase.input), testCase.name).toBe(testCase.expected);
}
});
});
describe("stripThinkingTags", () => {
it("strips <think>…</think> segments", () => {
const input = ["<think>", "secret", "</think>", "", "Hello"].join("\n");
expect(stripThinkingTags(input)).toBe("Hello");
});
it("strips <thinking>…</thinking> segments", () => {
const input = ["<thinking>", "secret", "</thinking>", "", "Hello"].join("\n");
expect(stripThinkingTags(input)).toBe("Hello");
});
it("keeps text when tags are unpaired", () => {
expect(stripThinkingTags("<think>\nsecret\nHello")).toBe("secret\nHello");
expect(stripThinkingTags("Hello\n</think>")).toBe("Hello\n");
});
it("returns original text when no tags exist", () => {
expect(stripThinkingTags("Hello")).toBe("Hello");
});
it("strips <final>…</final> segments", () => {
const input = "<final>\n\nHello there\n\n</final>";
expect(stripThinkingTags(input)).toBe("Hello there\n\n");
});
it("strips mixed <think> and <final> tags", () => {
const input = "<think>reasoning</think>\n\n<final>Hello</final>";
expect(stripThinkingTags(input)).toBe("Hello");
});
it("handles incomplete <final tag gracefully", () => {
// When streaming splits mid-tag, we may see "<final" without closing ">"
// This should not crash and should handle gracefully
expect(stripThinkingTags("<final\nHello")).toBe("<final\nHello");
expect(stripThinkingTags("Hello</final>")).toBe("Hello");
it("normalizes think/final tag variants", () => {
const cases = [
{
name: "strip think block",
input: ["<think>", "secret", "</think>", "", "Hello"].join("\n"),
expected: "Hello",
},
{
name: "strip thinking block",
input: ["<thinking>", "secret", "</thinking>", "", "Hello"].join("\n"),
expected: "Hello",
},
{
name: "unpaired think start",
input: "<think>\nsecret\nHello",
expected: "secret\nHello",
},
{
name: "unpaired think end",
input: "Hello\n</think>",
expected: "Hello\n",
},
{
name: "no tags",
input: "Hello",
expected: "Hello",
},
{
name: "strip final block",
input: "<final>\n\nHello there\n\n</final>",
expected: "Hello there\n\n",
},
{
name: "strip mixed think/final",
input: "<think>reasoning</think>\n\n<final>Hello</final>",
expected: "Hello",
},
{
name: "incomplete final start",
input: "<final\nHello",
expected: "<final\nHello",
},
{
name: "orphan final end",
input: "Hello</final>",
expected: "Hello",
},
] as const;
for (const testCase of cases) {
expect(stripThinkingTags(testCase.input), testCase.name).toBe(testCase.expected);
}
});
});

View File

@@ -26,17 +26,22 @@ describe("iconForTab", () => {
});
it("returns stable icons for known tabs", () => {
expect(iconForTab("chat")).toBe("messageSquare");
expect(iconForTab("overview")).toBe("barChart");
expect(iconForTab("channels")).toBe("link");
expect(iconForTab("instances")).toBe("radio");
expect(iconForTab("sessions")).toBe("fileText");
expect(iconForTab("cron")).toBe("loader");
expect(iconForTab("skills")).toBe("zap");
expect(iconForTab("nodes")).toBe("monitor");
expect(iconForTab("config")).toBe("settings");
expect(iconForTab("debug")).toBe("bug");
expect(iconForTab("logs")).toBe("scrollText");
const cases = [
{ tab: "chat", icon: "messageSquare" },
{ tab: "overview", icon: "barChart" },
{ tab: "channels", icon: "link" },
{ tab: "instances", icon: "radio" },
{ tab: "sessions", icon: "fileText" },
{ tab: "cron", icon: "loader" },
{ tab: "skills", icon: "zap" },
{ tab: "nodes", icon: "monitor" },
{ tab: "config", icon: "settings" },
{ tab: "debug", icon: "bug" },
{ tab: "logs", icon: "scrollText" },
] as const;
for (const testCase of cases) {
expect(iconForTab(testCase.tab), testCase.tab).toBe(testCase.icon);
}
});
it("returns a fallback icon for unknown tab", () => {
@@ -56,9 +61,14 @@ describe("titleForTab", () => {
});
it("returns expected titles", () => {
expect(titleForTab("chat")).toBe("Chat");
expect(titleForTab("overview")).toBe("Overview");
expect(titleForTab("cron")).toBe("Cron Jobs");
const cases = [
{ tab: "chat", title: "Chat" },
{ tab: "overview", title: "Overview" },
{ tab: "cron", title: "Cron Jobs" },
] as const;
for (const testCase of cases) {
expect(titleForTab(testCase.tab), testCase.tab).toBe(testCase.title);
}
});
});
@@ -77,108 +87,96 @@ describe("subtitleForTab", () => {
});
describe("normalizeBasePath", () => {
it("returns empty string for falsy input", () => {
expect(normalizeBasePath("")).toBe("");
});
it("adds leading slash if missing", () => {
expect(normalizeBasePath("ui")).toBe("/ui");
});
it("removes trailing slash", () => {
expect(normalizeBasePath("/ui/")).toBe("/ui");
});
it("returns empty string for root path", () => {
expect(normalizeBasePath("/")).toBe("");
});
it("handles nested paths", () => {
expect(normalizeBasePath("/apps/openclaw")).toBe("/apps/openclaw");
it("normalizes base-path variants", () => {
const cases = [
{ input: "", expected: "" },
{ input: "ui", expected: "/ui" },
{ input: "/ui/", expected: "/ui" },
{ input: "/", expected: "" },
{ input: "/apps/openclaw", expected: "/apps/openclaw" },
] as const;
for (const testCase of cases) {
expect(normalizeBasePath(testCase.input), testCase.input).toBe(testCase.expected);
}
});
});
describe("normalizePath", () => {
it("returns / for falsy input", () => {
expect(normalizePath("")).toBe("/");
});
it("adds leading slash if missing", () => {
expect(normalizePath("chat")).toBe("/chat");
});
it("removes trailing slash except for root", () => {
expect(normalizePath("/chat/")).toBe("/chat");
expect(normalizePath("/")).toBe("/");
it("normalizes paths", () => {
const cases = [
{ input: "", expected: "/" },
{ input: "chat", expected: "/chat" },
{ input: "/chat/", expected: "/chat" },
{ input: "/", expected: "/" },
] as const;
for (const testCase of cases) {
expect(normalizePath(testCase.input), testCase.input).toBe(testCase.expected);
}
});
});
describe("pathForTab", () => {
it("returns correct path without base", () => {
expect(pathForTab("chat")).toBe("/chat");
expect(pathForTab("overview")).toBe("/overview");
});
it("prepends base path", () => {
expect(pathForTab("chat", "/ui")).toBe("/ui/chat");
expect(pathForTab("sessions", "/apps/openclaw")).toBe("/apps/openclaw/sessions");
it("builds tab paths with optional bases", () => {
const cases = [
{ tab: "chat", base: undefined, expected: "/chat" },
{ tab: "overview", base: undefined, expected: "/overview" },
{ tab: "chat", base: "/ui", expected: "/ui/chat" },
{ tab: "sessions", base: "/apps/openclaw", expected: "/apps/openclaw/sessions" },
] as const;
for (const testCase of cases) {
expect(
pathForTab(testCase.tab, testCase.base),
`${testCase.tab}:${testCase.base ?? "root"}`,
).toBe(testCase.expected);
}
});
});
describe("tabFromPath", () => {
it("returns tab for valid path", () => {
expect(tabFromPath("/chat")).toBe("chat");
expect(tabFromPath("/overview")).toBe("overview");
expect(tabFromPath("/sessions")).toBe("sessions");
});
it("returns chat for root path", () => {
expect(tabFromPath("/")).toBe("chat");
});
it("handles base paths", () => {
expect(tabFromPath("/ui/chat", "/ui")).toBe("chat");
expect(tabFromPath("/apps/openclaw/sessions", "/apps/openclaw")).toBe("sessions");
});
it("returns null for unknown path", () => {
expect(tabFromPath("/unknown")).toBeNull();
});
it("is case-insensitive", () => {
expect(tabFromPath("/CHAT")).toBe("chat");
expect(tabFromPath("/Overview")).toBe("overview");
it("resolves tabs from path variants", () => {
const cases = [
{ path: "/chat", base: undefined, expected: "chat" },
{ path: "/overview", base: undefined, expected: "overview" },
{ path: "/sessions", base: undefined, expected: "sessions" },
{ path: "/", base: undefined, expected: "chat" },
{ path: "/ui/chat", base: "/ui", expected: "chat" },
{ path: "/apps/openclaw/sessions", base: "/apps/openclaw", expected: "sessions" },
{ path: "/unknown", base: undefined, expected: null },
{ path: "/CHAT", base: undefined, expected: "chat" },
{ path: "/Overview", base: undefined, expected: "overview" },
] as const;
for (const testCase of cases) {
expect(
tabFromPath(testCase.path, testCase.base),
`${testCase.path}:${testCase.base ?? "root"}`,
).toBe(testCase.expected);
}
});
});
describe("inferBasePathFromPathname", () => {
it("returns empty string for root", () => {
expect(inferBasePathFromPathname("/")).toBe("");
});
it("returns empty string for direct tab path", () => {
expect(inferBasePathFromPathname("/chat")).toBe("");
expect(inferBasePathFromPathname("/overview")).toBe("");
});
it("infers base path from nested paths", () => {
expect(inferBasePathFromPathname("/ui/chat")).toBe("/ui");
expect(inferBasePathFromPathname("/apps/openclaw/sessions")).toBe("/apps/openclaw");
});
it("handles index.html suffix", () => {
expect(inferBasePathFromPathname("/index.html")).toBe("");
expect(inferBasePathFromPathname("/ui/index.html")).toBe("/ui");
it("infers base-path variants from pathname", () => {
const cases = [
{ path: "/", expected: "" },
{ path: "/chat", expected: "" },
{ path: "/overview", expected: "" },
{ path: "/ui/chat", expected: "/ui" },
{ path: "/apps/openclaw/sessions", expected: "/apps/openclaw" },
{ path: "/index.html", expected: "" },
{ path: "/ui/index.html", expected: "/ui" },
] as const;
for (const testCase of cases) {
expect(inferBasePathFromPathname(testCase.path), testCase.path).toBe(testCase.expected);
}
});
});
describe("TAB_GROUPS", () => {
it("contains all expected groups", () => {
const labels = TAB_GROUPS.map((g) => g.label);
expect(labels).toContain("Chat");
expect(labels).toContain("Control");
expect(labels).toContain("Agent");
expect(labels).toContain("Settings");
const labels = TAB_GROUPS.map((g) => g.label.toLowerCase());
for (const expected of ["chat", "control", "agent", "settings"]) {
expect(labels).toContain(expected);
}
});
it("all tabs are unique", () => {