provider-batch-preview-step.test.tsx 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. /**
  2. * @vitest-environment happy-dom
  3. */
  4. import { act } from "react";
  5. import { createRoot } from "react-dom/client";
  6. import { beforeEach, describe, expect, it, vi } from "vitest";
  7. import type { ProviderBatchPreviewRow } from "@/actions/providers";
  8. import { ProviderBatchPreviewStep } from "@/app/[locale]/settings/providers/_components/batch-edit/provider-batch-preview-step";
  9. // ---------------------------------------------------------------------------
  10. // Mocks
  11. // ---------------------------------------------------------------------------
  12. vi.mock("next-intl", () => ({
  13. useTranslations: () => {
  14. const t = (key: string, params?: Record<string, unknown>) => {
  15. if (params) {
  16. let result = key;
  17. for (const [k, v] of Object.entries(params)) {
  18. result = result.replace(`{${k}}`, String(v));
  19. }
  20. return result;
  21. }
  22. return key;
  23. };
  24. return t;
  25. },
  26. }));
  27. vi.mock("@/components/ui/checkbox", () => ({
  28. Checkbox: ({ checked, onCheckedChange, ...props }: any) => (
  29. <input
  30. type="checkbox"
  31. checked={checked}
  32. onChange={() => onCheckedChange?.(!checked)}
  33. {...props}
  34. />
  35. ),
  36. }));
  37. vi.mock("lucide-react", () => ({
  38. Loader2: () => <div data-testid="loader-icon" />,
  39. }));
  40. // ---------------------------------------------------------------------------
  41. // Helpers
  42. // ---------------------------------------------------------------------------
  43. function render(ui: React.ReactElement) {
  44. const container = document.createElement("div");
  45. document.body.appendChild(container);
  46. let root: ReturnType<typeof createRoot>;
  47. act(() => {
  48. root = createRoot(container);
  49. root.render(ui);
  50. });
  51. return {
  52. container,
  53. unmount: () => {
  54. act(() => root.unmount());
  55. container.remove();
  56. },
  57. };
  58. }
  59. function makeRow(overrides: Partial<ProviderBatchPreviewRow> = {}): ProviderBatchPreviewRow {
  60. return {
  61. providerId: 1,
  62. providerName: "TestProvider",
  63. field: "priority",
  64. status: "changed",
  65. before: 0,
  66. after: 10,
  67. ...overrides,
  68. };
  69. }
  70. const defaultSummary = { providerCount: 2, fieldCount: 3, skipCount: 1 };
  71. // ---------------------------------------------------------------------------
  72. // Tests
  73. // ---------------------------------------------------------------------------
  74. describe("ProviderBatchPreviewStep", () => {
  75. beforeEach(() => {
  76. vi.clearAllMocks();
  77. });
  78. it("renders changed rows with before/after values", () => {
  79. const rows: ProviderBatchPreviewRow[] = [
  80. makeRow({ providerId: 1, providerName: "Alpha", field: "priority", before: 0, after: 5 }),
  81. makeRow({ providerId: 1, providerName: "Alpha", field: "weight", before: 1, after: 10 }),
  82. ];
  83. const { container, unmount } = render(
  84. <ProviderBatchPreviewStep
  85. rows={rows}
  86. summary={{ providerCount: 1, fieldCount: 2, skipCount: 0 }}
  87. excludedProviderIds={new Set()}
  88. onExcludeToggle={() => {}}
  89. />
  90. );
  91. const changedRow1 = container.querySelector('[data-testid="preview-row-1-priority"]');
  92. expect(changedRow1).toBeTruthy();
  93. expect(changedRow1?.getAttribute("data-status")).toBe("changed");
  94. // Mock t() returns key with params substituted where {param} appears in key
  95. // "preview.fieldChanged" does not contain {field} etc, so text is key with params inserted
  96. expect(changedRow1?.textContent).toContain("preview.fieldChanged");
  97. const changedRow2 = container.querySelector('[data-testid="preview-row-1-weight"]');
  98. expect(changedRow2).toBeTruthy();
  99. expect(changedRow2?.getAttribute("data-status")).toBe("changed");
  100. unmount();
  101. });
  102. it("renders skipped rows with skip reason", () => {
  103. const rows: ProviderBatchPreviewRow[] = [
  104. makeRow({
  105. providerId: 2,
  106. providerName: "Beta",
  107. field: "anthropic_thinking_budget_preference",
  108. status: "skipped",
  109. before: null,
  110. after: null,
  111. skipReason: "not_applicable",
  112. }),
  113. ];
  114. const { container, unmount } = render(
  115. <ProviderBatchPreviewStep
  116. rows={rows}
  117. summary={{ providerCount: 1, fieldCount: 0, skipCount: 1 }}
  118. excludedProviderIds={new Set()}
  119. onExcludeToggle={() => {}}
  120. />
  121. );
  122. const skippedRow = container.querySelector(
  123. '[data-testid="preview-row-2-anthropic_thinking_budget_preference"]'
  124. );
  125. expect(skippedRow).toBeTruthy();
  126. expect(skippedRow?.getAttribute("data-status")).toBe("skipped");
  127. expect(skippedRow?.textContent).toContain("preview.fieldSkipped");
  128. unmount();
  129. });
  130. it("groups rows by provider", () => {
  131. const rows: ProviderBatchPreviewRow[] = [
  132. makeRow({ providerId: 1, providerName: "Alpha", field: "priority" }),
  133. makeRow({ providerId: 2, providerName: "Beta", field: "weight" }),
  134. makeRow({ providerId: 1, providerName: "Alpha", field: "is_enabled" }),
  135. ];
  136. const { container, unmount } = render(
  137. <ProviderBatchPreviewStep
  138. rows={rows}
  139. summary={defaultSummary}
  140. excludedProviderIds={new Set()}
  141. onExcludeToggle={() => {}}
  142. />
  143. );
  144. const provider1 = container.querySelector('[data-testid="preview-provider-1"]');
  145. const provider2 = container.querySelector('[data-testid="preview-provider-2"]');
  146. expect(provider1).toBeTruthy();
  147. expect(provider2).toBeTruthy();
  148. // Provider 1 should have 2 rows
  149. const p1Rows = provider1?.querySelectorAll("[data-status]");
  150. expect(p1Rows?.length).toBe(2);
  151. // Provider 2 should have 1 row
  152. const p2Rows = provider2?.querySelectorAll("[data-status]");
  153. expect(p2Rows?.length).toBe(1);
  154. unmount();
  155. });
  156. it("shows summary counts", () => {
  157. const rows: ProviderBatchPreviewRow[] = [
  158. makeRow({ providerId: 1, providerName: "Alpha", field: "priority" }),
  159. ];
  160. const { container, unmount } = render(
  161. <ProviderBatchPreviewStep
  162. rows={rows}
  163. summary={{ providerCount: 5, fieldCount: 8, skipCount: 2 }}
  164. excludedProviderIds={new Set()}
  165. onExcludeToggle={() => {}}
  166. />
  167. );
  168. const summary = container.querySelector('[data-testid="preview-summary"]');
  169. expect(summary).toBeTruthy();
  170. // The mock t() substitutes {providerCount} -> 5, {fieldCount} -> 8, {skipCount} -> 2
  171. // into the key "preview.summary" which becomes "preview.summary" with params replaced
  172. const text = summary?.textContent ?? "";
  173. expect(text).toContain("preview.summary");
  174. unmount();
  175. });
  176. it("exclusion checkbox toggles provider", () => {
  177. const onToggle = vi.fn();
  178. const rows: ProviderBatchPreviewRow[] = [
  179. makeRow({ providerId: 3, providerName: "Gamma", field: "priority" }),
  180. ];
  181. const { container, unmount } = render(
  182. <ProviderBatchPreviewStep
  183. rows={rows}
  184. summary={defaultSummary}
  185. excludedProviderIds={new Set()}
  186. onExcludeToggle={onToggle}
  187. />
  188. );
  189. const checkbox = container.querySelector(
  190. '[data-testid="exclude-checkbox-3"]'
  191. ) as HTMLInputElement;
  192. expect(checkbox).toBeTruthy();
  193. expect(checkbox.checked).toBe(true); // not excluded = checked
  194. act(() => {
  195. checkbox.click();
  196. });
  197. expect(onToggle).toHaveBeenCalledWith(3);
  198. unmount();
  199. });
  200. it("loading state shows spinner", () => {
  201. const { container, unmount } = render(
  202. <ProviderBatchPreviewStep
  203. rows={[]}
  204. summary={{ providerCount: 0, fieldCount: 0, skipCount: 0 }}
  205. excludedProviderIds={new Set()}
  206. onExcludeToggle={() => {}}
  207. isLoading={true}
  208. />
  209. );
  210. const loading = container.querySelector('[data-testid="preview-loading"]');
  211. expect(loading).toBeTruthy();
  212. // Should not show the empty state
  213. const empty = container.querySelector('[data-testid="preview-empty"]');
  214. expect(empty).toBeNull();
  215. unmount();
  216. });
  217. it("shows empty state when no rows and not loading", () => {
  218. const { container, unmount } = render(
  219. <ProviderBatchPreviewStep
  220. rows={[]}
  221. summary={{ providerCount: 0, fieldCount: 0, skipCount: 0 }}
  222. excludedProviderIds={new Set()}
  223. onExcludeToggle={() => {}}
  224. />
  225. );
  226. const empty = container.querySelector('[data-testid="preview-empty"]');
  227. expect(empty).toBeTruthy();
  228. unmount();
  229. });
  230. it("excluded provider checkbox shows unchecked", () => {
  231. const rows: ProviderBatchPreviewRow[] = [
  232. makeRow({ providerId: 7, providerName: "Excluded", field: "weight" }),
  233. ];
  234. const { container, unmount } = render(
  235. <ProviderBatchPreviewStep
  236. rows={rows}
  237. summary={defaultSummary}
  238. excludedProviderIds={new Set([7])}
  239. onExcludeToggle={() => {}}
  240. />
  241. );
  242. const checkbox = container.querySelector(
  243. '[data-testid="exclude-checkbox-7"]'
  244. ) as HTMLInputElement;
  245. expect(checkbox).toBeTruthy();
  246. expect(checkbox.checked).toBe(false); // excluded = unchecked
  247. unmount();
  248. });
  249. });