From 606e38ed77acaf220d696a8b5a6e6724f64af3b4 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sun, 27 Sep 2026 17:42:33 +0300 Subject: [PATCH] refactor: simplify resource queries and layout tests --- .gitea/workflows/image.yml | 2 + .gitea/workflows/verify.yml | 2 + .gitignore | 1 + README.md | 7 + package-lock.json | 201 ++++++++++++++++++ package.json | 2 + .../api/useBrokerageOperations.test.tsx | 2 +- .../portfolio/api/resource-lifecycle.test.tsx | 41 ++++ .../portfolio/api/usePortfolio.test.tsx | 2 +- src/features/instrument-details/index.ts | 1 + .../model/useInstrumentOperations.ts | 51 +++++ .../ui/AccountPage/AccountPage.test.tsx | 20 -- .../position-layout.browser.test.tsx | 76 +++++++ src/pages/bond/ui/BondPage/BondPage.vm.ts | 52 +---- .../stock/ui/StockPage/StockPage.test.tsx | 49 ++++- src/pages/stock/ui/StockPage/StockPage.vm.ts | 54 +---- src/shared/api/resource-query.ts | 19 +- vitest.config.ts | 48 ++++- 18 files changed, 508 insertions(+), 122 deletions(-) create mode 100644 src/features/instrument-details/model/useInstrumentOperations.ts create mode 100644 src/pages/account/ui/AccountPage/position-layout.browser.test.tsx diff --git a/.gitea/workflows/image.yml b/.gitea/workflows/image.yml index d802cea..5ad0de3 100644 --- a/.gitea/workflows/image.yml +++ b/.gitea/workflows/image.yml @@ -30,6 +30,8 @@ jobs: - run: npm ci env: HUSKY: '0' + - name: Install Chromium and system dependencies + run: npx --no-install playwright install --with-deps chromium - run: npm run verify env: KNIP_DISABLE_RAW_TRANSFER: '1' diff --git a/.gitea/workflows/verify.yml b/.gitea/workflows/verify.yml index bd7f96f..8664042 100644 --- a/.gitea/workflows/verify.yml +++ b/.gitea/workflows/verify.yml @@ -21,6 +21,8 @@ jobs: - run: npm ci env: HUSKY: '0' + - name: Install Chromium and system dependencies + run: npx --no-install playwright install --with-deps chromium - name: Run all frontend gates run: npm run verify env: diff --git a/.gitignore b/.gitignore index 17ca640..82a10c4 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ dist storybook-static dist-ssr coverage +.vitest .scannerwork *.local diff --git a/README.md b/README.md index 94f3292..0097ab0 100644 --- a/README.md +++ b/README.md @@ -75,12 +75,19 @@ Service worker кеширует только статическую оболоч ```bash nvm use --silent +npm ci +npx --no-install playwright install chromium npm run verify ``` Команда проверяет, что generated OpenAPI types соответствуют committed snapshot, затем запускает strict TypeScript, Biome, один coverage-backed запуск Vitest, Knip, dependency rules приложения и stories, сборки приложения и Storybook. +Vitest включает JSDOM-тесты и проверку геометрии секций позиций в headless +Chromium при ширинах 375 и 1280 px. Browser project не использует JSDOM setup +и его заглушки layout API. Только браузерная проверка: +`npm run test:run -- --project='browser-*'`. После обновления Playwright повторите +установку Chromium; CI устанавливает его с `--with-deps` для Linux. Отдельный CI workflow `Frontend security` на push, pull request и ежедневно проверяет полную доступную историю Gitleaks 8.30.1 и выполняет `npm audit --include=dev --audit-level=low`. Любой advisory блокирует security job; diff --git a/package-lock.json b/package-lock.json index 0ac4bc4..84a3bcd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,6 +49,7 @@ "@types/react": "19.3.0", "@types/react-dom": "19.3.0", "@vitejs/plugin-react": "6.1.1", + "@vitest/browser-playwright": "5.0.0", "@vitest/coverage-v8": "5.0.0", "babel-plugin-react-compiler": "1.0.0", "conventional-changelog-conventionalcommits": "10.4.0", @@ -59,6 +60,7 @@ "lint-staged": "17.5.1", "msw": "2.15.0", "openapi-typescript": "7.13.0", + "playwright": "1.63.0", "release-it": "21.0.2", "storybook": "10.6.0", "typescript": "5.9.3", @@ -853,6 +855,13 @@ "node": ">=14.21.3" } }, + "node_modules/@blazediff/core": { + "version": "1.10.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@blazediff/core/-/core-1.10.0.tgz", + "integrity": "sha512-AOQff0zgR7cGsZL+4E7hVkmujoPUpm0J9xzWGWZj5wCjd3gmxESXAPfKyuzs93VdpQNFhHlBhfOjrcZ+XTERtQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -3732,6 +3741,13 @@ "url": "https://github.com/phun-ky/typeof?sponsor=1" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@redocly/ajv": { "version": "8.11.2", "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", @@ -5376,6 +5392,54 @@ } } }, + "node_modules/@vitest/browser": { + "version": "5.0.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@vitest/browser/-/browser-5.0.0.tgz", + "integrity": "sha512-JC9FG5xIRxPHXJPcdCaluIJcEoeM0IwGQ3xneuJk09LXKHRNs40BqWDWymQikbb20yOpYvzpKzmYgPRuSzKmvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@blazediff/core": "1.10.0", + "@vitest/mocker": "5.0.0", + "@vitest/ui": "5.0.0", + "@vitest/utils": "5.0.0", + "magic-string": "^1.2.3", + "pngjs": "^7.0.0", + "sirv": "^3.0.2", + "tinyrainbow": "^3.1.1", + "ws": "^8.21.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "5.0.0" + } + }, + "node_modules/@vitest/browser-playwright": { + "version": "5.0.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@vitest/browser-playwright/-/browser-playwright-5.0.0.tgz", + "integrity": "sha512-N+gED9y4/8pypaHjz/x0ah3CjoBr+N0hWWT+Gq4VXtMzyB+rUIdHni0ulzpD1j4H77iWy5Jg8PRMlVn6kjxo6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/browser": "5.0.0", + "@vitest/mocker": "5.0.0", + "tinyrainbow": "^3.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "playwright": "*", + "vitest": "5.0.0" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": false + } + } + }, "node_modules/@vitest/coverage-v8": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.0.0.tgz", @@ -5456,6 +5520,19 @@ } } }, + "node_modules/@vitest/pretty-format": { + "version": "5.0.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@vitest/pretty-format/-/pretty-format-5.0.0.tgz", + "integrity": "sha512-PVRNuB3wpReb4SQEs4zTKM4KWFhQ5pw3spE8naoDJNB5T5aWRzGKHwXcLUllr0WeOTXpB6bSr3CJLo5+7XQSSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vitest/spy": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz", @@ -5466,6 +5543,42 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vitest/ui": { + "version": "5.0.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@vitest/ui/-/ui-5.0.0.tgz", + "integrity": "sha512-h2FIFwggCY2GxUd2UdQoYNVQkOIqEQLPhNREcl3FUiRsdzQep7NWwYbSmhGEA9nFLPDq5pXzRMcBZQU8Py83sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "5.0.0", + "fflate": "^0.8.3", + "flatted": "^3.4.4", + "pathe": "^2.0.3", + "sirv": "^3.0.2", + "tinyrainbow": "^3.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "5.0.0" + } + }, + "node_modules/@vitest/utils": { + "version": "5.0.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@vitest/utils/-/utils-5.0.0.tgz", + "integrity": "sha512-dO++xL3vDfvhTAVimfkuQUA3k+JClIF1i1vAkPqpcGAthRmeWnXmHB7YPViPvgCwviX8u7Y5W1u2N//AaQr3fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "5.0.0", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@wealthledger/icons": { "resolved": "packages/icons", "link": true @@ -7606,6 +7719,13 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, "node_modules/filelist": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", @@ -7646,6 +7766,13 @@ "node": ">=10" } }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -9946,6 +10073,16 @@ "integrity": "sha512-7DnN7TmbLcYXcG4RVadXIihWlyuM9afoUww8Y5Agg431kGKiuL2/OMyP4mJ5wLz+pvN3t5ySClLOaVXJ+wekRQ==", "license": "MIT" }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -10749,6 +10886,35 @@ "dev": true, "license": "MIT" }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", @@ -10759,6 +10925,16 @@ "node": ">=4" } }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -12172,6 +12348,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -13643,6 +13834,16 @@ "dev": true, "license": "MIT" }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/tough-cookie": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", diff --git a/package.json b/package.json index 275132b..ad387de 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "@types/react": "19.3.0", "@types/react-dom": "19.3.0", "@vitejs/plugin-react": "6.1.1", + "@vitest/browser-playwright": "5.0.0", "@vitest/coverage-v8": "5.0.0", "babel-plugin-react-compiler": "1.0.0", "conventional-changelog-conventionalcommits": "10.4.0", @@ -82,6 +83,7 @@ "lint-staged": "17.5.1", "msw": "2.15.0", "openapi-typescript": "7.13.0", + "playwright": "1.63.0", "release-it": "21.0.2", "storybook": "10.6.0", "typescript": "5.9.3", diff --git a/src/entities/broker-operation/api/useBrokerageOperations.test.tsx b/src/entities/broker-operation/api/useBrokerageOperations.test.tsx index 800ecf8..fea398b 100644 --- a/src/entities/broker-operation/api/useBrokerageOperations.test.tsx +++ b/src/entities/broker-operation/api/useBrokerageOperations.test.tsx @@ -8,7 +8,7 @@ import { } from "./useBrokerageOperations"; describe("useBrokerageOperations", () => { - it("normalizes query parameters and loads a page without retries", async () => { + it("normalizes categories, preserves the cursor and loads a page", async () => { configureAuthClient({ accessToken: () => "token", refresh: async () => true, diff --git a/src/entities/portfolio/api/resource-lifecycle.test.tsx b/src/entities/portfolio/api/resource-lifecycle.test.tsx index 6bf9d4f..16e8609 100644 --- a/src/entities/portfolio/api/resource-lifecycle.test.tsx +++ b/src/entities/portfolio/api/resource-lifecycle.test.tsx @@ -79,8 +79,49 @@ describe("persistent resource query lifecycle", () => { await waitFor(() => expect(result.current.meta?.sync.state).toBe("failed")); expect(result.current.data).toBeUndefined(); expect(result.current.isSuccess).toBe(false); + expect(result.current.status).toBe("pending"); + expect(result.current.isPending).toBe(true); + expect(result.current.isLoading).toBe(false); + expect(result.current.transport).toEqual({ + status: "success", + fetchStatus: "idle", + error: null, + }); expect(result.current.meta?.availability).toBe("missing"); }); + it("keeps the refetch envelope while exposing the refreshed resource payload", async () => { + auth(); + let ready = false; + const refreshed = resource(snapshot, { stateVersion: "ready" }); + server.use( + http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => + HttpResponse.json(ready ? refreshed : resource(null)), + ), + ); + const { result } = renderHook(() => usePortfolio("a"), { + wrapper: createQueryWrapper().wrapper, + }); + await waitFor(() => + expect(result.current.transport.status).toBe("success"), + ); + await act(async () => { + const missing = await result.current.refetch(); + expect(missing.status).toBe("success"); + expect(missing.data).toEqual(resource(null)); + expect(missing.data?.data).toBeNull(); + ready = true; + const loaded = await result.current.refetch(); + expect(loaded.data).toEqual(refreshed); + expect(loaded.data?.data?.totalsByCurrency).toEqual( + snapshot.totalsByCurrency, + ); + }); + await waitFor(() => expect(result.current.data).toEqual(snapshot)); + expect(result.current.meta).toEqual(refreshed.meta); + expect(result.current.status).toBe("success"); + expect(result.current.isSuccess).toBe(true); + expect(result.current.isPending).toBe(false); + }); it("rejects a late ordinary response when an observe response for that resource arrived first", async () => { auth(); let release!: () => void; diff --git a/src/entities/portfolio/api/usePortfolio.test.tsx b/src/entities/portfolio/api/usePortfolio.test.tsx index efb6e4c..9c580aa 100644 --- a/src/entities/portfolio/api/usePortfolio.test.tsx +++ b/src/entities/portfolio/api/usePortfolio.test.tsx @@ -8,7 +8,7 @@ import { server } from "@/test/server"; import { usePortfolio } from "./usePortfolio"; describe("usePortfolio", () => { - it("uses the account endpoint with a 30 second cache and no retry", async () => { + it("loads the account portfolio", async () => { configureAuthClient({ accessToken: () => "token", refresh: async () => true, diff --git a/src/features/instrument-details/index.ts b/src/features/instrument-details/index.ts index dfaf39b..a8df803 100644 --- a/src/features/instrument-details/index.ts +++ b/src/features/instrument-details/index.ts @@ -1,4 +1,5 @@ export { section } from "./model/section"; +export { useInstrumentOperations } from "./model/useInstrumentOperations"; export { InstrumentChart } from "./ui/InstrumentChart.view"; export { InstrumentEvents } from "./ui/InstrumentEvents.view"; export { InstrumentPosition } from "./ui/InstrumentPosition.view"; diff --git a/src/features/instrument-details/model/useInstrumentOperations.ts b/src/features/instrument-details/model/useInstrumentOperations.ts new file mode 100644 index 0000000..2042dce --- /dev/null +++ b/src/features/instrument-details/model/useInstrumentOperations.ts @@ -0,0 +1,51 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { + brokerageOperationsQueryOptions, + type OperationCategory, + useBrokerageOperations, +} from "@/entities/broker-operation"; +import { apiErrorCode } from "@/shared/api"; + +function useInstrumentOperations( + uid: string, + filter: string, + categories: OperationCategory[], +) { + const scope = `${uid}:${filter}`; + const [pagination, setPagination] = useState<{ + scope: string; + cursors: string[]; + }>({ scope, cursors: [] }); + const cursors = pagination.scope === scope ? pagination.cursors : []; + const client = useQueryClient(); + const firstPage = { instrumentUid: uid, categories, limit: 20 }; + const operations = useBrokerageOperations(uid ? "all" : undefined, { + ...firstPage, + cursor: cursors.at(-1), + }); + const retry = (retryScreen: () => unknown) => { + if (apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR") { + setPagination({ scope, cursors: [] }); + void client.invalidateQueries({ + queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey, + exact: true, + }); + } else void retryScreen(); + }; + return { + operations, + retry, + hasPrevious: cursors.length > 0, + onPrevious: () => setPagination({ scope, cursors: cursors.slice(0, -1) }), + onNext: () => { + if (operations.data?.nextCursor) + setPagination({ + scope, + cursors: [...cursors, operations.data.nextCursor], + }); + }, + }; +} + +export { useInstrumentOperations }; diff --git a/src/pages/account/ui/AccountPage/AccountPage.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.test.tsx index 22ede2a..c9b718c 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.test.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.test.tsx @@ -660,26 +660,6 @@ describe("AccountPage states", () => { ).toBeInTheDocument(); }); - it("renders the bonds section as a single-column grid on any screen width", () => { - renderPage( - , - ); - const singleColumnGrids = Array.from(document.querySelectorAll("style")) - .map((style) => (style.textContent ?? "").replace(/\s/g, "")) - .filter( - (css) => - css.includes("--sg-cols:1") && - !css.includes("@media") && - !/--sg-cols:[23]/.test(css), - ); - expect(singleColumnGrids).toHaveLength(1); - }); - it("keeps other-position cards compact with labeled return and no logo", () => { renderPage( { + const kinds: AccountPagePositionSection["kind"][] = [ + "stocks", + "bonds", + "other", + ]; + const width = window.innerWidth; + expect([375, 1280]).toContain(width); + for (const kind of kinds) { + const positions: AccountPagePosition[] = [1, 2, 3].map((index) => ({ + figi: null, + displayName: `Инструмент ${index}`, + type: kind === "stocks" ? "STOCK" : kind === "bonds" ? "BOND" : "FUND", + quantity: "10", + currentValue: { currency: "RUB", value: "1000" }, + averagePurchasePrice: null, + absoluteReturn: null, + isin: null, + ticker: null, + logoName: null, + shareText: null, + returnText: null, + logoSrc: null, + })); + const { unmount } = render( + + ({ + key: position.displayName, + positions: [position], + })), + }} + /> + , + ); + const bounds = positions.map((position) => + screen.getByText(position.displayName).getBoundingClientRect(), + ); + const context = `${kind} at ${width}px`; + for (const [index, current] of bounds.entries()) { + expect(current.width, context).toBeGreaterThan(0); + expect(current.height, context).toBeGreaterThan(0); + if (index === 0) continue; + const previous = bounds[index - 1]; + if (kind === "other" && width === 1280) { + expect(current.top, context).toBeCloseTo(previous.top, 0); + expect(current.left, context).toBeGreaterThan(previous.right); + } else { + expect(current.left, context).toBeCloseTo(previous.left, 0); + expect(current.top, context).toBeGreaterThan(previous.bottom); + } + } + unmount(); + } +}); diff --git a/src/pages/bond/ui/BondPage/BondPage.vm.ts b/src/pages/bond/ui/BondPage/BondPage.vm.ts index 0177c4b..4c81fef 100644 --- a/src/pages/bond/ui/BondPage/BondPage.vm.ts +++ b/src/pages/bond/ui/BondPage/BondPage.vm.ts @@ -1,5 +1,3 @@ -import { useQueryClient } from "@tanstack/react-query"; -import { useState } from "react"; import { useParams, useSearchParams } from "react-router"; import { type BondPeriod, @@ -10,12 +8,11 @@ import { useBondPosition, useBondQuote, } from "@/entities/bond"; +import type { OperationCategory } from "@/entities/broker-operation"; import { - brokerageOperationsQueryOptions, - type OperationCategory, - useBrokerageOperations, -} from "@/entities/broker-operation"; -import { section } from "@/features/instrument-details"; + section, + useInstrumentOperations, +} from "@/features/instrument-details"; import { useScreenSync } from "@/features/tbank-connection"; import { apiErrorCode } from "@/shared/api"; import { formatDate, instrumentLogoUrl } from "@/shared/lib"; @@ -38,27 +35,13 @@ function useBondPageVm() { periods.find((value) => value === params.get("period")) ?? "month"; const filter = filters.find((value) => value === params.get("events")) ?? "all"; - const scope = `${uid}:${filter}`; - const [pagination, setPagination] = useState<{ - scope: string; - cursors: string[]; - }>({ scope, cursors: [] }); - const cursors = pagination.scope === scope ? pagination.cursors : []; - const client = useQueryClient(); const quote = useBondQuote(uid); const metrics = useBondMetrics(uid); const candles = useBondCandles(uid, period); const position = useBondPosition(uid); const coupons = useBondCoupons(uid); - const firstPage = { - instrumentUid: uid, - categories: categories[filter], - limit: 20, - }; - const operations = useBrokerageOperations(uid ? "all" : undefined, { - ...firstPage, - cursor: cursors.at(-1), - }); + const history = useInstrumentOperations(uid, filter, categories[filter]); + const { operations } = history; const sync = useScreenSync( { screen: "bond", @@ -75,15 +58,6 @@ function useBondPageVm() { next.set(key, value); setParams(next, { replace: true }); }; - const retryOperations = () => { - if (apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR") { - setPagination({ scope, cursors: [] }); - void client.invalidateQueries({ - queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey, - exact: true, - }); - } else void sync.retry(); - }; const chartData = candles.data?.candles.map((candle) => ({ ...candle, @@ -138,7 +112,7 @@ function useBondPageVm() { operations: operations.data, operationsState: { ...section(operations, sync.retry), - retry: retryOperations, + retry: () => history.retry(sync.retry), }, eventRows: operations.data?.items.map((item) => ({ @@ -148,15 +122,9 @@ function useBondPageVm() { })) ?? [], filter, setFilter: (value: string) => setParam("events", value), - hasPrevious: cursors.length > 0, - onPrevious: () => setPagination({ scope, cursors: cursors.slice(0, -1) }), - onNext: () => { - if (operations.data?.nextCursor) - setPagination({ - scope, - cursors: [...cursors, operations.data.nextCursor], - }); - }, + hasPrevious: history.hasPrevious, + onPrevious: history.onPrevious, + onNext: history.onNext, }; } type BondPageVm = ReturnType; diff --git a/src/pages/stock/ui/StockPage/StockPage.test.tsx b/src/pages/stock/ui/StockPage/StockPage.test.tsx index fc251ce..56175a6 100644 --- a/src/pages/stock/ui/StockPage/StockPage.test.tsx +++ b/src/pages/stock/ui/StockPage/StockPage.test.tsx @@ -70,6 +70,7 @@ function setup( noPosition?: boolean; failCompany?: boolean; failStock?: boolean; + cursorError?: string; path?: string; slug?: string; ambiguousTicker?: boolean; @@ -79,6 +80,8 @@ function setup( const requests: URL[] = []; const resolved = { ...stock, slug: options.slug ?? uid }; let failQuote = false; + let cursorError = options.cursorError; + let operationPayment = "200"; server.use( http.get("*/api/v1/stocks/:uid", () => options.failStock @@ -166,6 +169,8 @@ function setup( const url = new URL(request.url); requests.push(url); const next = url.searchParams.has("cursor"); + if (next && cursorError) + return HttpResponse.json({ code: cursorError }, { status: 400 }); return HttpResponse.json( resource({ accountId: "all", @@ -182,7 +187,7 @@ function setup( category: next ? "trade" : "income", description: "", account: { id: "main", name: "Основной" }, - payment: money("200"), + payment: money(operationPayment), quantity: next ? "2" : null, price: next ? money("100") : null, }, @@ -211,6 +216,10 @@ function setup( ); return { requests, + recoverOperations: () => { + cursorError = undefined; + operationPayment = "201"; + }, failQuote: () => { failQuote = true; }, @@ -374,7 +383,7 @@ describe("stock page", () => { expect(requests).toHaveLength(previousRequests); expect(modes).toEqual(["observe"]); }); - it("sends instrument-filtered continuation, resets it on filter changes and changes chart period", async () => { + it("sends instrument-filtered continuation, restores it on returning to the filter and changes chart period", async () => { const { requests } = setup(); await screen.findByText("Зачисление дивидендов"); fireEvent.click(screen.getByRole("button", { name: "Далее" })); @@ -399,6 +408,14 @@ describe("stock page", () => { ), ).toBe(true), ); + expect(await screen.findByText("Зачисление дивидендов")).toBeVisible(); + expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled(); + fireEvent.click(screen.getByRole("radio", { name: "Все" })); + expect(await screen.findByText("Продажа")).toBeVisible(); + expect(screen.getByRole("button", { name: "Назад" })).toBeEnabled(); + fireEvent.click(screen.getByRole("button", { name: "Назад" })); + expect(await screen.findByText("Зачисление дивидендов")).toBeVisible(); + expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled(); fireEvent.click(screen.getByRole("radio", { name: "Год" })); await waitFor(() => expect( @@ -406,6 +423,34 @@ describe("stock page", () => { ).toBe(true), ); }); + it.each([ + ["INVALID_OPERATIONS_CURSOR", null, null], + ["TBANK_UNAVAILABLE", "next", "observe"], + ])( + "recovers history after %s with fresh data and the correct cursor", + async (code, cursor, sync) => { + 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) + expect(screen.getByRole("button", { name: "Назад" })).toBeEnabled(); + else expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled(); + }, + ); it("keeps the last quote on a refresh failure", async () => { const scopes: unknown[] = []; server.use( diff --git a/src/pages/stock/ui/StockPage/StockPage.vm.ts b/src/pages/stock/ui/StockPage/StockPage.vm.ts index f996827..bab72b7 100644 --- a/src/pages/stock/ui/StockPage/StockPage.vm.ts +++ b/src/pages/stock/ui/StockPage/StockPage.vm.ts @@ -1,11 +1,5 @@ -import { useQueryClient } from "@tanstack/react-query"; -import { useState } from "react"; import { useParams, useSearchParams } from "react-router"; -import { - brokerageOperationsQueryOptions, - type OperationCategory, - useBrokerageOperations, -} from "@/entities/broker-operation"; +import type { OperationCategory } from "@/entities/broker-operation"; import { type StockPeriod, useStock, @@ -15,7 +9,10 @@ import { useStockPosition, useStockQuote, } from "@/entities/stock"; -import { section } from "@/features/instrument-details"; +import { + section, + useInstrumentOperations, +} from "@/features/instrument-details"; import { useScreenSync } from "@/features/tbank-connection"; import { apiErrorCode } from "@/shared/api"; import { formatDate, instrumentLogoUrl } from "@/shared/lib"; @@ -38,27 +35,13 @@ function useStockPageVm() { periods.find((value) => value === params.get("period")) ?? "month"; const filter = filters.find((value) => value === params.get("events")) ?? "all"; - const scope = `${uid}:${filter}`; - const [pagination, setPagination] = useState<{ - scope: string; - cursors: string[]; - }>({ scope, cursors: [] }); - const cursors = pagination.scope === scope ? pagination.cursors : []; - const client = useQueryClient(); const quote = useStockQuote(uid); const candles = useStockCandles(uid, period); const position = useStockPosition(uid); const company = useStockCompany(uid); const dividends = useStockDividends(uid); - const firstPage = { - instrumentUid: uid, - categories: categories[filter], - limit: 20, - }; - const operations = useBrokerageOperations(uid ? "all" : undefined, { - ...firstPage, - cursor: cursors.at(-1), - }); + const history = useInstrumentOperations(uid, filter, categories[filter]); + const { operations } = history; const sync = useScreenSync( { screen: "stock", @@ -75,15 +58,6 @@ function useStockPageVm() { next.set(key, value); setParams(next, { replace: true }); }; - const retryOperations = () => { - if (apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR") { - setPagination({ scope, cursors: [] }); - void client.invalidateQueries({ - queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey, - exact: true, - }); - } else void sync.retry(); - }; const chartData = candles.data?.candles.map((candle) => ({ ...candle, @@ -145,7 +119,7 @@ function useStockPageVm() { operations: operations.data, operationsState: { ...section(operations, sync.retry), - retry: retryOperations, + retry: () => history.retry(sync.retry), }, eventRows: operations.data?.items.map((item) => ({ @@ -155,15 +129,9 @@ function useStockPageVm() { })) ?? [], filter, setFilter: (value: string) => setParam("events", value), - hasPrevious: cursors.length > 0, - onPrevious: () => setPagination({ scope, cursors: cursors.slice(0, -1) }), - onNext: () => { - if (operations.data?.nextCursor) - setPagination({ - scope, - cursors: [...cursors, operations.data.nextCursor], - }); - }, + hasPrevious: history.hasPrevious, + onPrevious: history.onPrevious, + onNext: history.onNext, }; } type StockPageVm = ReturnType; diff --git a/src/shared/api/resource-query.ts b/src/shared/api/resource-query.ts index 1f124e4..f368ed7 100644 --- a/src/shared/api/resource-query.ts +++ b/src/shared/api/resource-query.ts @@ -22,6 +22,12 @@ function unwrapResourceResult( result: UseQueryResult, ) { const missing = result.isSuccess && result.data.data === null; + const data: T["data"] | undefined = result.data?.data; + const meta: T["meta"] | undefined = result.data?.meta; + const status: UseQueryResult["status"] = missing + ? "pending" + : result.status; + // Project resource fields; refetch still returns the transport envelope. return { ...result, transport: { @@ -29,18 +35,11 @@ function unwrapResourceResult( fetchStatus: result.fetchStatus, error: result.error, }, - data: result.data?.data ?? undefined, - meta: result.data?.meta, + data: data ?? undefined, + meta, isPending: result.isPending || missing, isSuccess: result.isSuccess && !missing, - status: missing ? "pending" : result.status, - } as UseQueryResult, E> & { - meta?: T["meta"]; - transport: { - status: UseQueryResult["status"]; - fetchStatus: UseQueryResult["fetchStatus"]; - error: E | null; - }; + status, }; } diff --git a/vitest.config.ts b/vitest.config.ts index 3893652..fd3e03b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,8 +1,12 @@ import { fileURLToPath, URL } from "node:url"; import react from "@vitejs/plugin-react"; +import { playwright } from "@vitest/browser-playwright"; import { configDefaults, defineConfig } from "vitest/config"; const CI_TEST_TIMEOUT_MS = 15_000; +const LAYOUT_VIEWPORT_HEIGHT = 900; +const LAYOUT_NARROW_WIDTH = 375; +const LAYOUT_WIDE_WIDTH = 1280; export default defineConfig({ plugins: [react()], @@ -10,9 +14,47 @@ export default defineConfig({ test: { // CI runs the UI scenarios with coverage on a slower runner. testTimeout: process.env.CI ? CI_TEST_TIMEOUT_MS : undefined, - environment: "jsdom", - exclude: configDefaults.exclude, - setupFiles: "./src/test/setup.ts", + projects: [ + { + extends: true, + test: { + name: "unit", + environment: "jsdom", + exclude: [...configDefaults.exclude, "**/*.browser.test.tsx"], + setupFiles: "./src/test/setup.ts", + }, + }, + { + extends: true, + test: { + name: "browser", + include: ["src/**/*.browser.test.tsx"], + browser: { + enabled: true, + provider: playwright(), + headless: true, + instances: [ + { + browser: "chromium", + name: "browser-narrow", + viewport: { + width: LAYOUT_NARROW_WIDTH, + height: LAYOUT_VIEWPORT_HEIGHT, + }, + }, + { + browser: "chromium", + name: "browser-wide", + viewport: { + width: LAYOUT_WIDE_WIDTH, + height: LAYOUT_VIEWPORT_HEIGHT, + }, + }, + ], + }, + }, + }, + ], coverage: { include: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"], exclude: ["src/test/**", "**/*.test.{ts,tsx}", "**/*.stories.tsx"], -- 2.54.0