refactor: simplify frontend and clarify module ownership #45

Merged
ksv741 merged 16 commits from codex/frontend-calendar-form-cleanup into develop 2026-10-01 21:07:51 +03:00
5 changed files with 71 additions and 126 deletions
Showing only changes of commit 320ea5922a - Show all commits
@@ -1,32 +0,0 @@
import { MantineProvider } from "@mantine/core";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import {
TbankConnectionError,
TbankConnectionLoading,
} from "./TbankConnectionSection.view";
describe("TbankConnectionSection state views", () => {
it("renders loading and retries a status error", () => {
const retry = vi.fn();
const { rerender } = render(
<MantineProvider>
<TbankConnectionLoading />
</MantineProvider>,
);
expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute(
"aria-busy",
"true",
);
rerender(
<MantineProvider>
<TbankConnectionError retry={retry} />
</MantineProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
expect(retry).toHaveBeenCalledOnce();
});
});
@@ -44,6 +44,71 @@ function summary(currency: string, coupon: string, dividend: string) {
};
}
describe("income membership validation", () => {
const entry: IncomeAnalytics["entries"][number] = {
accruedInterest: null,
category: "income",
commission: null,
description: "Купон",
figi: "FIGI",
id: "coupon-1",
instrumentKey: "income-group",
instrumentType: "bond",
instrumentUid: null,
logoName: null,
occurredAt: "2026-08-01T12:00:00Z",
payment: money("RUB", "10"),
price: null,
quantity: null,
ticker: "BOND",
type: "OPERATION_TYPE_COUPON",
};
const instrument: IncomeAnalytics["instruments"][number] = {
...summary("RUB", "10", "0"),
displayName: "BOND",
figi: "FIGI",
instrumentType: "bond",
instrumentUid: null,
key: entry.instrumentKey,
receiptCount: 1,
ticker: "BOND",
};
const valid: IncomeAnalytics = {
...analytics,
entries: [entry],
instruments: [instrument],
totalsByCurrency: [summary("RUB", "10", "0")],
};
it.each([
["missing key", undefined],
["empty key", ""],
["whitespace key", " "],
["non-string key", 12],
["unknown key", "not-in-breakdown"],
])("rejects an entry with %s", (_name, instrumentKey) => {
// Deliberately malformed API data exercises the runtime validation boundary.
const response = {
...valid,
entries: [{ ...entry, instrumentKey }],
} as unknown as IncomeAnalytics;
expect(toIncomeDisplayModel(response)).toBeNull();
});
it.each([
["duplicate instrument keys", { instruments: [instrument, instrument] }],
["currency mismatch", { entries: [{ ...entry, payment: money("USD", "10") }] }],
["receipt count mismatch", { instruments: [{ ...instrument, receiptCount: 2 }] }],
["missing entries", { entries: [] }],
["empty instrument key", { instruments: [{ ...instrument, key: "" }] }],
])("rejects %s", (_name, invalid) => {
expect(toIncomeDisplayModel({ ...valid, ...invalid })).toBeNull();
});
});
describe("income comparison display", () => {
it.each([
["25", "100", "+25 %"],
@@ -324,17 +324,11 @@ describe("AccountIncomePage", () => {
expect(within(panel).getAllByRole("listitem")).toHaveLength(2);
});
it.each([
["missing key", undefined],
["empty key", ""],
["whitespace key", " "],
["non-string key", 12],
["unknown key", "not-in-breakdown"],
])("rejects %s and recovers through retry", async (_name, instrumentKey) => {
it("rejects inconsistent analytics and recovers through retry", async () => {
let response = {
...analytics,
entries: [
{ ...analytics.entries[0], instrumentKey },
{ ...analytics.entries[0], instrumentKey: "not-in-breakdown" },
analytics.entries[1],
],
};
@@ -356,51 +350,6 @@ describe("AccountIncomePage", () => {
).toBeInTheDocument();
});
it.each([
[
"duplicate instrument keys",
{
...analytics,
instruments: [...analytics.instruments, analytics.instruments[0]],
},
],
[
"currency mismatch",
{
...analytics,
entries: [
{ ...analytics.entries[0], payment: money("USD", "10") },
analytics.entries[1],
],
},
],
[
"receipt count mismatch",
{
...analytics,
instruments: [{ ...analytics.instruments[0], receiptCount: 3 }],
},
],
["missing entries", { ...analytics, entries: [] }],
[
"empty instrument key",
{ ...analytics, instruments: [{ ...analytics.instruments[0], key: "" }] },
],
])("shows a local error for %s", async (_name, response) => {
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(resource(response)),
),
);
renderPage();
expect(
await screen.findByRole("button", { name: "Повторить" }),
).toBeInTheDocument();
expect(
screen.queryByRole("region", { name: "Выплаты" }),
).not.toBeInTheDocument();
});
it("accepts an empty analytics response", async () => {
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
@@ -870,8 +819,6 @@ describe("income period and snapshot boundary", () => {
expect(
screen.getByRole("heading", { name: "Выплаты" }),
).toBeInTheDocument();
expect(screen.queryByRole("heading", { level: 3 })).not.toBeInTheDocument();
expect(screen.queryByRole("heading", { level: 4 })).not.toBeInTheDocument();
expect(screen.queryByText("RUB")).not.toBeInTheDocument();
expect(screen.queryByText("USD")).not.toBeInTheDocument();
const cards = screen.getAllByRole("listitem");
@@ -881,7 +828,6 @@ describe("income period and snapshot boundary", () => {
expect(cards[2]).toHaveTextContent("−3 ₽");
expect(screen.queryByText(/Итого:|Выплат:/)).not.toBeInTheDocument();
expect(screen.queryByRole("button")).not.toBeInTheDocument();
expect(screen.getAllByText("Купон")).toHaveLength(6);
});
});
@@ -302,7 +302,9 @@ describe("AccountPage states", () => {
expect(
screen.queryByRole("button", { name: "Повторить" }),
).not.toBeInTheDocument();
expect(screen.getByRole("link")).toHaveAttribute(
expect(screen.getByRole("link", {
name: code === "NOT_FOUND" ? "К счетам" : "Открыть настройки T-Bank",
})).toHaveAttribute(
"href",
code === "NOT_FOUND" ? "/accounts" : "/settings",
);
@@ -427,34 +429,13 @@ describe("AccountPage states", () => {
},
);
it("renders loading and empty states", () => {
renderPage(<AccountPageLoading />);
expect(screen.getByRole("status")).toBeInTheDocument();
it("renders the empty state", () => {
renderPage(<AccountPageEmpty {...emptyProps()} />);
expect(
screen.getByRole("heading", { name: "Портфель пока пуст" }),
).toBeInTheDocument();
});
it("renders allowed error actions", () => {
const retry = vi.fn();
renderPage(<AccountPageError code="NOT_FOUND" retry={retry} />);
expect(screen.getByRole("link", { name: "К счетам" })).toHaveAttribute(
"href",
"/accounts",
);
renderPage(<AccountPageError code="TBANK_NOT_CONNECTED" retry={retry} />);
expect(
screen.getByRole("link", { name: "Открыть настройки T-Bank" }),
).toHaveAttribute("href", "/settings");
renderPage(<AccountPageError code="TBANK_UNAVAILABLE" retry={retry} />);
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
expect(retry).toHaveBeenCalledOnce();
});
it("renders a stale portfolio in upstream order with nullable values hidden", () => {
renderPage(<AccountPageLoaded {...loadedProps()} />);
-15
View File
@@ -1,7 +1,6 @@
import { composeStories } from "@storybook/react-vite";
// eslint-disable-next-line testing-library/no-manual-cleanup -- Vitest globals are disabled, so RTL cannot register its automatic afterEach cleanup.
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { fn } from "storybook/test";
import { afterEach, describe, expect, it, vi } from "vitest";
import preview from "../../.storybook/preview";
@@ -56,18 +55,4 @@ describe("Storybook isolated product examples", () => {
for (const spy of spies) expect(spy).not.toHaveBeenCalled();
});
it("keeps synthetic totals visible while the refresh example calls a local retry", () => {
const { RefreshError } = composeStories(RefreshStories, preview);
const retry = fn();
const view = render(<RefreshError retry={retry} />);
expect(screen.getByText(/125\s*000,50/)).toBeVisible();
fireEvent.click(screen.getByRole("button"));
expect(retry).toHaveBeenCalledOnce();
view.rerender(
<RefreshError refreshError={false} refreshing retry={retry} />,
);
expect(screen.getByText(/125\s*000,50/)).toBeVisible();
expect(screen.getByRole("status")).toBeVisible();
});
});