openclaw/apps/web/lib/report-utils.test.ts
kumarabhirup 0f6849a731
Web app: add syntax highlighting, diff viewer, rich chat editor, and file search
Syntax highlighting & code viewer:
- Add shiki for syntax-highlighted fenced code blocks in chat messages
- New SyntaxBlock component (lazy shiki, dual light/dark theme)
- New CodeViewer for workspace file panel (routes code files via isCodeFile())
- API routes (browse-file, virtual-file) now return "code" type for known extensions

Diff rendering:
- New DiffCard component for rendering unified diffs with add/remove colors
- diff-blocks.ts parser to extract fenced blocks from markdown
- Chain-of-thought tool steps show inline diffs for edit/write tools
  (synthetic from old_string/new_string or direct from tool output)
- Agent runner passes through diff/firstChangedLine from edit tool results
- Document view handles diff blocks alongside report blocks

Rich chat editor (Tiptap):
- Replace plain textarea with Tiptap-based ChatEditor
- File mention extension (@-mention files with autocomplete dropdown)
- File mention list with keyboard navigation and search via suggest-files API
- New suggest-files API endpoint for fuzzy file search

File search & navigation:
- FileSearch component in workspace sidebar (debounced search, keyboard nav)
- Search results navigate sidebar to file location and open in panel
- File picker modal for browsing/selecting workspace files

Drag & drop:
- File tree nodes support native HTML5 drag (application/x-file-mention)
  for cross-component drops (e.g. dragging files into chat editor)

Chat attachments reworked:
- Switch from browser File objects to path-based references (name + path)
- Simplified attachment strip (no media previews, shows shortened paths)

Also adds software-engineering skill and related CSS for code blocks/shiki.
2026-02-13 18:06:59 -08:00

346 lines
9.8 KiB
TypeScript

