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
+3 -33
View File
@@ -352,11 +352,7 @@ 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 %");
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();
expect(
screen.queryByRole("heading", { name: "Моя позиция" }),
).not.toBeInTheDocument();
@@ -364,38 +360,12 @@ describe("bond page", () => {
screen.getByRole("heading", { name: "Моя история событий" }),
).toBeVisible();
});
it("preserves cached quote on 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("preserves cached quote without manual refresh", async () => {
const controls = setup({ path: `/bonds/${uid}?period=year&events=trades` });
await screen.findByText("98,125 %");
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("98,125 %")).toBeVisible();
expect(scopes).toEqual([
{
accountId: "all",
candlePeriod: "year",
categories: ["trade"],
instrumentUid: uid,
limit: 20,
screen: "bond",
},
]);
expect(screen.queryByRole("button", { name: "Обновить" })).not.toBeInTheDocument();
});
it("offers search for an ambiguous or missing instrument without loading personal data", async () => {
const { requests } = setup({ ambiguousTicker: true, path: "/bonds/SAME" });
-1
View File
@@ -15,7 +15,6 @@ const BondPage: FC = () => {
code={vm.code}
kind="bond"
loading={vm.bondState.loading}
retry={vm.bondState.retry}
/>
);
@@ -1,7 +1,6 @@
import type { FC } from "react";
import {
Alert,
Badge,
Group,
Skeleton,
@@ -137,8 +136,6 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
<InstrumentHeader
logoSrc={vm.logoSrc}
name={bond.name}
onRefresh={vm.refresh}
refreshing={vm.refreshing}
subtitle={subtitle}
>
@@ -156,20 +153,8 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
)}
</Group>
{!!(vm.refreshError || vm.bondState.error || vm.quoteState.error) && (
<Alert aria-live="polite" color="orange" mt="md" role="status">
{t(vm.quote ? "accounts.refreshError" : "stock.sectionError")}
</Alert>
)}
{quoteContent}
{!!(vm.quoteState.refreshing || vm.bondState.refreshing) && (
<Text c="dimmed" role="status" size="xs">
{t("accounts.refreshing")}
</Text>
)}
<InstrumentTerms title={t("bond.terms")}>
<InstrumentMetrics>
+8 -14
View File
@@ -42,7 +42,7 @@ function useBondPageVm() {
const { operations } = history;
const sync = useScreenSync(
useScreenSync(
{
accountId: "all",
candlePeriod: period,
@@ -56,29 +56,26 @@ function useBondPageVm() {
return {
bond: bond.data,
bondState: section(bond, uid ? sync.retry : () => bond.refetch()),
bondState: section(bond),
candles: candles.data,
candlesState: section(candles, sync.retry),
candlesState: section(candles),
canonicalTo:
bond.data?.slug && bond.data.slug !== bondId
? `/bonds/${encodeURIComponent(bond.data.slug)}${search}`
: null,
code: apiErrorCode(bond.error),
coupons: coupons.data,
couponsState: section(coupons, sync.retry),
couponsState: section(coupons),
eventRows: toInstrumentEventRows(operations.data?.items, bond.data),
filter,
hasPrevious: history.hasPrevious,
logoSrc: instrumentLogoUrl(bond.data?.logoName),
metrics: metrics.data,
metricsState: section(metrics, sync.retry),
metricsState: section(metrics),
onNext: history.onNext,
onPrevious: history.onPrevious,
operations: operations.data,
operationsState: {
...section(operations, sync.retry),
retry: () => history.retry(sync.retry),
},
operationsState: section(operations),
pastCoupons:
coupons.data?.coupons
.filter(event => event.timing !== "upcoming")
@@ -87,12 +84,9 @@ function useBondPageVm() {
) ?? [],
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,
upcoming: