From e43cc07a8740e861effaed5fb51567fe5c57bb82 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 11 Jun 2026 17:17:09 +0500 Subject: [PATCH] test(ux): render + interaction coverage for the new UX components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover the three components added in the UX pass that had only store/helper unit tests: - DensitySelector — renders both options; pressing one writes the global density through ui-store; toggles back. - QuickCreateSheet — flattens object leaves from app navigation (incl. nested groups), dedupes an object surfaced under multiple views, ignores non-object leaves; pressing a row routes to the object's create form and closes the sheet; empty hint when nothing is creatable. - EditableSelectField (via DetailViewRenderer) — stays read-only without an onFieldEdit handler; opening the picker and choosing a new option calls onFieldEdit(field, value); re-selecting the current value is a no-op. QuickCreateSheet's chain doesn't transitively import ~/lib/i18n, so the test imports it to initialize i18next. Full suite 1337 passing. Co-Authored-By: Claude Opus 4.8 (1M context) --- __tests__/components/DensitySelector.test.tsx | 34 ++++++++ .../DetailInlineSelectEdit.test.tsx | 72 +++++++++++++++++ .../components/QuickCreateSheet.test.tsx | 79 +++++++++++++++++++ 3 files changed, 185 insertions(+) create mode 100644 __tests__/components/DensitySelector.test.tsx create mode 100644 __tests__/components/DetailInlineSelectEdit.test.tsx create mode 100644 __tests__/components/QuickCreateSheet.test.tsx diff --git a/__tests__/components/DensitySelector.test.tsx b/__tests__/components/DensitySelector.test.tsx new file mode 100644 index 0000000..5ee6fc5 --- /dev/null +++ b/__tests__/components/DensitySelector.test.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { render, fireEvent } from "@testing-library/react-native"; + +import { DensitySelector } from "~/components/common/DensitySelector"; +import { useUIStore } from "~/stores/ui-store"; + +describe("DensitySelector", () => { + beforeEach(() => { + // Reset the shared store to the default before each case. + useUIStore.setState({ density: "comfortable" }); + }); + + it("renders both density options", () => { + const { getByText } = render(); + expect(getByText("Comfortable")).toBeTruthy(); + expect(getByText("Compact")).toBeTruthy(); + }); + + it("switches the global density to compact on press", () => { + const { getByLabelText } = render(); + expect(useUIStore.getState().density).toBe("comfortable"); + + fireEvent.press(getByLabelText("Compact")); + expect(useUIStore.getState().density).toBe("compact"); + }); + + it("toggles back to comfortable", () => { + useUIStore.setState({ density: "compact" }); + const { getByLabelText } = render(); + + fireEvent.press(getByLabelText("Comfortable")); + expect(useUIStore.getState().density).toBe("comfortable"); + }); +}); diff --git a/__tests__/components/DetailInlineSelectEdit.test.tsx b/__tests__/components/DetailInlineSelectEdit.test.tsx new file mode 100644 index 0000000..21db80d --- /dev/null +++ b/__tests__/components/DetailInlineSelectEdit.test.tsx @@ -0,0 +1,72 @@ +import React from "react"; +import { render, fireEvent } from "@testing-library/react-native"; + +import { DetailViewRenderer } from "~/components/renderers/DetailViewRenderer"; +import type { FieldDefinition, FormViewMeta } from "~/components/renderers/types"; + +/** + * Inline select/status editing on the detail screen. The editable badge is an + * internal component (`EditableSelectField`), so it's exercised through + * `DetailViewRenderer` with an `onFieldEdit` handler. + */ +describe("DetailViewRenderer — inline select edit", () => { + const record = { id: "r1", status: "todo" }; + const fields: FieldDefinition[] = [ + { + name: "status", + label: "Status", + type: "select", + options: [ + { value: "todo", label: "To Do" }, + { value: "in_progress", label: "In Progress" }, + { value: "done", label: "Done" }, + ], + }, + ]; + // A curated view guarantees the field renders regardless of value. + const view: FormViewMeta = { sections: [{ fields: ["status"] }] }; + + it("stays read-only (no edit affordance) without an onFieldEdit handler", () => { + const { getByText, queryByLabelText } = render( + , + ); + // The current value still shows as its option label… + expect(getByText("To Do")).toBeTruthy(); + // …but there is no tappable edit control. + expect(queryByLabelText("Edit Status")).toBeNull(); + }); + + it("opens the picker and persists a new value via onFieldEdit", () => { + const onFieldEdit = jest.fn().mockResolvedValue(undefined); + const { getByLabelText } = render( + , + ); + + fireEvent.press(getByLabelText("Edit Status")); + fireEvent.press(getByLabelText("In Progress")); + + expect(onFieldEdit).toHaveBeenCalledWith("status", "in_progress"); + }); + + it("does not persist when the current value is re-selected", () => { + const onFieldEdit = jest.fn(); + const { getByLabelText } = render( + , + ); + + fireEvent.press(getByLabelText("Edit Status")); + fireEvent.press(getByLabelText("To Do")); // already the current value + + expect(onFieldEdit).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/components/QuickCreateSheet.test.tsx b/__tests__/components/QuickCreateSheet.test.tsx new file mode 100644 index 0000000..32ea968 --- /dev/null +++ b/__tests__/components/QuickCreateSheet.test.tsx @@ -0,0 +1,79 @@ +import React from "react"; +import { render, fireEvent } from "@testing-library/react-native"; + +import "~/lib/i18n"; // initialize i18next so t() resolves (the component chain doesn't) +import { QuickCreateSheet } from "~/components/home/QuickCreateSheet"; +import type { AppMeta } from "~/hooks/useApps"; + +// The global expo-router mock hands back a fresh `push` per call, so override it +// here with a shared spy we can assert on. +const mockPush = jest.fn(); +jest.mock("expo-router", () => ({ + useRouter: () => ({ push: mockPush, replace: jest.fn(), back: jest.fn() }), +})); + +const apps: AppMeta[] = [ + { + name: "todo_app", + label: "Todo", + navigation: [ + { + id: "g1", + type: "group", + label: "Tasks", + children: [ + { id: "n1", type: "object", label: "All Tasks", objectName: "todo_task" }, + ], + }, + // Non-object leaves are ignored by quick-create. + { id: "n2", type: "dashboard", label: "Dashboard", dashboardName: "d1" }, + ], + }, + { + name: "crm", + label: "CRM", + navigation: [ + { id: "n3", type: "object", label: "Contacts", objectName: "contact" }, + // Same app/object surfaced under a second view — must dedupe. + { id: "n4", type: "object", label: "Contacts (Kanban)", objectName: "contact" }, + ], + }, +]; + +describe("QuickCreateSheet", () => { + beforeEach(() => mockPush.mockClear()); + + it("lists creatable objects flattened from the apps' navigation, deduped", () => { + const { getByText, queryByText, queryAllByText } = render( + , + ); + + // Object leaves (incl. those nested in a group) surface. + expect(getByText("All Tasks")).toBeTruthy(); + expect(getByText("Contacts")).toBeTruthy(); + // The dashboard leaf is not a creatable object. + expect(queryByText("Dashboard")).toBeNull(); + // The second view over `contact` is deduped away. + expect(queryByText("Contacts (Kanban)")).toBeNull(); + expect(queryAllByText("Contacts")).toHaveLength(1); + }); + + it("opens the object's create form and closes the sheet on press", () => { + const onOpenChange = jest.fn(); + const { getByLabelText } = render( + , + ); + + fireEvent.press(getByLabelText("All Tasks")); + + expect(onOpenChange).toHaveBeenCalledWith(false); + expect(mockPush).toHaveBeenCalledWith("/(app)/todo_app/todo_task/new"); + }); + + it("shows an empty hint when no app publishes a creatable object", () => { + const { getByText } = render( + , + ); + expect(getByText("No objects available to create.")).toBeTruthy(); + }); +});