pelagia-portal/App/tests/unit/po-line-items-editor.test.tsx
2026-05-18 23:18:58 +05:30

178 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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