178 lines
8.1 KiB
TypeScript
178 lines
8.1 KiB
TypeScript
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 = {
|
||
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(<LineItemsEditor items={[DEFAULT_ITEM]} onChange={vi.fn()} />);
|
||
// Each row has a description input
|
||
expect(screen.getAllByPlaceholderText("Item description")).toHaveLength(1);
|
||
});
|
||
|
||
it("shows the initial description value", () => {
|
||
render(<LineItemsEditor items={[DEFAULT_ITEM]} onChange={vi.fn()} />);
|
||
const input = screen.getByPlaceholderText("Item description") as HTMLInputElement;
|
||
expect(input.value).toBe("Test Item");
|
||
});
|
||
|
||
it("shows the initial quantity value", () => {
|
||
render(<LineItemsEditor items={[DEFAULT_ITEM]} onChange={vi.fn()} />);
|
||
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(<LineItemsEditor items={[DEFAULT_ITEM]} onChange={vi.fn()} />);
|
||
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(<LineItemsEditor items={[DEFAULT_ITEM]} onChange={vi.fn()} />);
|
||
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(<LineItemsEditor items={[DEFAULT_ITEM]} onChange={onChange} />);
|
||
const input = screen.getByPlaceholderText("Item description");
|
||
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(<LineItemsEditor items={[DEFAULT_ITEM]} onChange={vi.fn()} />);
|
||
const addBtn = screen.getByRole("button", { name: /add line item/i });
|
||
await userEvent.click(addBtn);
|
||
expect(screen.getAllByPlaceholderText("Item description")).toHaveLength(2);
|
||
});
|
||
|
||
it("enables remove button after a second row is added", async () => {
|
||
render(<LineItemsEditor items={[DEFAULT_ITEM]} onChange={vi.fn()} />);
|
||
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(<LineItemsEditor items={items} onChange={vi.fn()} />);
|
||
const removeBtns = screen.getAllByRole("button", { name: /delete|remove|trash/i });
|
||
await userEvent.click(removeBtns[0]);
|
||
expect(screen.getAllByPlaceholderText("Item description")).toHaveLength(1);
|
||
});
|
||
|
||
it("calls onChange with updated gstRate when GST dropdown changes", async () => {
|
||
const onChange = vi.fn();
|
||
render(<LineItemsEditor items={[DEFAULT_ITEM]} onChange={onChange} />);
|
||
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(<LineItemsEditor items={[DEFAULT_ITEM]} onChange={vi.fn()} />);
|
||
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(<LineItemsEditor items={[DEFAULT_ITEM]} onChange={vi.fn()} />);
|
||
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(<LineItemsEditor items={[DEFAULT_ITEM]} onChange={vi.fn()} />);
|
||
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[] = [
|
||
{ description: "Item A", quantity: 5, unit: "pc", unitPrice: 100, gstRate: 0.18 },
|
||
{ description: "Item B", quantity: 10, unit: "L", unitPrice: 50, gstRate: 0.18 },
|
||
];
|
||
// Taxable: 500 + 500 = 1000; GST: 180; Grand: 1180
|
||
render(<LineItemsEditor items={items} onChange={vi.fn()} />);
|
||
const text = document.body.textContent ?? "";
|
||
expect(text).toMatch(/1[,.]?000/); // taxable
|
||
expect(text).toMatch(/1[,.]?180/); // grand total
|
||
});
|
||
});
|
||
|
||
// ── Read-only mode ────────────────────────────────────────────────────────────
|
||
|
||
describe("LineItemsEditor — read-only mode", () => {
|
||
it("renders without input fields", () => {
|
||
render(<LineItemsEditor items={[DEFAULT_ITEM]} readOnly />);
|
||
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(<LineItemsEditor items={[DEFAULT_ITEM]} readOnly />);
|
||
expect(screen.getByText("Test Item")).toBeInTheDocument();
|
||
});
|
||
|
||
it("shows GST% column in read-only mode", () => {
|
||
render(<LineItemsEditor items={[DEFAULT_ITEM]} readOnly />);
|
||
expect(screen.getByText("18%")).toBeInTheDocument();
|
||
});
|
||
|
||
it("shows taxable, GST, grand total rows in read-only footer", () => {
|
||
render(<LineItemsEditor items={[DEFAULT_ITEM]} readOnly />);
|
||
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(<LineItemsEditor items={[DEFAULT_ITEM]} readOnly originalItems={original} />);
|
||
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(<LineItemsEditor items={[DEFAULT_ITEM]} readOnly originalItems={original} />);
|
||
// The original price 80 should appear with line-through styling
|
||
const text = document.body.textContent ?? "";
|
||
expect(text).toMatch(/80/);
|
||
expect(text).toMatch(/100/);
|
||
});
|
||
});
|