test(web): add unit tests for MS18 components (#503)
All checks were successful
ci/woodpecker/push/web Pipeline was successful

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #503.
This commit is contained in:
2026-02-24 02:23:05 +00:00
committed by jason.woltje
parent 8b4c565f20
commit 7f89682946
4 changed files with 265 additions and 12 deletions

View File

@@ -0,0 +1,89 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { KnowledgeEditor } from "../KnowledgeEditor";
// Mock Tiptap since it requires a full DOM with contenteditable support
vi.mock("@tiptap/react", () => {
const EditorContent = ({ editor }: { editor: unknown }): React.JSX.Element => (
<div data-testid="editor-content" data-editor={editor ? "ready" : "null"} />
);
return {
useEditor: (): null => null,
EditorContent,
};
});
// Mock tiptap-markdown
vi.mock("tiptap-markdown", () => ({
Markdown: {
configure: vi.fn().mockReturnValue({}),
},
}));
// Mock lowlight
vi.mock("lowlight", () => ({
common: {},
createLowlight: vi.fn().mockReturnValue({}),
}));
// Mock extensions
vi.mock("@tiptap/starter-kit", () => ({
default: { configure: vi.fn().mockReturnValue({}) },
}));
vi.mock("@tiptap/extension-link", () => ({
default: { configure: vi.fn().mockReturnValue({}) },
}));
vi.mock("@tiptap/extension-table", () => ({
Table: { configure: vi.fn().mockReturnValue({}) },
}));
vi.mock("@tiptap/extension-table-row", () => ({
TableRow: {},
}));
vi.mock("@tiptap/extension-table-cell", () => ({
TableCell: {},
}));
vi.mock("@tiptap/extension-table-header", () => ({
TableHeader: {},
}));
vi.mock("@tiptap/extension-code-block-lowlight", () => ({
default: { configure: vi.fn().mockReturnValue({}) },
}));
vi.mock("@tiptap/extension-placeholder", () => ({
default: { configure: vi.fn().mockReturnValue({}) },
}));
describe("KnowledgeEditor", (): void => {
const defaultProps = {
content: "",
onChange: vi.fn(),
};
beforeEach((): void => {
vi.clearAllMocks();
});
it("should render loading state when editor is null", (): void => {
render(<KnowledgeEditor {...defaultProps} />);
expect(screen.getByText("Loading editor...")).toBeInTheDocument();
});
it("should render with knowledge-editor class", (): void => {
// When editor is null, the loading fallback renders instead
const { container } = render(<KnowledgeEditor {...defaultProps} />);
expect(container.firstChild).toBeInTheDocument();
});
it("should accept optional placeholder prop", (): void => {
// Smoke test that it doesn't crash with custom placeholder
render(<KnowledgeEditor {...defaultProps} placeholder="Custom placeholder" />);
expect(screen.getByText("Loading editor...")).toBeInTheDocument();
});
it("should accept optional editable prop", (): void => {
// Smoke test that it doesn't crash when read-only
render(<KnowledgeEditor {...defaultProps} editable={false} />);
expect(screen.getByText("Loading editor...")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,63 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { WidgetConfigDialog } from "../WidgetConfigDialog";
describe("WidgetConfigDialog", (): void => {
const defaultProps = {
widgetId: "TasksWidget-abc123",
open: true,
onClose: vi.fn(),
};
beforeEach((): void => {
vi.clearAllMocks();
});
it("should render nothing when closed", (): void => {
const { container } = render(<WidgetConfigDialog {...defaultProps} open={false} />);
expect(container.innerHTML).toBe("");
});
it("should render dialog when open", (): void => {
render(<WidgetConfigDialog {...defaultProps} />);
expect(screen.getByRole("dialog", { name: "Widget Settings" })).toBeInTheDocument();
});
it("should show widget name in header", (): void => {
render(<WidgetConfigDialog {...defaultProps} />);
// TasksWidget is registered with displayName "Tasks"
expect(screen.getByText(/Tasks.*Settings/)).toBeInTheDocument();
});
it("should show placeholder message when no config schema", (): void => {
render(<WidgetConfigDialog {...defaultProps} />);
expect(
screen.getByText("No configuration options available for this widget yet.")
).toBeInTheDocument();
});
it("should call onClose when close button is clicked", async (): Promise<void> => {
const user = userEvent.setup();
render(<WidgetConfigDialog {...defaultProps} />);
await user.click(screen.getByLabelText("Close"));
expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
});
it("should call onClose when footer Close button is clicked", async (): Promise<void> => {
const user = userEvent.setup();
render(<WidgetConfigDialog {...defaultProps} />);
// Footer has a "Close" text button
await user.click(screen.getByText("Close"));
expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
});
it("should handle unknown widget type gracefully", (): void => {
render(<WidgetConfigDialog {...defaultProps} widgetId="UnknownWidget-xyz" />);
// Should show fallback "Widget Settings" when type is not in registry
expect(screen.getByText("Widget Settings")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,101 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import React from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { WidgetPicker } from "../WidgetPicker";
import type { WidgetPlacement } from "@mosaic/shared";
describe("WidgetPicker", (): void => {
const defaultProps = {
open: true,
onClose: vi.fn(),
onAddWidget: vi.fn(),
currentLayout: [] as WidgetPlacement[],
};
beforeEach((): void => {
vi.clearAllMocks();
});
it("should render nothing when closed", (): void => {
const { container } = render(<WidgetPicker {...defaultProps} open={false} />);
expect(container.innerHTML).toBe("");
});
it("should render dialog when open", (): void => {
render(<WidgetPicker {...defaultProps} />);
expect(screen.getByRole("dialog", { name: "Add Widget" })).toBeInTheDocument();
});
it("should display Add Widget heading", (): void => {
render(<WidgetPicker {...defaultProps} />);
expect(screen.getByText("Add Widget")).toBeInTheDocument();
});
it("should render search input", (): void => {
render(<WidgetPicker {...defaultProps} />);
expect(screen.getByPlaceholderText("Search widgets...")).toBeInTheDocument();
});
it("should display available widgets", (): void => {
render(<WidgetPicker {...defaultProps} />);
// Widget registry has multiple widgets; at least one Add button should appear
const addButtons = screen.getAllByText("Add");
expect(addButtons.length).toBeGreaterThan(0);
});
it("should filter widgets by search text", async (): Promise<void> => {
const user = userEvent.setup();
render(<WidgetPicker {...defaultProps} />);
const searchInput = screen.getByPlaceholderText("Search widgets...");
// Type a search term that won't match any widget
await user.type(searchInput, "zzz-nonexistent-widget-zzz");
expect(screen.getByText("No widgets found")).toBeInTheDocument();
});
it("should call onAddWidget when Add is clicked", async (): Promise<void> => {
const user = userEvent.setup();
const onAdd = vi.fn();
render(<WidgetPicker {...defaultProps} onAddWidget={onAdd} />);
const addButtons = screen.getAllByText("Add");
await user.click(addButtons[0]!);
expect(onAdd).toHaveBeenCalledTimes(1);
const placement = onAdd.mock.calls[0]![0] as WidgetPlacement;
expect(placement).toHaveProperty("i");
expect(placement).toHaveProperty("x");
expect(placement).toHaveProperty("y");
expect(placement).toHaveProperty("w");
expect(placement).toHaveProperty("h");
});
it("should call onClose when close button is clicked", async (): Promise<void> => {
const user = userEvent.setup();
render(<WidgetPicker {...defaultProps} />);
await user.click(screen.getByLabelText("Close"));
expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
});
it("should place new widgets after existing layout items", async (): Promise<void> => {
const user = userEvent.setup();
const onAdd = vi.fn();
const existingLayout: WidgetPlacement[] = [
{ i: "test-1", x: 0, y: 0, w: 6, h: 3 },
{ i: "test-2", x: 0, y: 3, w: 6, h: 2 },
];
render(<WidgetPicker {...defaultProps} onAddWidget={onAdd} currentLayout={existingLayout} />);
const addButtons = screen.getAllByText("Add");
await user.click(addButtons[0]!);
const placement = onAdd.mock.calls[0]![0] as WidgetPlacement;
// Should be placed at y=5 (3+2) to avoid overlap
expect(placement.y).toBe(5);
});
});

View File

@@ -14,21 +14,21 @@
| TW-WDG-004 | done | Widget configuration UI — Per-widget settings dialog using configSchema, configure data source/filters/colors/title | #488 | web | feat/ms18-layout-management | TW-WDG-002 | TW-VER-001 | worker | 2026-02-23 | 2026-02-23 | 30K | ~8K | PR #499 merged (bundled with WDG-005) |
| TW-WDG-005 | done | Layout management UI — Save/rename/switch/delete layouts, reset to default. UI controls in dashboard header area | #488 | web | feat/ms18-layout-management | TW-WDG-002 | TW-VER-001 | worker | 2026-02-23 | 2026-02-23 | 20K | ~8K | PR #499 merged (bundled with WDG-004) |
| TW-EDT-001 | done | Tiptap integration — Install @tiptap/react + extensions, build KnowledgeEditor component with toolbar (headings, bold, italic, lists, code, links, tables) | #489 | web | feat/ms18-tiptap-editor | TW-PLAN-001 | TW-EDT-002 | worker | 2026-02-23 | 2026-02-23 | 35K | ~12K | PR #500 merged |
| TW-EDT-002 | done | Markdown round-trip + File Manager integration — Import markdown to Tiptap, export to markdown + HTML. Replace textarea in knowledge create/edit | #489 | web | feat/ms18-markdown-roundtrip | TW-EDT-001 | TW-VER-001 | worker | 2026-02-23 | 2026-02-23 | 30K | ~10K | PR #501 (pending) |
| TW-KBN-001 | not-started | Kanban filtering — Add filter bar (project, assignee, priority, search). Support project-level and user-level views. URL param persistence | #490 | web | TBD | TW-PLAN-001 | TW-VER-001 | worker | — | — | 30K | — | |
| TW-VER-001 | not-started | Tests — Unit tests for new components, update existing tests, fix any regressions | #491 | web | TBD | TW-WDG-003,TW-WDG-004,TW-WDG-005,TW-EDT-002,TW-KBN-001 | TW-VER-002,TW-DOC-001 | worker | — | — | 25K | | |
| TW-EDT-002 | done | Markdown round-trip + File Manager integration — Import markdown to Tiptap, export to markdown + HTML. Replace textarea in knowledge create/edit | #489 | web | feat/ms18-markdown-roundtrip | TW-EDT-001 | TW-VER-001 | worker | 2026-02-23 | 2026-02-23 | 30K | ~10K | PR #501 merged |
| TW-KBN-001 | done | Kanban filtering — Add filter bar (project, assignee, priority, search). Support project-level and user-level views. URL param persistence | #490 | web | feat/ms18-kanban-filtering | TW-PLAN-001 | TW-VER-001 | worker | 2026-02-23 | 2026-02-23 | 30K | ~10K | PR #502 merged |
| TW-VER-001 | done | Tests — Unit tests for new components, update existing tests, fix any regressions | #491 | web | feat/ms18-verification-tests | TW-WDG-003,TW-WDG-004,TW-WDG-005,TW-EDT-002,TW-KBN-001 | TW-VER-002,TW-DOC-001 | worker | 2026-02-23 | 2026-02-23 | 25K | ~8K | PR pending; 20 new tests (1195 total) |
| TW-VER-002 | not-started | Theme verification — Verify all 5 themes render correctly on all pages, no broken colors/contrast issues | #491 | web | TBD | TW-THM-003,TW-VER-001 | TW-DOC-001 | worker | — | — | 15K | — | |
| TW-DOC-001 | not-started | Documentation updates — TASKS.md, manifest, scratchpad, PRD status updates | #491 | — | — | TW-VER-001,TW-VER-002 | TW-VER-003 | orchestrator | — | — | 10K | — | |
| TW-VER-003 | not-started | Deploy to Coolify + smoke test — Deploy, verify themes/widgets/editor/kanban all functional, auth working, no console errors | #491 | — | — | TW-DOC-001 | | orchestrator | — | — | 15K | — | |
## Summary
| Metric | Value |
| ------------- | ---------------------------------------------------- |
| Total tasks | 16 |
| Completed | 12 (PLAN-001, THM-001003, WDG-001005, EDT-001002) |
| In Progress | 0 |
| Remaining | 4 |
| PRs merged | #493#500, #501 (pending) |
| Issues closed | |
| Milestone | MS18-ThemeWidgets |
| Metric | Value |
| ------------- | ---------------------------------------------------------------------- |
| Total tasks | 16 |
| Completed | 14 (PLAN-001, THM-001003, WDG-001005, EDT-001002, KBN-001, VER-001) |
| In Progress | 0 |
| Remaining | 2 |
| PRs merged | #493#502 |
| Issues closed | #487, #488, #489, #490 |
| Milestone | MS18-ThemeWidgets |