refactor: simplify finance view models

This commit is contained in:
2026-10-01 20:53:13 +03:00
parent ff67a768ad
commit 2844b22dba
7 changed files with 116 additions and 47 deletions
+1
View File
@@ -1,3 +1,4 @@
export { toInstrumentEventRows } from "./model/operation-rows";
export { section, type SectionState } from "./model/section";
export { useInstrumentParameters } from "./model/useInstrumentParameters";
export { InstrumentChart } from "./ui/InstrumentChart/InstrumentChart.view";
@@ -0,0 +1,44 @@
import { expect, it } from "vitest";
import type { BrokerageOperation } from "@/entities/broker-operation";
import { toInstrumentEventRows } from "./operation-rows";
const operation: BrokerageOperation = {
accruedInterest: null,
category: "trade",
commission: null,
description: "Покупка",
figi: null,
id: "first",
instrumentType: "stock",
instrumentUid: "uid",
logoName: null,
occurredAt: "2026-01-01T00:00:00Z",
payment: null,
price: null,
quantity: null,
ticker: null,
type: "OPERATION_TYPE_BUY",
};
it("fills only missing identity without changing rows or their order", () => {
const items = [operation, {
...operation,
id: "second",
logoName: "",
ticker: "own",
}];
const original = structuredClone(items);
expect(toInstrumentEventRows(items, {
logoName: "fallback.png",
ticker: "fallback",
})).toEqual([
{ ...operation, logoName: "fallback.png", ticker: "fallback" },
items[1],
]);
expect(items).toEqual(original);
expect(toInstrumentEventRows(items)).toEqual(items);
expect(toInstrumentEventRows()).toEqual([]);
});
@@ -0,0 +1,14 @@
import type { BrokerageOperation } from "@/entities/broker-operation";
function toInstrumentEventRows(
items: BrokerageOperation[] = [],
instrument?: Pick<BrokerageOperation, "logoName" | "ticker">,
): BrokerageOperation[] {
return items.map(item => ({
...item,
logoName: item.logoName ?? instrument?.logoName ?? null,
ticker: item.ticker ?? instrument?.ticker ?? null,
}));
}
export { toInstrumentEventRows };
@@ -203,6 +203,37 @@ describe("AccountsPage", () => {
});
});
it.each(["idle", "failed"] as const)(
"keeps partial empty membership distinct from no accounts (sync=%s)",
async (state) => {
server.use(
http.get("*/api/v1/brokerage-accounts", () =>
HttpResponse.json(resource([], {
availability: "partial",
sync: { errorCode: null, id: null, state },
})),
),
);
const client = createTestQueryClient();
renderPage(client);
await waitFor(() =>
expect(client.getQueryData(brokerageAccountsQueryKey)).toMatchObject({
data: [],
meta: { availability: "partial" },
}),
);
expect(screen.queryByText("Нет инвестиционных счетов"))
.not.toBeInTheDocument();
if (state === "failed")
// eslint-disable-next-line vitest/no-conditional-expect -- Each fixed state has a different observable recovery contract.
expect(await screen.findByRole("button", { name: "Повторить" }))
.toBeVisible();
else
// eslint-disable-next-line vitest/no-conditional-expect -- Each fixed state has a different observable recovery contract.
expect(screen.getByRole("status")).toBeVisible();
},
);
it("keeps unknown membership loading instead of declaring no accounts", async () => {
server.use(
http.get("*/api/v1/brokerage-accounts", () =>
@@ -15,31 +15,31 @@ function useAccountsPageViewModel() {
) {
if (!accounts.isError && accounts.meta?.sync.state !== "failed")
return { state: "loading" as const };
if (accounts.isError || accounts.meta?.sync.state === "failed")
return {
code: apiErrorCode(accounts.error),
retry: sync.retry,
state: "error" as const,
};
return {
code: apiErrorCode(accounts.error),
retry: sync.retry,
state: "error" as const,
};
}
if (!accounts.data?.length)
return {
lastSuccessfulAt: accounts.meta?.lastSuccessfulAt,
refreshError: accounts.isError || sync.transportError || sync.sourceError,
refreshing: accounts.isFetching || sync.refreshing,
retry: sync.retry,
sourceError: sync.sourceError,
state: "empty" as const,
};
return {
accounts: brokerageAccountCards(accounts.data, accounts.meta?.dependencies),
const refresh = {
lastSuccessfulAt: accounts.meta?.lastSuccessfulAt,
refreshError: accounts.isError || sync.transportError || sync.sourceError,
refreshing: accounts.isFetching || sync.refreshing,
retry: sync.retry,
sourceError: sync.sourceError,
};
if (accounts.data.length === 0)
return {
...refresh,
state: "empty" as const,
};
return {
accounts: brokerageAccountCards(accounts.data, accounts.meta?.dependencies),
...refresh,
state: "loaded" as const,
};
}
+6 -15
View File
@@ -9,7 +9,11 @@ import {
useBondQuote,
} from "@/entities/bond";
import { useBrokerageOperationHistory } from "@/entities/broker-operation";
import { section, useInstrumentParameters } from "@/features/instrument-details";
import {
section,
toInstrumentEventRows,
useInstrumentParameters,
} from "@/features/instrument-details";
import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode } from "@/shared/api";
import { compareDateDesc, instrumentLogoUrl } from "@/shared/lib";
@@ -62,20 +66,7 @@ function useBondPageVm() {
code: apiErrorCode(bond.error),
coupons: coupons.data,
couponsState: section(coupons, sync.retry),
eventRows:
operations.data?.items.map(item => ({
...item,
logoName:
item.logoName
?? bond.data?.logoName
?? null,
ticker:
item.ticker
?? bond.data?.ticker
?? null,
})) ?? [],
eventRows: toInstrumentEventRows(operations.data?.items, bond.data),
filter,
hasPrevious: history.hasPrevious,
logoSrc: instrumentLogoUrl(bond.data?.logoName),
+2 -14
View File
@@ -11,6 +11,7 @@ import {
} from "@/entities/stock";
import {
section,
toInstrumentEventRows,
useInstrumentParameters,
} from "@/features/instrument-details";
import { useScreenSync } from "@/features/tbank-connection";
@@ -65,20 +66,7 @@ function useStockPageVm() {
companyState: section(company, sync.retry),
dividends: dividends.data,
dividendsState: section(dividends, sync.retry),
eventRows:
operations.data?.items.map(item => ({
...item,
logoName:
item.logoName
?? stock.data?.logoName
?? null,
ticker:
item.ticker
?? stock.data?.ticker
?? null,
})) ?? [],
eventRows: toInstrumentEventRows(operations.data?.items, stock.data),
filter,
hasPrevious: history.hasPrevious,
logoSrc: instrumentLogoUrl(stock.data?.logoName),