import { describe, it, expect, vi } from "vitest"; import { render, screen, fireEvent, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { LineItemsEditor } from "@/components/po/po-line-items-editor"; import type { LineItemInput } from "@/lib/validations/po"; const DEFAULT_ITEM: LineItemInput = { name: "Bearing Assembly", description: "Test Item", quantity: 10, unit: "pc", unitPrice: 100, gstRate: 0.18, }; // ── Render (edit mode) ──────────────────────────────────────────────────────── describe("LineItemsEditor — edit mode", () => { it("renders one row by default when one item provided", () => { render(); // Each row has a description input expect(screen.getAllByPlaceholderText("Description (optional)")).toHaveLength(1); }); it("shows the initial description value", () => { render(); const input = screen.getByPlaceholderText("Description (optional)") as HTMLInputElement; expect(input.value).toBe("Test Item"); }); it("shows the initial quantity value", () => { render(); const inputs = screen.getAllByRole("spinbutton"); expect(inputs[0].getAttribute("value") ?? (inputs[0] as HTMLInputElement).value).toBe("10"); }); it("shows 18% as the default GST rate", () => { render(); const selects = screen.getAllByRole("combobox") as HTMLSelectElement[]; const gstSelect = selects.find((s) => s.value === "0.18"); expect(gstSelect).toBeTruthy(); expect(gstSelect!.value).toBe("0.18"); }); it("disables the remove button when only one row exists", () => { render(); const removeBtn = screen.getByRole("button", { name: /delete|remove|trash/i }); expect(removeBtn).toBeDisabled(); }); it("calls onChange when description is changed", async () => { const onChange = vi.fn(); render(); const input = screen.getByPlaceholderText("Description (optional)"); await userEvent.clear(input); await userEvent.type(input, "Gear Oil"); expect(onChange).toHaveBeenCalled(); const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0] as LineItemInput[]; expect(lastCall[0].description).toBe("Gear Oil"); }); it("adds a row when 'Add line item' is clicked", async () => { render(); const addBtn = screen.getByRole("button", { name: /add line item/i }); await userEvent.click(addBtn); expect(screen.getAllByPlaceholderText("Description (optional)")).toHaveLength(2); }); it("enables remove button after a second row is added", async () => { render(); await userEvent.click(screen.getByRole("button", { name: /add line item/i })); const removeBtns = screen.getAllByRole("button", { name: /delete|remove|trash/i }); removeBtns.forEach((btn) => expect(btn).not.toBeDisabled()); }); it("removes a row when delete is clicked (with 2 rows)", async () => { const items: LineItemInput[] = [ { ...DEFAULT_ITEM, description: "Item A" }, { ...DEFAULT_ITEM, description: "Item B" }, ]; render(); const removeBtns = screen.getAllByRole("button", { name: /delete|remove|trash/i }); await userEvent.click(removeBtns[0]); expect(screen.getAllByPlaceholderText("Description (optional)")).toHaveLength(1); }); it("calls onChange with updated gstRate when GST dropdown changes", async () => { const onChange = vi.fn(); render(); const selects = screen.getAllByRole("combobox") as HTMLSelectElement[]; const gstSelect = selects.find((s) => s.value === "0.18")!; fireEvent.change(gstSelect, { target: { value: "0.05" } }); const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0] as LineItemInput[]; expect(lastCall[0].gstRate).toBeCloseTo(0.05); }); }); // ── Totals calculation (edit mode) ──────────────────────────────────────────── describe("LineItemsEditor — totals calculation", () => { it("shows correct taxable subtotal (qty × unit price)", () => { // qty=10, unitPrice=100 → taxable=1000 render(); expect(screen.getByText(/Taxable subtotal/i)).toBeInTheDocument(); // Should show 1,000 somewhere in the footer const text = document.body.textContent ?? ""; expect(text).toMatch(/1[,.]?000/); }); it("shows correct GST amount (taxable × gstRate)", () => { // 1000 × 0.18 = 180 render(); expect(screen.getByText(/^GST$/i)).toBeInTheDocument(); const text = document.body.textContent ?? ""; expect(text).toMatch(/180/); }); it("shows correct grand total (taxable + GST)", () => { // 1000 + 180 = 1180 render(); expect(screen.getByText(/Grand Total/i)).toBeInTheDocument(); const text = document.body.textContent ?? ""; expect(text).toMatch(/1[,.]?180/); }); it("sums multiple line items correctly", () => { const items: LineItemInput[] = [ { name: "Item A", description: "Item A", quantity: 5, unit: "pc", unitPrice: 100, gstRate: 0.18 }, { name: "Item B", description: "Item B", quantity: 10, unit: "L", unitPrice: 50, gstRate: 0.18 }, ]; // Taxable: 500 + 500 = 1000; GST: 180; Grand: 1180 render(); const text = document.body.textContent ?? ""; expect(text).toMatch(/1[,.]?000/); // taxable expect(text).toMatch(/1[,.]?180/); // grand total }); it("shows correct per-row total when GST rate is 0% (zero-rated)", () => { // qty=1, unitPrice=1000, gstRate=0 → row total = ₹1,000.00, NOT ₹1,180.00 const item: LineItemInput = { name: "Test Item", quantity: 1, unit: "pc", unitPrice: 1000, gstRate: 0 }; render(); const text = document.body.textContent ?? ""; // Grand total should be 1000, not 1180 expect(text).toMatch(/1[,.]?000/); expect(text).not.toMatch(/1[,.]?180/); }); it("shows correct per-row total when GST rate is 0% after changing from 18%", async () => { // Start with 18%, change to 0%, verify row total updates correctly const item: LineItemInput = { name: "Test Item", quantity: 1, unit: "pc", unitPrice: 1000, gstRate: 0.18 }; render(); const selects = screen.getAllByRole("combobox") as HTMLSelectElement[]; const gstSelect = selects.find((s) => s.value === "0.18")!; fireEvent.change(gstSelect, { target: { value: "0" } }); // After changing to 0%, total should be 1000 not 1180 const text = document.body.textContent ?? ""; expect(text).not.toMatch(/1[,.]?180/); expect(text).toMatch(/1[,.]?000/); }); }); // ── Read-only mode ──────────────────────────────────────────────────────────── describe("LineItemsEditor — read-only mode", () => { it("renders without input fields", () => { render(); expect(screen.queryByPlaceholderText("Item description")).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: /add line item/i })).not.toBeInTheDocument(); }); it("displays the description as text", () => { render(); expect(screen.getByText("Test Item")).toBeInTheDocument(); }); it("shows GST% column in read-only mode", () => { render(); expect(screen.getByText("18%")).toBeInTheDocument(); }); it("shows taxable, GST, grand total rows in read-only footer", () => { render(); expect(screen.getByText(/Taxable subtotal/i)).toBeInTheDocument(); expect(screen.getByText(/^GST$/i)).toBeInTheDocument(); expect(screen.getByText(/Grand Total/i)).toBeInTheDocument(); }); it("shows manager-amended diff banner when originalItems provided", () => { const original: LineItemInput[] = [{ ...DEFAULT_ITEM, unitPrice: 80 }]; render(); expect(screen.getByText(/amended by manager/i)).toBeInTheDocument(); }); it("shows strikethrough on changed unit price when diff available", () => { const original: LineItemInput[] = [{ ...DEFAULT_ITEM, unitPrice: 80 }]; render(); // The original price 80 should appear with line-through styling const text = document.body.textContent ?? ""; expect(text).toMatch(/80/); expect(text).toMatch(/100/); }); });