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", () => { describe("handleChatScroll", () => {
it("sets chatUserNearBottom=true when within the 450px threshold", () => { it("updates near-bottom state across threshold boundaries", () => {
const { host } = createScrollHost({}); const cases = [
// distanceFromBottom = 2000 - 1600 - 400 = 0 → clearly near bottom {
const event = createScrollEvent(2000, 1600, 400); name: "clearly near bottom",
handleChatScroll(host, event); event: createScrollEvent(2000, 1600, 400),
expect(host.chatUserNearBottom).toBe(true); expected: true,
}); },
{
it("sets chatUserNearBottom=true when distance is just under threshold", () => { name: "just under threshold",
const { host } = createScrollHost({}); event: createScrollEvent(2000, 1151, 400),
// distanceFromBottom = 2000 - 1151 - 400 = 449 → just under threshold expected: true,
const event = createScrollEvent(2000, 1151, 400); },
handleChatScroll(host, event); {
expect(host.chatUserNearBottom).toBe(true); name: "exactly at threshold",
}); event: createScrollEvent(2000, 1150, 400),
expected: false,
it("sets chatUserNearBottom=false when distance is exactly at threshold", () => { },
const { host } = createScrollHost({}); {
// distanceFromBottom = 2000 - 1150 - 400 = 450 → at threshold (uses strict <) name: "well above threshold",
const event = createScrollEvent(2000, 1150, 400); event: createScrollEvent(2000, 500, 400),
handleChatScroll(host, event); expected: false,
expect(host.chatUserNearBottom).toBe(false); },
}); {
name: "scrolled up beyond long message",
it("sets chatUserNearBottom=false when scrolled well above threshold", () => { event: createScrollEvent(2000, 1100, 400),
const { host } = createScrollHost({}); expected: false,
// distanceFromBottom = 2000 - 500 - 400 = 1100 → way above threshold },
const event = createScrollEvent(2000, 500, 400); ] as const;
handleChatScroll(host, event); for (const testCase of cases) {
expect(host.chatUserNearBottom).toBe(false); const { host } = createScrollHost({});
}); handleChatScroll(host, testCase.event);
expect(host.chatUserNearBottom, testCase.name).toBe(testCase.expected);
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);
}); });
}); });
@@ -121,85 +115,67 @@ describe("scheduleChatScroll", () => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
it("scrolls to bottom when user is near bottom (no force)", async () => { it("respects near-bottom, force, and initial-load behavior", async () => {
const { host, container } = createScrollHost({ const cases = [
scrollHeight: 2000, {
scrollTop: 1600, name: "near-bottom auto-scroll",
clientHeight: 400, scrollTop: 1600,
}); chatUserNearBottom: true,
// distanceFromBottom = 2000 - 1600 - 400 = 0 → near bottom chatHasAutoScrolled: false,
host.chatUserNearBottom = true; 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); for (const testCase of cases) {
await host.updateComplete; 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 () => { if (testCase.expectedScrollsToBottom) {
const { host, container } = createScrollHost({ expect(container.scrollTop, testCase.name).toBe(container.scrollHeight);
scrollHeight: 2000, } else {
scrollTop: 500, expect(container.scrollTop, testCase.name).toBe(originalScrollTop);
clientHeight: 400, }
}); expect(host.chatNewMessagesBelow, testCase.name).toBe(testCase.expectedNewMessagesBelow);
// 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);
}); });
}); });

View File