import { describe, it, expect } from "vitest";
import {
isReportFile,
classifyFileType,
reportTitleToSlug,
panelColSpan,
formatChartValue,
formatChartLabel,
validateReportConfig,
} from "./report-utils";
// ─── isReportFile ───
describe("isReportFile", () => {
it("returns true for .report.json files", () => {
expect(isReportFile("deals-pipeline.report.json")).toBe(true);
});
it("returns true for deeply nested report files", () => {
expect(isReportFile("analytics.report.json")).toBe(true);
});
it("returns false for regular JSON", () => {
expect(isReportFile("config.json")).toBe(false);
});
it("returns false for similarly named non-report files", () => {
expect(isReportFile("report.json")).toBe(false);
});
it("returns false for markdown", () => {
expect(isReportFile("report.md")).toBe(false);
});
it("returns false for empty string", () => {
expect(isReportFile("")).toBe(false);
});
});
// ─── classifyFileType ───
describe("classifyFileType", () => {
const mockIsDb = (n: string) => /\.(duckdb|sqlite|sqlite3|db)$/.test(n);
it("classifies .report.json as report", () => {
expect(classifyFileType("test.report.json", mockIsDb)).toBe("report");
});
it("classifies .duckdb as database", () => {
expect(classifyFileType("workspace.duckdb", mockIsDb)).toBe("database");
});
it("classifies .sqlite as database", () => {
expect(classifyFileType("data.sqlite", mockIsDb)).toBe("database");
});
it("classifies .md as document", () => {
expect(classifyFileType("readme.md", mockIsDb)).toBe("document");
});
it("classifies .mdx as document", () => {
expect(classifyFileType("page.mdx", mockIsDb)).toBe("document");
});
it("classifies .yaml as code", () => {
expect(classifyFileType("config.yaml", mockIsDb)).toBe("code");
});
it("classifies .ts as code", () => {
expect(classifyFileType("index.ts", mockIsDb)).toBe("code");
});
it("classifies .txt as file", () => {
expect(classifyFileType("notes.txt", mockIsDb)).toBe("file");
});
it("report takes priority over other extensions", () => {
// .report.json should be "report", not "file"
expect(classifyFileType("x.report.json", mockIsDb)).toBe("report");
});
});
// ─── reportTitleToSlug ───
describe("reportTitleToSlug", () => {
it("converts simple title to slug", () => {
expect(reportTitleToSlug("Deals Pipeline")).toBe("deals-pipeline");
});
it("removes special characters", () => {
expect(reportTitleToSlug("Q1 2025 Revenue (Draft)")).toBe("q1-2025-revenue-draft");
});
it("trims leading/trailing hyphens", () => {
expect(reportTitleToSlug(" Hello World! ")).toBe("hello-world");
});
it("truncates to 40 characters", () => {
const long = "A".repeat(100);
expect(reportTitleToSlug(long).length).toBeLessThanOrEqual(40);
});
it("handles empty string", () => {
expect(reportTitleToSlug("")).toBe("");
});
it("handles unicode/emoji gracefully", () => {
const result = reportTitleToSlug("Sales Overview 📊");
expect(result).toBe("sales-overview");
expect(result).not.toContain("📊");
});
it("collapses multiple dashes", () => {
expect(reportTitleToSlug("a --- b")).toBe("a-b");
});
});
// ─── panelColSpan ───
describe("panelColSpan", () => {
it("returns col-span-6 for full", () => {
expect(panelColSpan("full")).toBe("col-span-6");
});
it("returns col-span-3 for half", () => {
expect(panelColSpan("half")).toBe("col-span-3");
});
it("returns col-span-2 for third", () => {
expect(panelColSpan("third")).toBe("col-span-2");
});
it("returns col-span-3 for undefined (default)", () => {
expect(panelColSpan(undefined)).toBe("col-span-3");
});
it("returns col-span-3 for unknown size", () => {
expect(panelColSpan("quarter")).toBe("col-span-3");
});
});
// ─── formatChartValue ───
describe("formatChartValue", () => {
it("returns empty string for null", () => {
expect(formatChartValue(null)).toBe("");
});
it("returns empty string for undefined", () => {
expect(formatChartValue(undefined)).toBe("");
});
it("formats millions", () => {
expect(formatChartValue(1_500_000)).toBe("1.5M");
});
it("formats thousands", () => {
expect(formatChartValue(1_500)).toBe("1.5K");
});
it("formats negative millions", () => {
expect(formatChartValue(-2_500_000)).toBe("-2.5M");
});
it("formats negative thousands", () => {
expect(formatChartValue(-2_500)).toBe("-2.5K");
});
it("formats integers below 1000 as-is", () => {
expect(formatChartValue(42)).toBe("42");
});
it("formats floats to 2 decimal places", () => {
expect(formatChartValue(3.14159)).toBe("3.14");
});
it("formats zero as integer", () => {
expect(formatChartValue(0)).toBe("0");
});
it("formats strings as-is", () => {
expect(formatChartValue("hello")).toBe("hello");
});
it("formats boolean as string", () => {
expect(formatChartValue(true)).toBe("true");
});
it("formats exactly 1000", () => {
expect(formatChartValue(1000)).toBe("1.0K");
});
it("formats exactly 1000000", () => {
expect(formatChartValue(1000000)).toBe("1.0M");
});
it("formats 999 as integer", () => {
expect(formatChartValue(999)).toBe("999");
});
});
// ─── formatChartLabel ───
describe("formatChartLabel", () => {
it("returns empty string for null", () => {
expect(formatChartLabel(null)).toBe("");
});
it("returns empty string for undefined", () => {
expect(formatChartLabel(undefined)).toBe("");
});
it("returns short strings unchanged", () => {
expect(formatChartLabel("Active")).toBe("Active");
});
it("truncates long strings", () => {
const long = "A".repeat(25);
expect(formatChartLabel(long)).toBe("A".repeat(18) + "...");
});
it("shortens ISO date strings", () => {
expect(formatChartLabel("2025-06-15T10:30:00Z")).toBe("2025-06-15");
});
it("shortens full datetime strings", () => {
expect(formatChartLabel("2025-06-15 10:30:00.000")).toBe("2025-06-15");
});
it("does not shorten non-date long strings", () => {
const notDate = "This is definitely not a date string at all";
expect(formatChartLabel(notDate)).toBe("This is definitely..." );
});
it("handles numbers by converting to string", () => {
expect(formatChartLabel(42)).toBe("42");
});
it("handles exactly 20-char string (no truncation)", () => {
expect(formatChartLabel("12345678901234567890")).toBe("12345678901234567890");
});
it("truncates 21-char string", () => {
expect(formatChartLabel("123456789012345678901")).toBe("123456789012345678...");
});
});
// ─── validateReportConfig ───
describe("validateReportConfig", () => {
const validConfig = {
version: 1,
title: "Test",
panels: [
{ id: "p1", title: "P1", type: "bar", sql: "SELECT 1", mapping: { xAxis: "x" } },
],
};
it("returns null for valid config", () => {
expect(validateReportConfig(validConfig)).toBeNull();
});
it("returns null for valid config with filters", () => {
expect(validateReportConfig({
...validConfig,
filters: [{ id: "f1", type: "dateRange", label: "Date", column: "created_at" }],
})).toBeNull();
});
it("rejects null config", () => {
expect(validateReportConfig(null)).not.toBeNull();
});
it("rejects non-object config", () => {
expect(validateReportConfig("string")).not.toBeNull();
});
it("rejects missing title", () => {
expect(validateReportConfig({ panels: [] })).toContain("title");
});
it("rejects empty title", () => {
expect(validateReportConfig({ title: "", panels: [] })).toContain("title");
});
it("rejects missing panels", () => {
expect(validateReportConfig({ title: "Test" })).toContain("panels");
});
it("rejects non-array panels", () => {
expect(validateReportConfig({ title: "Test", panels: "not-array" })).toContain("panels");
});
it("accepts empty panels array", () => {
expect(validateReportConfig({ title: "Test", panels: [] })).toBeNull();
});
it("rejects panel without id", () => {
const config = { title: "Test", panels: [{ title: "P", type: "bar", sql: "SELECT 1", mapping: {} }] };
expect(validateReportConfig(config)).toContain("Panel 0");
expect(validateReportConfig(config)).toContain("id");
});
it("rejects panel without title", () => {
const config = { title: "Test", panels: [{ id: "p", type: "bar", sql: "SELECT 1", mapping: {} }] };
expect(validateReportConfig(config)).toContain("title");
});
it("rejects panel without type", () => {
const config = { title: "Test", panels: [{ id: "p", title: "P", sql: "SELECT 1", mapping: {} }] };
expect(validateReportConfig(config)).toContain("type");
});
it("rejects panel without sql", () => {
const config = { title: "Test", panels: [{ id: "p", title: "P", type: "bar", mapping: {} }] };
expect(validateReportConfig(config)).toContain("sql");
});
it("rejects panel without mapping", () => {
const config = { title: "Test", panels: [{ id: "p", title: "P", type: "bar", sql: "SELECT 1" }] };
expect(validateReportConfig(config)).toContain("mapping");
});
it("validates multiple panels", () => {
const config = {
title: "Test",
panels: [
{ id: "p1", title: "P1", type: "bar", sql: "SELECT 1", mapping: {} },
{ id: "p2", title: "P2", type: "pie", sql: "SELECT 2", mapping: {} },
],
};
expect(validateReportConfig(config)).toBeNull();
});
it("reports correct panel index on validation error", () => {
const config = {
title: "Test",
panels: [
{ id: "p1", title: "P1", type: "bar", sql: "SELECT 1", mapping: {} },
{ id: "p2", type: "pie", sql: "SELECT 2", mapping: {} }, // missing title
],
};
expect(validateReportConfig(config)).toContain("Panel 1");
});
});