diff --git a/src/features/instrument-details/index.ts b/src/features/instrument-details/index.ts index 988741b..1ee5023 100644 --- a/src/features/instrument-details/index.ts +++ b/src/features/instrument-details/index.ts @@ -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"; diff --git a/src/features/instrument-details/model/operation-rows.test.ts b/src/features/instrument-details/model/operation-rows.test.ts new file mode 100644 index 0000000..d3bbe27 --- /dev/null +++ b/src/features/instrument-details/model/operation-rows.test.ts @@ -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([]); +}); diff --git a/src/features/instrument-details/model/operation-rows.ts b/src/features/instrument-details/model/operation-rows.ts new file mode 100644 index 0000000..905a494 --- /dev/null +++ b/src/features/instrument-details/model/operation-rows.ts @@ -0,0 +1,14 @@ +import type { BrokerageOperation } from "@/entities/broker-operation"; + +function toInstrumentEventRows( + items: BrokerageOperation[] = [], + instrument?: Pick, +): BrokerageOperation[] { + return items.map(item => ({ + ...item, + logoName: item.logoName ?? instrument?.logoName ?? null, + ticker: item.ticker ?? instrument?.ticker ?? null, + })); +} + +export { toInstrumentEventRows }; diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx b/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx index 2ce0931..6e69741 100644 --- a/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx +++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx @@ -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", () => diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts b/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts index 5013448..e7cd89e 100644 --- a/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts +++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts @@ -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, }; } diff --git a/src/pages/bond/ui/BondPage/BondPage.vm.ts b/src/pages/bond/ui/BondPage/BondPage.vm.ts index ffa6c18..6a0af5f 100644 --- a/src/pages/bond/ui/BondPage/BondPage.vm.ts +++ b/src/pages/bond/ui/BondPage/BondPage.vm.ts @@ -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), diff --git a/src/pages/stock/ui/StockPage/StockPage.vm.ts b/src/pages/stock/ui/StockPage/StockPage.vm.ts index 98c7f19..77c418f 100644 --- a/src/pages/stock/ui/StockPage/StockPage.vm.ts +++ b/src/pages/stock/ui/StockPage/StockPage.vm.ts @@ -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),