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/);
});
});