v2: бейдж «Мои отчёты» показывает квоту «использовано / доступно»
All checks were successful
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 59s
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 12s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped

Владелец выдал покупателю 50 оценок и не смог найти, где виден остаток.
Ответ был: нигде. Бэкенд отдаёт limit/used/remaining/unlimited целиком, а
фронт рендерил голое used с фолбэком на длину истории — «0» у свежего
аккаунта читался как «отчётов нет», а был месячным счётчиком (#3320 п.4:
одна цифра с двумя смыслами).

Теперь: «3 / 50» + title с расшифровкой. Фолбэк на history.length убран —
lifetime-число с потолком 50 строк смыслово другое; нет квоты (unlimited
или не загрузилась) — нет бейджа: лучше ничего, чем не то.

Тест по значению, с открытием меню; фальсификация: возврат голого used
красит «Unable to find 3 / 50». 204 теста (v2 + mera-public) зелёные.
This commit is contained in:
bot-backend 2026-09-02 12:25:14 +05:00
parent 4fe0538ea0
commit 012d98205f
3 changed files with 75 additions and 20 deletions

View file

@ -677,7 +677,14 @@ export default function TradeInV2Page() {
// TopNav «Мои отчёты» badge: per-user estimate count. quota.used is the
// authoritative monthly counter; fall back to the loaded history length, then
// to undefined (TopNav renders 0) for pre-load states.
const reportsCount = quota.data?.used ?? history.data?.length;
// Счётчик у «Мои отчёты» — МЕСЯЧНАЯ квота, и вместе с use идёт limit:
// «использовано / доступно». Прежний фолбэк на history.length убран —
// это lifetime-число с потолком 50 строк, у него другой смысл, и одна
// цифра с двумя смыслами уже попала в аудит (#3320 п.4). Нет квоты —
// нет цифры: честнее спрятать, чем показать не то.
const quotaBadge = quota.data && !quota.data.unlimited
? { used: quota.data.used, limit: quota.data.limit }
: undefined;
// Real user for the TopNav menu. undefined until /me resolves → TopNav shows
// its neutral «Гость» fallback (never the old "Андрей Петров / Брусника").
@ -1045,7 +1052,7 @@ export default function TradeInV2Page() {
<TopNav
active={nav}
onNavigate={setNav}
reports={reportsCount ?? 0}
quotaBadge={quotaBadge}
user={topNavUser}
onLogout={() => logoutMutation.mutate()}
showTeamLink={showTeamNavItem}

View file

@ -20,8 +20,8 @@ import { navLabels } from "./ui-config";
import { useSupportChat } from "./SupportChatContext";
// Real logged-in user identity, derived by the page from useMe()
// ({username, role, brand}). Deliberately excludes `reports` — the «Мои
// отчёты» badge count is a separate prop fed from useQuota().used.
// ({username, role, brand}). Deliberately excludes the quota — the «Мои
// отчёты» badge is a separate prop fed from useQuota().
interface TopNavUser {
name: string;
org: string;
@ -32,9 +32,12 @@ interface TopNavUser {
interface TopNavProps {
active: number;
onNavigate: (i: number) => void;
// «Мои отчёты» badge count — page feeds it from useQuota().used (per-user
// estimate count). Defaults to 0 for unwired usage.
reports?: number;
// Бейдж «Мои отчёты»: месячная квота «использовано / доступно» из
// useQuota(). undefined → бейджа нет (unlimited-аккаунт или квота не
// загрузилась). Раньше тут было голое число used с фолбэком на длину
// истории — одна цифра с двумя смыслами (#3320 п.4): «0» читался как
// «отчётов нет», а был месячным счётчиком, и остаток не показывался нигде.
quotaBadge?: { used: number; limit: number };
// Real logged-in user. undefined → neutral «Гость» placeholder, NEVER the
// old design fixture ("Андрей Петров / Брусника").
user?: TopNavUser;
@ -114,7 +117,7 @@ const menuItemDisabledStyle: CSSProperties = {
export default function TopNav({
active,
onNavigate,
reports = 0,
quotaBadge,
user,
onLogout,
showTeamLink = false,
@ -460,18 +463,21 @@ export default function TopNav({
/>
</svg>
Мои отчёты{" "}
<span
style={{
marginLeft: "auto",
fontFamily: tokens.font.mono,
fontSize: "10px",
// accentDeep (not accent): 10px counter text; accent #2e8bff
// on the white menu is ~2.9:1, accentDeep clears AA (#2264 C4).
color: tokens.accentDeep,
}}
>
{reports}
</span>
{quotaBadge && (
<span
style={{
marginLeft: "auto",
fontFamily: tokens.font.mono,
fontSize: "10px",
// accentDeep (not accent): 10px counter text; accent #2e8bff
// on the white menu is ~2.9:1, accentDeep clears AA (#2264 C4).
color: tokens.accentDeep,
}}
title={`Оценок в этом месяце: ${quotaBadge.used} из ${quotaBadge.limit}`}
>
{quotaBadge.used} / {quotaBadge.limit}
</span>
)}
</div>
<div

View file

@ -0,0 +1,42 @@
/**
* Бейдж «Мои отчёты» обязан показывать МЕСЯЧНУЮ квоту «использовано / доступно».
*
* До 02.09.2026 рендерилось голое `used` с фолбэком на длину истории: «0» у
* свежего аккаунта читался как «отчётов нет», остаток не показывался нигде
* владелец, выдав покупателю 50 оценок, сам не смог найти, где их видно
* (#3320 п.4).
*/
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
vi.mock("../SupportChatContext", () => ({
useSupportChat: () => ({ openChat: vi.fn() }),
}));
import TopNav from "../TopNav";
const USER = { name: "Тест", org: "Орг", email: "t@t", initials: "ТТ" };
describe("TopNav: бейдж квоты", () => {
it("показывает использовано / доступно, а не голое число", () => {
render(
<TopNav active={0} onNavigate={() => {}} user={USER}
quotaBadge={{ used: 3, limit: 50 }} />,
);
// Меню выпадающее — бейдж существует только после открытия.
fireEvent.click(screen.getByText("ТТ"));
expect(
screen.getByText((_, el) => el?.textContent === "3 / 50"),
).toBeTruthy();
});
it("без квоты (unlimited или не загрузилась) бейджа нет — лучше ничего, чем не то", () => {
render(<TopNav active={0} onNavigate={() => {}} user={USER} />);
fireEvent.click(screen.getByText("ТТ"));
expect(
screen.queryByText((_, el) => /^\d+ \/ \d+$/.test(el?.textContent ?? "")),
).toBeNull();
// сам пункт меню на месте
expect(screen.getByText(/Мои отчёты/)).toBeTruthy();
});
});