From 1baac3e31d5ffd16cab1cb166b9f375d893e7da2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 21 Feb 2026 22:35:10 +0000 Subject: [PATCH] test(ui): consolidate navigation/scroll/format matrices --- ui/src/ui/app-scroll.test.ts | 206 ++++++++++++++++------------------- ui/src/ui/format.test.ts | 127 ++++++++++----------- ui/src/ui/navigation.test.ts | 188 ++++++++++++++++---------------- 3 files changed, 250 insertions(+), 271 deletions(-) diff --git a/ui/src/ui/app-scroll.test.ts b/ui/src/ui/app-scroll.test.ts index 111b54de9..244d61c35 100644 --- a/ui/src/ui/app-scroll.test.ts +++ b/ui/src/ui/app-scroll.test.ts @@ -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); + } }); }); diff --git a/ui/src/ui/format.test.ts b/ui/src/ui/format.test.ts index 239bdd213..24cf7f266 100644 --- a/ui/src/ui/format.test.ts +++ b/ui/src/ui/format.test.ts @@ -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 segments", () => { - const input = ["", "secret", "", "", "Hello"].join("\n"); - expect(stripThinkingTags(input)).toBe("Hello"); - }); - - it("strips segments", () => { - const input = ["", "secret", "", "", "Hello"].join("\n"); - expect(stripThinkingTags(input)).toBe("Hello"); - }); - - it("keeps text when tags are unpaired", () => { - expect(stripThinkingTags("\nsecret\nHello")).toBe("secret\nHello"); - expect(stripThinkingTags("Hello\n")).toBe("Hello\n"); - }); - - it("returns original text when no tags exist", () => { - expect(stripThinkingTags("Hello")).toBe("Hello"); - }); - - it("strips segments", () => { - const input = "\n\nHello there\n\n"; - expect(stripThinkingTags(input)).toBe("Hello there\n\n"); - }); - - it("strips mixed and tags", () => { - const input = "reasoning\n\nHello"; - expect(stripThinkingTags(input)).toBe("Hello"); - }); - - it("handles incomplete { - // When streaming splits mid-tag, we may see "" - // This should not crash and should handle gracefully - expect(stripThinkingTags("")).toBe("Hello"); + it("normalizes think/final tag variants", () => { + const cases = [ + { + name: "strip think block", + input: ["", "secret", "", "", "Hello"].join("\n"), + expected: "Hello", + }, + { + name: "strip thinking block", + input: ["", "secret", "", "", "Hello"].join("\n"), + expected: "Hello", + }, + { + name: "unpaired think start", + input: "\nsecret\nHello", + expected: "secret\nHello", + }, + { + name: "unpaired think end", + input: "Hello\n", + expected: "Hello\n", + }, + { + name: "no tags", + input: "Hello", + expected: "Hello", + }, + { + name: "strip final block", + input: "\n\nHello there\n\n", + expected: "Hello there\n\n", + }, + { + name: "strip mixed think/final", + input: "reasoning\n\nHello", + expected: "Hello", + }, + { + name: "incomplete final start", + input: "", + expected: "Hello", + }, + ] as const; + for (const testCase of cases) { + expect(stripThinkingTags(testCase.input), testCase.name).toBe(testCase.expected); + } }); }); diff --git a/ui/src/ui/navigation.test.ts b/ui/src/ui/navigation.test.ts index 4ff027934..79839ef16 100644 --- a/ui/src/ui/navigation.test.ts +++ b/ui/src/ui/navigation.test.ts @@ -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", () => {