From 7f89682946316668e50cc77a8daa329f7c61310c Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 24 Feb 2026 02:23:05 +0000 Subject: [PATCH] test(web): add unit tests for MS18 components (#503) Co-authored-by: Jason Woltje Co-committed-by: Jason Woltje --- .../__tests__/KnowledgeEditor.test.tsx | 89 +++++++++++++++ .../__tests__/WidgetConfigDialog.test.tsx | 63 +++++++++++ .../widgets/__tests__/WidgetPicker.test.tsx | 101 ++++++++++++++++++ docs/TASKS.md | 24 ++--- 4 files changed, 265 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/components/knowledge/__tests__/KnowledgeEditor.test.tsx create mode 100644 apps/web/src/components/widgets/__tests__/WidgetConfigDialog.test.tsx create mode 100644 apps/web/src/components/widgets/__tests__/WidgetPicker.test.tsx diff --git a/apps/web/src/components/knowledge/__tests__/KnowledgeEditor.test.tsx b/apps/web/src/components/knowledge/__tests__/KnowledgeEditor.test.tsx new file mode 100644 index 0000000..14821dc --- /dev/null +++ b/apps/web/src/components/knowledge/__tests__/KnowledgeEditor.test.tsx @@ -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 => ( +
+ ); + + 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(); + 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(); + expect(container.firstChild).toBeInTheDocument(); + }); + + it("should accept optional placeholder prop", (): void => { + // Smoke test that it doesn't crash with custom placeholder + render(); + expect(screen.getByText("Loading editor...")).toBeInTheDocument(); + }); + + it("should accept optional editable prop", (): void => { + // Smoke test that it doesn't crash when read-only + render(); + expect(screen.getByText("Loading editor...")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/widgets/__tests__/WidgetConfigDialog.test.tsx b/apps/web/src/components/widgets/__tests__/WidgetConfigDialog.test.tsx new file mode 100644 index 0000000..e07d9cd --- /dev/null +++ b/apps/web/src/components/widgets/__tests__/WidgetConfigDialog.test.tsx @@ -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(); + expect(container.innerHTML).toBe(""); + }); + + it("should render dialog when open", (): void => { + render(); + expect(screen.getByRole("dialog", { name: "Widget Settings" })).toBeInTheDocument(); + }); + + it("should show widget name in header", (): void => { + render(); + // TasksWidget is registered with displayName "Tasks" + expect(screen.getByText(/Tasks.*Settings/)).toBeInTheDocument(); + }); + + it("should show placeholder message when no config schema", (): void => { + render(); + expect( + screen.getByText("No configuration options available for this widget yet.") + ).toBeInTheDocument(); + }); + + it("should call onClose when close button is clicked", async (): Promise => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByLabelText("Close")); + expect(defaultProps.onClose).toHaveBeenCalledTimes(1); + }); + + it("should call onClose when footer Close button is clicked", async (): Promise => { + const user = userEvent.setup(); + render(); + + // 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(); + // Should show fallback "Widget Settings" when type is not in registry + expect(screen.getByText("Widget Settings")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/widgets/__tests__/WidgetPicker.test.tsx b/apps/web/src/components/widgets/__tests__/WidgetPicker.test.tsx new file mode 100644 index 0000000..dfc680a --- /dev/null +++ b/apps/web/src/components/widgets/__tests__/WidgetPicker.test.tsx @@ -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(); + expect(container.innerHTML).toBe(""); + }); + + it("should render dialog when open", (): void => { + render(); + expect(screen.getByRole("dialog", { name: "Add Widget" })).toBeInTheDocument(); + }); + + it("should display Add Widget heading", (): void => { + render(); + expect(screen.getByText("Add Widget")).toBeInTheDocument(); + }); + + it("should render search input", (): void => { + render(); + expect(screen.getByPlaceholderText("Search widgets...")).toBeInTheDocument(); + }); + + it("should display available widgets", (): void => { + render(); + // 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 => { + const user = userEvent.setup(); + render(); + + 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 => { + const user = userEvent.setup(); + const onAdd = vi.fn(); + render(); + + 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 => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByLabelText("Close")); + expect(defaultProps.onClose).toHaveBeenCalledTimes(1); + }); + + it("should place new widgets after existing layout items", async (): Promise => { + 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(); + + 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); + }); +}); diff --git a/docs/TASKS.md b/docs/TASKS.md index 7b47bfd..32cf633 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -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-001–003, WDG-001–005, EDT-001–002) | -| 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-001–003, WDG-001–005, EDT-001–002, KBN-001, VER-001) | +| In Progress | 0 | +| Remaining | 2 | +| PRs merged | #493–#502 | +| Issues closed | #487, #488, #489, #490 | +| Milestone | MS18-ThemeWidgets |