feat: add quiet reading with rare confirmations

Part of the quiet-user-feedback change; docs and specs land in their own repositories.
This commit is contained in:
2026-10-06 07:01:58 +03:00
parent c096fc70ef
commit 5b30bb802a
77 changed files with 1352 additions and 1259 deletions
+11 -80
View File
@@ -396,37 +396,15 @@ describe("stock page", () => {
screen.queryByRole("heading", { name: "Моя позиция" }),
).not.toBeInTheDocument();
expect(screen.getByRole("heading", { name: "График цены" })).toBeVisible();
const message = await screen.findByText(/Не удалось загрузить этот раздел/);
expect(
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
message.closest("[role=\"status\"]"),
).toHaveAttribute("aria-live", "polite");
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
});
it("recovers an initially missing company section without reloading successful sections", async () => {
it("keeps successful sections visible when company data is initially missing", async () => {
const { requests } = setup({ failCompany: true });
const retry = await screen.findByRole("button", { name: "Повторить" });
await screen.findByText("Зачисление дивидендов");
const modes: (null | string)[] = [];
server.use(
http.get("*/api/v1/stocks/:uid/company", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
return HttpResponse.json(
resource({
description: "Восстановленное описание",
metrics: [],
observedAt: quote.observedAt,
unavailable: [],
}),
);
}),
);
const previousRequests = requests.length;
fireEvent.click(retry);
expect(await screen.findByText("Восстановленное описание")).toBeVisible();
expect(screen.getByText("Зачисление дивидендов")).toBeVisible();
expect(requests).toHaveLength(previousRequests);
expect(modes).toEqual(["observe"]);
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
});
it("sends instrument-filtered continuation, restores it on returning to the filter and changes chart period", async () => {
const { requests } = setup();
@@ -469,75 +447,28 @@ describe("stock page", () => {
);
});
it.each([
[
"INVALID_OPERATIONS_CURSOR",
null,
null,
],
["TBANK_UNAVAILABLE", "next", "observe"],
"INVALID_OPERATIONS_CURSOR",
"TBANK_UNAVAILABLE",
])(
"recovers history after %s with fresh data and the correct cursor",
async (code, cursor, sync) => {
"keeps history quiet after %s without technical retry",
async (code) => {
const controls = setup({ cursorError: code });
await screen.findByText("Зачисление дивидендов");
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
const retry = await screen.findByRole("button", { name: "Повторить" });
controls.recoverOperations();
const previousRequests = controls.requests.length;
fireEvent.click(retry);
expect(await screen.findByText("+201 ₽")).toBeVisible();
const recovered = controls.requests.slice(previousRequests);
expect(recovered).toHaveLength(1);
expect(recovered[0]?.pathname).toBe(
"/api/v1/brokerage-accounts/all/operations",
);
expect(recovered[0]?.searchParams.get("instrumentUid")).toBe(uid);
expect(recovered[0]?.searchParams.get("limit")).toBe("20");
expect(recovered[0]?.searchParams.get("cursor")).toBe(cursor);
expect(recovered[0]?.searchParams.get("sync")).toBe(sync);
if (cursor)
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
expect(screen.getByRole("button", { name: "Назад" })).toBeEnabled();
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
else expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled();
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Моя история событий" })).toBeVisible();
},
);
it("keeps the last quote on a refresh failure", async () => {
const scopes: unknown[] = [];
server.use(
http.post("*/api/v1/tbank-sync", async ({ request }) => {
scopes.push(await request.json());
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
);
}),
);
it("keeps the last quote without manual refresh", async () => {
const controls = setup({
path: `/stocks/${uid}?period=year&events=trades`,
});
await screen.findByText("300 ₽");
controls.failQuote();
fireEvent.click(screen.getByRole("button", { name: "Обновить" }));
const message = await screen.findByText(/последние полученные/);
expect(
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
message.closest("[role=\"status\"]"),
).toHaveAttribute("aria-live", "polite");
expect(screen.getByText("300 ₽")).toBeVisible();
expect(scopes).toEqual([
{
accountId: "all",
candlePeriod: "year",
categories: ["trade"],
instrumentUid: uid,
limit: 20,
screen: "stock",
},
]);
expect(screen.queryByRole("button", { name: "Обновить" })).not.toBeInTheDocument();
});
it("offers recovery for an unavailable direct link", async () => {
setup({ failStock: true });
@@ -15,7 +15,6 @@ const StockPage: FC = () => {
code={vm.code}
kind="stock"
loading={vm.stockState.loading}
retry={vm.stockState.retry}
/>
);
@@ -1,9 +1,6 @@
import type { FC } from "react";
import {
Alert,
Text,
} from "@mantine/core";
import { Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import {
@@ -42,8 +39,6 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
<InstrumentHeader
logoSrc={vm.logoSrc}
name={stock.name}
onRefresh={vm.refresh}
refreshing={vm.refreshing}
subtitle={subtitle}
>
@@ -53,12 +48,6 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
</Text>
)}
{!!(vm.refreshError || vm.stockState.error || vm.quoteState.error) && (
<Alert aria-live="polite" color="orange" mt="md" role="status">
{t(vm.quote ? "accounts.refreshError" : "stock.sectionError")}
</Alert>
)}
<StockQuote quote={vm.quote} quoteState={vm.quoteState} />
<StockCompany
+8 -14
View File
@@ -42,7 +42,7 @@ function useStockPageVm() {
const { operations } = history;
const sync = useScreenSync(
useScreenSync(
{
accountId: "all",
candlePeriod: period,
@@ -56,16 +56,16 @@ function useStockPageVm() {
return {
candles: candles.data,
candlesState: section(candles, sync.retry),
candlesState: section(candles),
canonicalTo:
stock.data?.slug && stock.data.slug !== stockId
? `/stocks/${encodeURIComponent(stock.data.slug)}${search}`
: null,
code: apiErrorCode(stock.error),
company: company.data,
companyState: section(company, sync.retry),
companyState: section(company),
dividends: dividends.data,
dividendsState: section(dividends, sync.retry),
dividendsState: section(dividends),
eventRows: toInstrumentEventRows(operations.data?.items, stock.data),
filter,
hasPrevious: history.hasPrevious,
@@ -73,10 +73,7 @@ function useStockPageVm() {
onNext: history.onNext,
onPrevious: history.onPrevious,
operations: operations.data,
operationsState: {
...section(operations, sync.retry),
retry: () => history.retry(sync.retry),
},
operationsState: section(operations),
pastDividends:
dividends.data?.dividends
.filter(event => event.timing !== "upcoming")
@@ -85,16 +82,13 @@ function useStockPageVm() {
) ?? [],
period,
position: position.data,
positionState: section(position, sync.retry),
positionState: section(position),
quote: quote.data,
quoteState: section(quote, sync.retry),
refresh: sync.refresh,
refreshError: sync.transportError || sync.sourceError,
refreshing: sync.refreshing,
quoteState: section(quote),
setFilter,
setPeriod,
stock: stock.data,
stockState: section(stock, uid ? sync.retry : () => stock.refetch()),
stockState: section(stock),
upcoming:
dividends.data?.dividends
.filter(event => event.timing === "upcoming")
@@ -1,12 +1,6 @@
import type { FC } from "react";
import {
Alert,
Button,
Group,
Stack,
Text,
} from "@mantine/core";
import { Alert, Group, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import {
@@ -80,17 +74,7 @@ const StockCompany: FC<StockCompanyProps> = (props) => {
<Stack gap="lg">
{props.company.unavailable.length > 0 && (
<Alert aria-live="polite" color="orange" role="status">
<Group justify="space-between">
<Text size="sm">{t("stock.partialCompany")}</Text>
<Button
onClick={props.companyState.retry}
size="xs"
variant="default"
>
{t("accounts.retry")}
</Button>
</Group>
<Text size="sm">{t("stock.partialCompany")}</Text>
</Alert>
)}
@@ -82,12 +82,6 @@ const StockQuote: FC<StockQuoteProps> = (props) => {
{t("stock.partialQuote")}
</Text>
)}
{!!props.quoteState.refreshing && (
<Text c="dimmed" role="status" size="xs">
{t("accounts.refreshing")}
</Text>
)}
</div>
);
}