diff --git a/src/features/instrument-details/model/section.test.ts b/src/features/instrument-details/model/section.test.ts index c4642c2..eb44779 100644 --- a/src/features/instrument-details/model/section.test.ts +++ b/src/features/instrument-details/model/section.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { resource } from "@/test/resource"; @@ -76,4 +76,29 @@ describe("instrument source and transport presentation", () => { loading: false, }); }); + + it("carries the retry of an exhausted read and only of that read", () => { + const refetch = vi.fn(() => Promise.resolve()); + + const state = section({ + data: { rows: [] }, + error: { code: "TBANK_UNAVAILABLE", status: 503 }, + isError: true, + isFetching: false, + refetch, + }); + + expect(state.readRetry?.exhausted).toBe(true); + state.readRetry?.onRetry(); + expect(refetch).toHaveBeenCalledOnce(); + }); + + it("offers no retry for access loss or a healthy read", () => { + const base = { data: undefined, isFetching: false, refetch: vi.fn() }; + + expect(section({ ...base, error: { code: "NOT_FOUND" }, isError: true }) + .readRetry?.exhausted).toBe(false); + expect(section({ ...base, error: null, isError: false }) + .readRetry?.exhausted).toBe(false); + }); }); diff --git a/src/features/instrument-details/model/section.ts b/src/features/instrument-details/model/section.ts index a18b264..7c62f68 100644 --- a/src/features/instrument-details/model/section.ts +++ b/src/features/instrument-details/model/section.ts @@ -1,10 +1,11 @@ -import type { InvestmentResourceMeta } from "@/shared/api"; +import { type InvestmentResourceMeta, readRetry } from "@/shared/api"; type SectionState = { error: boolean; hasData: boolean; loading: boolean; partial?: boolean; + readRetry?: ReturnType; }; function hasSectionData(data: unknown) { @@ -13,8 +14,11 @@ function hasSectionData(data: unknown) { function section(query: { data: unknown; + error?: unknown; isError: boolean; + isFetching?: boolean; meta?: InvestmentResourceMeta; + refetch?: () => Promise; }): SectionState { const isError = query.isError || query.meta?.lastAttempt.state === "failed"; const hasData = hasSectionData(query.data); @@ -24,6 +28,17 @@ function section(query: { hasData, loading: !hasData && !isError, partial: query.meta?.completeness === "incomplete", + // Each section retries only its own GET, so sections recover independently. + readRetry: query.refetch + ? readRetry([ + { + error: query.error, + isError: query.isError, + isFetching: query.isFetching ?? false, + refetch: query.refetch, + }, + ]) + : undefined, }; } diff --git a/src/features/instrument-details/ui/InstrumentSection.view.tsx b/src/features/instrument-details/ui/InstrumentSection.view.tsx index 5c37ba1..f9c32c6 100644 --- a/src/features/instrument-details/ui/InstrumentSection.view.tsx +++ b/src/features/instrument-details/ui/InstrumentSection.view.tsx @@ -12,6 +12,8 @@ import { import { LoadingSkeleton } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; +import { ReadRetry } from "@/shared/ui"; + import type { SectionState } from "../model/section"; import classes from "./InstrumentDetails.module.css"; @@ -81,6 +83,8 @@ const InstrumentSection: FC = (props) => { )} + {!!props.state.readRetry && } + {sectionContent} ); diff --git a/src/pages/bond/ui/BondPage/BondPage.test.tsx b/src/pages/bond/ui/BondPage/BondPage.test.tsx index 3c5fa52..caf8345 100644 --- a/src/pages/bond/ui/BondPage/BondPage.test.tsx +++ b/src/pages/bond/ui/BondPage/BondPage.test.tsx @@ -347,7 +347,6 @@ describe("bond page", () => { it("keeps market content and sold-position history when coupons fail", async () => { setup({ failCoupons: true, noPosition: true }); await screen.findByText("98,125 %"); - expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument(); expect( screen.queryByRole("heading", { name: "Моя позиция" }), ).not.toBeInTheDocument(); @@ -355,6 +354,22 @@ describe("bond page", () => { screen.getByRole("heading", { name: "Моя история событий" }), ).toBeVisible(); }); + it( + "offers a local retry for the failed coupons section only", + { timeout: 10_000 }, + async () => { + setup({ failCoupons: true }); + await screen.findByText("98,125 %"); + // Automatic read retries wait 1s + 2s before the failure is final. + await screen.findByRole( + "button", + { name: "Повторить" }, + { timeout: 6000 }, + ); + expect(screen.getAllByRole("button", { name: "Повторить" })).toHaveLength(1); + expect(screen.getByText("98,125 %")).toBeVisible(); + }, + ); it("preserves cached quote without manual refresh", async () => { const controls = setup({ path: `/bonds/${uid}?period=year&events=trades` }); await screen.findByText("98,125 %"); diff --git a/src/pages/stock/ui/StockPage/StockPage.test.tsx b/src/pages/stock/ui/StockPage/StockPage.test.tsx index 129c1db..e66a4a3 100644 --- a/src/pages/stock/ui/StockPage/StockPage.test.tsx +++ b/src/pages/stock/ui/StockPage/StockPage.test.tsx @@ -81,6 +81,7 @@ function setup( candles?: NonNullable["candles"]; cursorError?: string; failCompany?: boolean; + failCompanyAfter?: { value: boolean }; failStock?: boolean; metrics?: NonNullable["metrics"]; noPosition?: boolean; @@ -133,7 +134,7 @@ function setup( ), ); if (params.section === "company") - return options.failCompany + return (options.failCompanyAfter?.value ?? options.failCompany) ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) : HttpResponse.json( @@ -396,7 +397,6 @@ describe("stock page", () => { screen.queryByRole("heading", { name: "Моя позиция" }), ).not.toBeInTheDocument(); expect(screen.getByRole("heading", { name: "График цены" })).toBeVisible(); - expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument(); }); it("keeps successful sections visible when company data is initially missing", async () => { const { requests } = setup({ failCompany: true }); @@ -404,8 +404,39 @@ describe("stock page", () => { const previousRequests = requests.length; expect(screen.getByText("Зачисление дивидендов")).toBeVisible(); expect(requests).toHaveLength(previousRequests); - expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument(); }); + it( + "retries only the failed company section and recovers it in place", + { timeout: 10_000 }, + async () => { + const failing = { value: true }; + const { requests } = setup({ failCompanyAfter: failing }); + await screen.findByText("Зачисление дивидендов"); + + // Automatic read retries wait 1s + 2s before the failure is final. + const retry = await screen.findByRole( + "button", + { name: "Повторить" }, + { timeout: 6000 }, + ); + + expect(screen.getByRole("heading", { name: "График цены" })).toBeVisible(); + expect(screen.getAllByRole("button", { name: "Повторить" })).toHaveLength(1); + const before = requests.length; + const companyBefore = requests.filter(url => url.pathname.endsWith("/company")).length; + + failing.value = false; + fireEvent.click(retry); + + await waitFor(() => + expect(screen.queryByRole("button", { name: "Повторить" })) + .not.toBeInTheDocument()); + expect(requests.filter(url => url.pathname.endsWith("/company"))) + .toHaveLength(companyBefore + 1); + expect(requests.length - before).toBe(1); + expect(screen.getByText("Описание компании")).toBeVisible(); + }, + ); it("sends instrument-filtered continuation, restores it on returning to the filter and changes chart period", async () => { const { requests } = setup(); await screen.findByText("Зачисление дивидендов");