pelagia-portal/App/tests/unit/po-line-items-editor.test.tsx
Claude (auto-fix) e9e618fda8
All checks were successful
PR checks / checks (pull_request) Successful in 31s
feat(po): add week, month, year to line-item unit options
The PO line-items Unit of Measure dropdown only offered hr/day among
time-based units. Add week, month and year so durations beyond days can
be selected, as requested. UOM_OPTIONS is the single source of truth and
`unit` is validated as a free-form string, so no schema/validation change
is needed.

Fixes #44

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 00:32:39 +05:30

221 lines
10 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 = {
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(<LineItemsEditor items={[DEFAULT_ITEM]} onChange={vi.fn()} />);
// Each row has a description input
expect(screen.getAllByPlaceholderText("Description (optional)")).toHaveLength(1);
});
it("shows the initial description value", () => {
render(<LineItemsEditor items={[DEFAULT_ITEM]} onChange={vi.fn()} />);
const input = screen.getByPlaceholderText("Description (optional)") 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("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(<LineItemsEditor items={[DEFAULT_ITEM]} onChange={vi.fn()} />);
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(<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("Description (optional)")).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);
});
it("offers month and year as unit-of-measure options", () => {
render(<LineItemsEditor items={[DEFAULT_ITEM]} onChange={vi.fn()} />);
const selects = screen.getAllByRole("combobox") as HTMLSelectElement[];
const unitSelect = selects.find((s) => s.value === "pc")!;
const values = Array.from(unitSelect.options).map((o) => o.value);
expect(values).toContain("month");
expect(values).toContain("year");
});
it("calls onChange with the selected duration unit", async () => {
const onChange = vi.fn();
render(<LineItemsEditor items={[DEFAULT_ITEM]} onChange={onChange} />);
const selects = screen.getAllByRole("combobox") as HTMLSelectElement[];
const unitSelect = selects.find((s) => s.value === "pc")!;
fireEvent.change(unitSelect, { target: { value: "year" } });
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0] as LineItemInput[];
expect(lastCall[0].unit).toBe("year");
});
});
// ── 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[] = [
{ 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(<LineItemsEditor items={items} onChange={vi.fn()} />);
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(<LineItemsEditor items={[item]} onChange={vi.fn()} />);
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(<LineItemsEditor items={[item]} onChange={vi.fn()} />);
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(<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/);
});
});