feat(pwa): экран штрафов с фотографиями нарушений

Список с вкладками «Неоплаченные / Все» и суммой долга в шапке; в карточке —
фотография с камеры первой, до реквизитов: по ней водитель узнаёт свою машину и
свою поездку. Дальше сумма, скидка с датой (то, ради чего заходят вовремя),
скорость и разрешённый предел, постановление, камера, ссылка на PDF.

Строка «Долг по штрафам» на главном экране теперь ведёт сюда, а не сразу к
оплате: платить, не поняв за что, человек не станет. Аренда по-прежнему ведёт к
оплате — за тем её и открывают.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 17:18:58 +10:00
co-authored by Claude Opus 5
parent 9187b5766e
commit f4a9da25cf
7 changed files with 375 additions and 5 deletions
@@ -0,0 +1,100 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const FINE = {
id: 12, number: "18810525260813035846", date: "2026-08-12",
article: "12.09.2 - Превышение скорости", address: "Владивосток, Некрасовская, 90",
car_number: "А947УТ125", amount: 562.5, discount_amount: 281.25,
discount_until: "2026-09-01", paid: false, cancelled: false, has_photo: true,
};
vi.mock("@/api/driver", () => ({
getFines: vi.fn(async (status: string) => ({
items: status === "open" ? [FINE] : [FINE, { ...FINE, id: 13, paid: true, amount: 500 }],
open_count: 1, open_sum: 562.5,
})),
getFine: vi.fn(async () => ({
...FINE, post_date: "2026-08-13", speed: 63, speed_limit: 40, speed_excess: 23,
camera: "АвтоУраган-ВСМ2-М",
photos: ["https://s3.pptaxi.ru/pp-fines/1/offense_photo-0.jpg"],
document: "https://s3.pptaxi.ru/pp-fines/1/decision.pdf",
})),
}));
import { FinesPage } from "./FinesPage";
import { FineDetailPage } from "./FineDetailPage";
import * as driver from "@/api/driver";
function wrap(path = "/fines") {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path="/fines" element={<FinesPage />} />
<Route path="/fines/:fineId" element={<FineDetailPage />} />
<Route path="/topup" element={<p>экран пополнения</p>} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
);
}
describe("список штрафов", () => {
beforeEach(() => vi.clearAllMocks());
it("открывается на неоплаченных и показывает сумму долга", async () => {
wrap();
expect(await screen.findByText(/Не оплачено: 1 на 562,50 ₽/)).toBeInTheDocument();
expect(vi.mocked(driver.getFines).mock.calls[0][0]).toBe("open");
});
it("вкладка «Все» показывает и оплаченные", async () => {
wrap();
await screen.findByText(/Не оплачено/);
await userEvent.click(screen.getByRole("button", { name: "Все" }));
expect(await screen.findByText("оплачен")).toBeInTheDocument();
});
it("штраф открывается в карточку", async () => {
wrap();
await userEvent.click(await screen.findByRole("button", { name: /Превышение скорости/ }));
expect(await screen.findByRole("heading", { name: /Превышение скорости/ })).toBeInTheDocument();
});
});
describe("карточка штрафа", () => {
beforeEach(() => vi.clearAllMocks());
it("фотография нарушения стоит первой и открывается в полный размер", async () => {
wrap("/fines/12");
const img = await screen.findByAltText("Фото нарушения 1");
expect(img).toHaveAttribute("src", expect.stringContaining("offense_photo"));
expect(img.closest("a")).toHaveAttribute("target", "_blank");
});
it("скорость и скидка объясняют штраф без звонка в парк", async () => {
wrap("/fines/12");
expect(await screen.findByText(/63 км\/ч при разрешённых 40/)).toBeInTheDocument();
expect(screen.getByText(/281,25 ₽ со скидкой/)).toBeInTheDocument();
expect(screen.getByText("18810525260813035846")).toBeInTheDocument();
});
it("неоплаченный штраф ведёт к оплате штрафов", async () => {
wrap("/fines/12");
await userEvent.click(await screen.findByRole("button", { name: /Оплатить штрафы/ }));
expect(await screen.findByText("экран пополнения")).toBeInTheDocument();
});
it("нет фото — экран объясняет это, а не показывает пустоту", async () => {
vi.mocked(driver.getFine).mockResolvedValueOnce({
...FINE, post_date: null, speed: null, speed_limit: null, speed_excess: null,
camera: "", photos: [], document: null,
} as never);
wrap("/fines/12");
expect(await screen.findByText(/Фотографии по этому постановлению нет/)).toBeInTheDocument();
});
});