UI fix:
- Close-only modals showing a redundant second close button now use
hideCancel: ReceivedInvoices paid-detail modal (was two identical "Zavřít"
buttons) and PlanCellModal "Den je součástí rozsahu".
- Add modal-duplicate-close test enforcing close-only modals set hideCancel.
Lint: cleared all 68 warnings → 0.
- preserve-caught-error: attach { cause } in ai.service / exchange-rates.
- no-require-imports: package.json version read via fs (APP_VERSION) instead
of require(), avoiding a rootDir-expanding static JSON import.
- react-hooks/exhaustive-deps (11): ref-in-cleanup copies, derived-value
useMemo wrapping, PlanGrid field extraction, stable nextKey useCallback,
AuthContext documented cycle-break.
- no-explicit-any (53): precise route param/Prisma types, generic enrich*()
preserving payload shape, minimal vite module type, frontend body/query-key
types, SystemInfo for Settings.
Refactor (test enablement): shift-form types moved to dependency-free
shiftFormTypes.ts so the print-HTML builders are unit-testable without the
component graph; characterization test pins their output.
Gates: 649 tests pass, tsc -b clean, lint 0. Verified touched flows live
via Playwright (PlanWork CRUD + optimistic cache, warehouse form keys,
Settings system info, invoice detail).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
156 lines
4.6 KiB
TypeScript
156 lines
4.6 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
buildProjectLogsHtml,
|
|
buildUserSectionHtml,
|
|
buildPrintHtml,
|
|
} from "../admin/hooks/useAttendanceAdmin";
|
|
|
|
/**
|
|
* Characterization test for the attendance print-HTML builders.
|
|
*
|
|
* These builders had `any`-typed params; this pins their CURRENT output so the
|
|
* follow-up retyping (precise AttendanceRecord/UserTotal/PrintData types) is
|
|
* provably behavior-preserving. The only runtime touch in that retype is
|
|
* `parseInt(log.hours)` → `parseInt(String(log.hours))` to satisfy the typed
|
|
* `string | number | null` shape — the cases below exercise both string and
|
|
* numeric hours/minutes precisely so that equivalence is locked in.
|
|
*
|
|
* Fixtures are cast through `Parameters<typeof fn>` so the test compiles both
|
|
* before and after the param types tighten.
|
|
*/
|
|
|
|
type RecordArg = Parameters<typeof buildProjectLogsHtml>[0];
|
|
type UserDataArg = Parameters<typeof buildUserSectionHtml>[1];
|
|
type PrintDataArg = Parameters<typeof buildUserSectionHtml>[2];
|
|
const asRecord = (o: unknown) => o as unknown as RecordArg;
|
|
const asUserData = (o: unknown) => o as unknown as UserDataArg;
|
|
const asPrintData = (o: unknown) => o as unknown as PrintDataArg;
|
|
|
|
describe("buildProjectLogsHtml", () => {
|
|
it("formats hours/minutes given as strings", () => {
|
|
expect(
|
|
buildProjectLogsHtml(
|
|
asRecord({
|
|
project_logs: [
|
|
{ project_id: 1, project_name: "Alpha", hours: "2", minutes: "30" },
|
|
],
|
|
}),
|
|
),
|
|
).toBe("<div>Alpha (2:30h)</div>");
|
|
});
|
|
|
|
it("formats hours/minutes given as numbers (parseInt-of-number path)", () => {
|
|
expect(
|
|
buildProjectLogsHtml(
|
|
asRecord({
|
|
project_logs: [
|
|
{ project_id: 2, project_name: "Beta", hours: 1, minutes: 5 },
|
|
],
|
|
}),
|
|
),
|
|
).toBe("<div>Beta (1:05h)</div>");
|
|
});
|
|
|
|
it("derives duration from started_at/ended_at when hours are absent", () => {
|
|
expect(
|
|
buildProjectLogsHtml(
|
|
asRecord({
|
|
project_logs: [
|
|
{
|
|
project_id: 3,
|
|
project_name: "Gamma",
|
|
started_at: "2098-01-05T08:00:00",
|
|
ended_at: "2098-01-05T10:30:00",
|
|
},
|
|
],
|
|
}),
|
|
),
|
|
).toBe("<div>Gamma (2:30h)</div>");
|
|
});
|
|
|
|
it("falls back to '#id' and 0:00 when name and times are missing", () => {
|
|
expect(
|
|
buildProjectLogsHtml(asRecord({ project_logs: [{ project_id: 7 }] })),
|
|
).toBe("<div>#7 (0:00h)</div>");
|
|
});
|
|
|
|
it("renders the bare project name when there are no logs", () => {
|
|
expect(
|
|
buildProjectLogsHtml(
|
|
asRecord({ project_name: "Solo & <Co>", project_logs: [] }),
|
|
),
|
|
).toBe("Solo & <Co>");
|
|
});
|
|
});
|
|
|
|
// Deterministic fixture: a fixed far-future month, one work day (with a numeric
|
|
// project log so the snapshot also guards the parseInt path in context) and one
|
|
// vacation day.
|
|
const workRecord = {
|
|
id: 1,
|
|
user_id: 5,
|
|
shift_date: "2098-03-04",
|
|
leave_type: "work",
|
|
arrival_time: "2098-03-04T08:00:00",
|
|
departure_time: "2098-03-04T16:30:00",
|
|
break_start: "2098-03-04T12:00:00",
|
|
break_end: "2098-03-04T12:30:00",
|
|
notes: "Poznámka <b>",
|
|
project_logs: [
|
|
{ project_id: 1, project_name: "Alpha", hours: 8, minutes: 0 },
|
|
],
|
|
};
|
|
const leaveRecord = {
|
|
id: 2,
|
|
user_id: 5,
|
|
shift_date: "2098-03-05",
|
|
leave_type: "vacation",
|
|
leave_hours: 8,
|
|
};
|
|
const userData = {
|
|
name: "Jan Novák",
|
|
minutes: 480,
|
|
records: [workRecord, leaveRecord],
|
|
};
|
|
const printData = {
|
|
month: "2098-03",
|
|
month_name: "Březen 2098",
|
|
selected_user_name: null,
|
|
user_totals: { "5": userData },
|
|
};
|
|
|
|
describe("buildUserSectionHtml", () => {
|
|
it("produces a stable per-user section (full output pinned)", () => {
|
|
const html = buildUserSectionHtml(
|
|
"5",
|
|
asUserData(userData),
|
|
asPrintData(printData),
|
|
);
|
|
expect(html).toMatchSnapshot();
|
|
});
|
|
});
|
|
|
|
describe("buildPrintHtml", () => {
|
|
// NB: the document footer embeds `new Date()`, so assert stable fragments
|
|
// rather than snapshotting the whole (non-deterministic) output.
|
|
it("wraps the user sections in a stable document shell", () => {
|
|
const section = buildUserSectionHtml(
|
|
"5",
|
|
asUserData(userData),
|
|
asPrintData(printData),
|
|
);
|
|
const html = buildPrintHtml(
|
|
asPrintData(printData),
|
|
section,
|
|
"",
|
|
"",
|
|
"Firma s.r.o.",
|
|
);
|
|
expect(html).toContain("<title>Docházka - Březen 2098</title>");
|
|
expect(html).toContain("EVIDENCE DOCHÁZKY");
|
|
expect(html).toContain('<div class="period">Březen 2098</div>');
|
|
expect(html).toContain("Firma s.r.o.");
|
|
expect(html).toContain(section);
|
|
});
|
|
});
|