@@ -2,70 +2,75 @@ import { describe, expect, it } from "vitest";
import { formatRelativeTimestamp, stripThinkingTags } from "./format.ts"; import { formatRelativeTimestamp, stripThinkingTags } from "./format.ts";
describe("formatAgo", () => { describe("formatAgo", () => {
it("returns 'in <1m' for timestamps less than 60s in the future", () => { it("formats relative timestamps across future/past/null cases", () => {
expect(formatRelativeTimestamp(Date.now() + 30_000)).toBe("in <1m"); const now = Date.now();
}); const cases = [
{ name: "<1m future", input: now + 30_000, expected: "in <1m" },
it("returns 'Xm from now' for future timestamps", () => { { name: "minutes future", input: now + 5 * 60_000, expected: "in 5m" },
expect(formatRelativeTimestamp(Date.now() + 5 * 60_000)).toBe("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" },
it("returns 'Xh from now' for future timestamps", () => { { name: "minutes past", input: now - 5 * 60_000, expected: "5m ago" },
expect(formatRelativeTimestamp(Date.now() + 3 * 60 * 60_000)).toBe("in 3h"); { name: "null", input: null, expected: "n/a" },
}); { name: "undefined", input: undefined, expected: "n/a" },
] as const;
it("returns 'Xd from now' for future timestamps beyond 48h", () => { for (const testCase of cases) {
expect(formatRelativeTimestamp(Date.now() + 3 * 24 * 60 * 60_000)).toBe("in 3d"); expect(formatRelativeTimestamp(testCase.input), testCase.name).toBe(testCase.expected);
}); }
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");
}); });
}); });
describe("stripThinkingTags", () => { describe("stripThinkingTags", () => {
it("strips <think>…</think> segments", () => { it("normalizes think/final tag variants", () => {
const input = ["<think>", "secret", "</think>", "", "Hello"].join("\n"); const cases = [
expect(stripThinkingTags(input)).toBe("Hello"); {
}); name: "strip think block",
input: ["<think>", "secret", "</think>", "", "Hello"].join("\n"),
it("strips <thinking>…</thinking> segments", () => { expected: "Hello",
const input = ["<thinking>", "secret", "</thinking>", "", "Hello"].join("\n"); },
expect(stripThinkingTags(input)).toBe("Hello"); {
}); name: "strip thinking block",
input: ["<thinking>", "secret", "</thinking>", "", "Hello"].join("\n"),
it("keeps text when tags are unpaired", () => { expected: "Hello",
expect(stripThinkingTags("<think>\nsecret\nHello")).toBe("secret\nHello"); },
expect(stripThinkingTags("Hello\n</think>")).toBe("Hello\n"); {
}); name: "unpaired think start",
input: "<think>\nsecret\nHello",
it("returns original text when no tags exist", () => { expected: "secret\nHello",
expect(stripThinkingTags("Hello")).toBe("Hello"); },
}); {
name: "unpaired think end",
it("strips <final>…</final> segments", () => { input: "Hello\n</think>",
const input = "<final>\n\nHello there\n\n</final>"; expected: "Hello\n",
expect(stripThinkingTags(input)).toBe("Hello there\n\n"); },
}); {
name: "no tags",
it("strips mixed <think> and <final> tags", () => { input: "Hello",
const input = "<think>reasoning</think>\n\n<final>Hello</final>"; expected: "Hello",
expect(stripThinkingTags(input)).toBe("Hello"); },
}); {
name: "strip final block",
it("handles incomplete <final tag gracefully", () => { input: "<final>\n\nHello there\n\n</final>",
// When streaming splits mid-tag, we may see "<final" without closing ">" expected: "Hello there\n\n",
// This should not crash and should handle gracefully },
expect(stripThinkingTags("<final\nHello")).toBe("<final\nHello"); {
expect(stripThinkingTags("Hello</final>")).toBe("Hello"); 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", () => { it("returns stable icons for known tabs", () => {
expect(iconForTab("chat")).toBe("messageSquare"); const cases = [
expect(iconForTab("overview")).toBe("barChart"); { tab: "chat", icon: "messageSquare" },
expect(iconForTab("channels")).toBe("link"); { tab: "overview", icon: "barChart" },
expect(iconForTab("instances")).toBe("radio"); { tab: "channels", icon: "link" },
expect(iconForTab("sessions")).toBe("fileText"); { tab: "instances", icon: "radio" },
expect(iconForTab("cron")).toBe("loader"); { tab: "sessions", icon: "fileText" },
expect(iconForTab("skills")).toBe("zap"); { tab: "cron", icon: "loader" },
expect(iconForTab("nodes")).toBe("monitor"); { tab: "skills", icon: "zap" },
expect(iconForTab("config")).toBe("settings"); { tab: "nodes", icon: "monitor" },
expect(iconForTab("debug")).toBe("bug"); { tab: "config", icon: "settings" },
expect(iconForTab("logs")).toBe("scrollText"); { 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", () => { it("returns a fallback icon for unknown tab", () => {
@@ -56,9 +61,14 @@ describe("titleForTab", () => {
}); });
it("returns expected titles", () => { it("returns expected titles", () => {
expect(titleForTab("chat")).toBe("Chat"); const cases = [
expect(titleForTab("overview")).toBe("Overview"); { tab: "chat", title: "Chat" },
expect(titleForTab("cron")).toBe("Cron Jobs"); { 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", () => { describe("normalizeBasePath", () => {
it("returns empty string for falsy input", () => { it("normalizes base-path variants", () => {
expect(normalizeBasePath("")).toBe(""); const cases = [
}); { input: "", expected: "" },
{ input: "ui", expected: "/ui" },
it("adds leading slash if missing", () => { { input: "/ui/", expected: "/ui" },
expect(normalizeBasePath("ui")).toBe("/ui"); { input: "/", expected: "" },
}); { input: "/apps/openclaw", expected: "/apps/openclaw" },
] as const;
it("removes trailing slash", () => { for (const testCase of cases) {
expect(normalizeBasePath("/ui/")).toBe("/ui"); expect(normalizeBasePath(testCase.input), testCase.input).toBe(testCase.expected);
}); }
it("returns empty string for root path", () => {
expect(normalizeBasePath("/")).toBe("");
});
it("handles nested paths", () => {
expect(normalizeBasePath("/apps/openclaw")).toBe("/apps/openclaw");
}); });
}); });
describe("normalizePath", () => { describe("normalizePath", () => {
it("returns / for falsy input", () => { it("normalizes paths", () => {
expect(normalizePath("")).toBe("/"); const cases = [
}); { input: "", expected: "/" },
{ input: "chat", expected: "/chat" },
it("adds leading slash if missing", () => { { input: "/chat/", expected: "/chat" },
expect(normalizePath("chat")).toBe("/chat"); { input: "/", expected: "/" },
}); ] as const;
for (const testCase of cases) {
it("removes trailing slash except for root", () => { expect(normalizePath(testCase.input), testCase.input).toBe(testCase.expected);
expect(normalizePath("/chat/")).toBe("/chat"); }
expect(normalizePath("/")).toBe("/");
}); });
}); });
describe("pathForTab", () => { describe("pathForTab", () => {
it("returns correct path without base", () => { it("builds tab paths with optional bases", () => {
expect(pathForTab("chat")).toBe("/chat"); const cases = [
expect(pathForTab("overview")).toBe("/overview"); { tab: "chat", base: undefined, expected: "/chat" },
}); { tab: "overview", base: undefined, expected: "/overview" },
{ tab: "chat", base: "/ui", expected: "/ui/chat" },
it("prepends base path", () => { { tab: "sessions", base: "/apps/openclaw", expected: "/apps/openclaw/sessions" },
expect(pathForTab("chat", "/ui")).toBe("/ui/chat"); ] as const;
expect(pathForTab("sessions", "/apps/openclaw")).toBe("/apps/openclaw/sessions"); for (const testCase of cases) {
expect(
pathForTab(testCase.tab, testCase.base),
`${testCase.tab}:${testCase.base ?? "root"}`,
).toBe(testCase.expected);
}
}); });
}); });
describe("tabFromPath", () => { describe("tabFromPath", () => {
it("returns tab for valid path", () => { it("resolves tabs from path variants", () => {
expect(tabFromPath("/chat")).toBe("chat"); const cases = [
expect(tabFromPath("/overview")).toBe("overview"); { path: "/chat", base: undefined, expected: "chat" },
expect(tabFromPath("/sessions")).toBe("sessions"); { path: "/overview", base: undefined, expected: "overview" },
}); { path: "/sessions", base: undefined, expected: "sessions" },
{ path: "/", base: undefined, expected: "chat" },
it("returns chat for root path", () => { { path: "/ui/chat", base: "/ui", expected: "chat" },
expect(tabFromPath("/")).toBe("chat"); { path: "/apps/openclaw/sessions", base: "/apps/openclaw", expected: "sessions" },
}); { path: "/unknown", base: undefined, expected: null },
{ path: "/CHAT", base: undefined, expected: "chat" },
it("handles base paths", () => { { path: "/Overview", base: undefined, expected: "overview" },
expect(tabFromPath("/ui/chat", "/ui")).toBe("chat"); ] as const;
expect(tabFromPath("/apps/openclaw/sessions", "/apps/openclaw")).toBe("sessions"); for (const testCase of cases) {
}); expect(
tabFromPath(testCase.path, testCase.base),
it("returns null for unknown path", () => { `${testCase.path}:${testCase.base ?? "root"}`,
expect(tabFromPath("/unknown")).toBeNull(); ).toBe(testCase.expected);
}); }
it("is case-insensitive", () => {
expect(tabFromPath("/CHAT")).toBe("chat");
expect(tabFromPath("/Overview")).toBe("overview");
}); });
}); });
describe("inferBasePathFromPathname", () => { describe("inferBasePathFromPathname", () => {
it("returns empty string for root", () => { it("infers base-path variants from pathname", () => {
expect(inferBasePathFromPathname("/")).toBe(""); const cases = [
}); { path: "/", expected: "" },
{ path: "/chat", expected: "" },
it("returns empty string for direct tab path", () => { { path: "/overview", expected: "" },
expect(inferBasePathFromPathname("/chat")).toBe(""); { path: "/ui/chat", expected: "/ui" },
expect(inferBasePathFromPathname("/overview")).toBe(""); { path: "/apps/openclaw/sessions", expected: "/apps/openclaw" },
}); { path: "/index.html", expected: "" },
{ path: "/ui/index.html", expected: "/ui" },
it("infers base path from nested paths", () => { ] as const;
expect(inferBasePathFromPathname("/ui/chat")).toBe("/ui"); for (const testCase of cases) {
expect(inferBasePathFromPathname("/apps/openclaw/sessions")).toBe("/apps/openclaw"); expect(inferBasePathFromPathname(testCase.path), testCase.path).toBe(testCase.expected);
}); }
it("handles index.html suffix", () => {
expect(inferBasePathFromPathname("/index.html")).toBe("");
expect(inferBasePathFromPathname("/ui/index.html")).toBe("/ui");
}); });
}); });
describe("TAB_GROUPS", () => { describe("TAB_GROUPS", () => {
it("contains all expected groups", () => { it("contains all expected groups", () => {
const labels = TAB_GROUPS.map((g) => g.label); const labels = TAB_GROUPS.map((g) => g.label.toLowerCase());
expect(labels).toContain("Chat"); for (const expected of ["chat", "control", "agent", "settings"]) {
expect(labels).toContain("Control"); expect(labels).toContain(expected);
expect(labels).toContain("Agent"); }
expect(labels).toContain("Settings");
}); });
it("all tabs are unique", () => { it("all tabs are unique", () => {