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.
346 lines
9.8 KiB
TypeScript
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");
|
|
});
|
|
});
|