From a9a05c2360277cf50e3ba7a39d2d19bb23c6e48f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 22:19:00 +0400 Subject: [PATCH] =?UTF-8?q?perf(web):=20=D1=81=D0=B2=D0=BE=D0=B4=202=20?= =?UTF-8?q?=E2=80=94=20=D1=87=D0=B8=D1=81=D1=82=D0=B0=D1=8F=20=D0=BB=D0=BE?= =?UTF-8?q?=D0=B3=D0=B8=D0=BA=D0=B0=20=D0=BE=D0=BA=D0=BD=D0=B0=20=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86,=20=D0=BF=D0=B0=D1=80?= =?UTF-8?q?=D0=B0=D0=BC=D0=B5=D1=82=D1=80=D0=BE=D0=B2=20=D0=B8=20=D0=B2?= =?UTF-8?q?=D0=B0=D1=80=D0=B8=D0=B0=D0=BD=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/pages/Svod2Page/svod2Paging.js | 112 ++++++++++++++ web/src/pages/Svod2Page/svod2Paging.test.js | 154 ++++++++++++++++++++ 2 files changed, 266 insertions(+) create mode 100644 web/src/pages/Svod2Page/svod2Paging.js create mode 100644 web/src/pages/Svod2Page/svod2Paging.test.js diff --git a/web/src/pages/Svod2Page/svod2Paging.js b/web/src/pages/Svod2Page/svod2Paging.js new file mode 100644 index 0000000..e0289ec --- /dev/null +++ b/web/src/pages/Svod2Page/svod2Paging.js @@ -0,0 +1,112 @@ +// Чистая логика серверной постраничной загрузки свода 2: окно страниц, срез с заглушками, +// LRU-кэш страниц, параметры запроса, варианты фильтров. Без React, JSX и axios. + +export const PAGE_SIZE = 300; +export const WINDOW_RADIUS = 1; +export const PAGE_CACHE_LIMIT = 5; +export const FILTER_COLUMNS = ['smeta_type', 'smeta_direction', 'org_name']; +export const EMPTY_FACET_MAP = Object.freeze(new Map()); + +export function getPageCount(total) { + return total > 0 ? Math.ceil(total / PAGE_SIZE) : 0; +} + +export function getVisiblePage({ scrollTop, rowHeight, total }) { + const lastPage = Math.max(getPageCount(total) - 1, 0); + const firstRow = Math.floor(scrollTop / rowHeight); + const page = Math.floor(firstRow / PAGE_SIZE); + if (!Number.isFinite(page)) return 0; + return Math.min(Math.max(page, 0), lastPage); +} + +export function getWindowPages(visiblePage, total) { + const count = getPageCount(total); + const pages = []; + for (let p = visiblePage - WINDOW_RADIUS; p <= visiblePage + WINDOW_RADIUS; p++) { + if (p >= 0 && p < count) pages.push(p); + } + return pages; +} + +export function getWindowRange(pages, total) { + if (pages.length === 0) return { windowStart: 0, windowEnd: 0 }; + const first = pages[0]; + const last = pages[pages.length - 1]; + return { windowStart: first * PAGE_SIZE, windowEnd: Math.min(total, (last + 1) * PAGE_SIZE) }; +} + +export function buildWindowRows(pages, pageCache, total) { + const result = []; + for (const p of pages) { + const start = p * PAGE_SIZE; + const expected = Math.min(total, (p + 1) * PAGE_SIZE) - start; + const cached = pageCache.get(p) ?? []; + for (let i = 0; i < expected; i++) { + result.push(i < cached.length ? cached[i] : { __placeholder: true, index: start + i }); + } + } + return result; +} + +export function getVirtualizerPadding({ windowStart, windowEnd, total, rowHeight }) { + return { + paddingStart: windowStart * rowHeight, + paddingEnd: Math.max(total - windowEnd, 0) * rowHeight, + }; +} + +export function touchPage(cache, pageIndex, rows, keepPages = []) { + const next = new Map(cache); + next.delete(pageIndex); + next.set(pageIndex, rows); + const keep = new Set(keepPages); + keep.add(pageIndex); + for (const key of [...next.keys()]) { + if (next.size <= PAGE_CACHE_LIMIT) break; + if (!keep.has(key)) next.delete(key); + } + return next; +} + +export function getMissingPages(pages, cache, inflight) { + return pages.filter((p) => !cache.has(p) && !inflight.has(p)); +} + +export function buildSvod2QueryParams({ orgIds = [], search = '', columnFilters = [] } = {}) { + const params = {}; + if (orgIds.length > 0) params.org_ids = orgIds.join(','); + if (search !== '') params.search = search; + for (const { id, value } of columnFilters) { + if (FILTER_COLUMNS.includes(id) && value !== '' && value !== null && value !== undefined) { + params[id] = String(value); + } + } + return params; +} + +export function buildSvod2PageParams({ orgIds, search, columnFilters, offset, limit = PAGE_SIZE }) { + return { view: 'compact', offset, limit, ...buildSvod2QueryParams({ orgIds, search, columnFilters }) }; +} + +export function facetsToMaps(result) { + const maps = new Map(); + for (const column of FILTER_COLUMNS) { + const map = new Map(); + for (const { value, count } of result?.[column] ?? []) map.set(value, count); + maps.set(column, map); + } + return maps; +} + +export function shouldShowFound({ hasCurrentData, loading, facets }) { + return Boolean(hasCurrentData) && !loading && !!facets && facets.search_total !== facets.total; +} + +export function isPlaceholderRow(row) { + return Boolean(row?.__placeholder); +} + +export function getSvod2RowId(row) { + if (isPlaceholderRow(row)) return `ph-${row.index}`; + return `${row.data?.header?.source_form}-${row.data?.header?.source_id}-${row.data?.line_id}`; +} diff --git a/web/src/pages/Svod2Page/svod2Paging.test.js b/web/src/pages/Svod2Page/svod2Paging.test.js new file mode 100644 index 0000000..98e2fdb --- /dev/null +++ b/web/src/pages/Svod2Page/svod2Paging.test.js @@ -0,0 +1,154 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + EMPTY_FACET_MAP, + PAGE_CACHE_LIMIT, + buildSvod2PageParams, + buildSvod2QueryParams, + buildWindowRows, + facetsToMaps, + getMissingPages, + getPageCount, + getSvod2RowId, + getVirtualizerPadding, + getVisiblePage, + getWindowPages, + getWindowRange, + isPlaceholderRow, + shouldShowFound, + touchPage, +} from './svod2Paging.js'; + +test('окно в начале — страницы 0 и 1 (решение #15)', () => assert.deepEqual(getWindowPages(0, 5563), [0, 1])); +test('окно в середине и в конце', () => { + assert.deepEqual(getWindowPages(5, 5563), [4, 5, 6]); + assert.deepEqual(getWindowPages(18, 5563), [17, 18]); +}); +test('total = 0 — пустое окно и пустой срез', () => { + assert.deepEqual(getWindowPages(0, 0), []); + assert.deepEqual(buildWindowRows([], new Map(), 0), []); + assert.equal(getPageCount(0), 0); + assert.deepEqual(getWindowRange([], 0), { windowStart: 0, windowEnd: 0 }); +}); +test('видимая страница прижимается к последней при уменьшении total', () => + assert.equal(getVisiblePage({ scrollTop: 200000 * 37, rowHeight: 37, total: 700 }), 2)); +test('видимая страница: обычный скролл и total = 0', () => { + assert.equal(getVisiblePage({ scrollTop: 0, rowHeight: 37, total: 5563 }), 0); + assert.equal(getVisiblePage({ scrollTop: 300 * 37, rowHeight: 37, total: 5563 }), 1); + assert.equal(getVisiblePage({ scrollTop: 300 * 37 - 1, rowHeight: 37, total: 5563 }), 0); + assert.equal(getVisiblePage({ scrollTop: 5000, rowHeight: 37, total: 0 }), 0); +}); +test('срез: неполная последняя страница и заглушки', () => { + const rows = buildWindowRows([17, 18], new Map([[18, Array.from({ length: 163 }, (_, i) => ({ id: i }))]]), 5563); + assert.equal(rows.length, 300 + 163); + assert.deepEqual(rows[0], { __placeholder: true, index: 5100 }); + assert.equal(rows[300].id, 0); +}); +test('страница короче ожидаемой дополняется заглушками, длиннее — обрезается', () => { + // total изменился между запросами: страница 0 пришла с 250 строками, страница 1 — с 400 + const short = Array.from({ length: 250 }, (_, i) => ({ id: i })); + const long = Array.from({ length: 400 }, (_, i) => ({ id: 1000 + i })); + const rows = buildWindowRows( + [0, 1], + new Map([ + [0, short], + [1, long], + ]), + 600, + ); + assert.equal(rows.length, 600); + assert.equal(rows[249].id, 249); + assert.deepEqual(rows[250], { __placeholder: true, index: 250 }); + assert.deepEqual(rows[299], { __placeholder: true, index: 299 }); + assert.equal(rows[300].id, 1000); + assert.equal(rows[599].id, 1299); +}); +test('сумма отступов и строк окна = total * rowH', () => { + const { windowStart, windowEnd } = getWindowRange([4, 5, 6], 5563); + assert.equal(windowStart, 1200); + assert.equal(windowEnd, 2100); + const { paddingStart, paddingEnd } = getVirtualizerPadding({ windowStart, windowEnd, total: 5563, rowHeight: 37 }); + assert.equal(paddingStart + (windowEnd - windowStart) * 37 + paddingEnd, 5563 * 37); +}); +test('окно с последней неполной страницей: windowEnd = total, нижний отступ 0', () => { + const { windowStart, windowEnd } = getWindowRange([17, 18], 5563); + assert.equal(windowEnd, 5563); + assert.deepEqual(getVirtualizerPadding({ windowStart, windowEnd, total: 5563, rowHeight: 37 }).paddingEnd, 0); +}); +test('LRU: не больше 5 страниц, страницы окна не вытесняются', () => { + let cache = new Map(); + for (let p = 0; p < 7; p++) cache = touchPage(cache, p, [{ id: p }], [0, 1]); + assert.equal(PAGE_CACHE_LIMIT, 5); + assert.equal(cache.size, 5); + assert.ok(cache.has(0)); + assert.ok(cache.has(1)); + assert.deepEqual( + [...cache.keys()].sort((a, b) => a - b), + [0, 1, 4, 5, 6], + ); +}); +test('LRU: повторное обращение делает страницу самой свежей, исходный Map не мутируется', () => { + const base = new Map([ + [1, []], + [2, []], + ]); + const next = touchPage(base, 1, [{ id: 'x' }]); + assert.notEqual(next, base); + assert.deepEqual([...base.keys()], [1, 2]); + assert.deepEqual([...next.keys()], [2, 1]); + assert.deepEqual(next.get(1), [{ id: 'x' }]); +}); +test('getMissingPages исключает загруженные и летящие', () => + assert.deepEqual(getMissingPages([4, 5, 6], new Map([[5, []]]), new Set([6])), [4])); +test('параметры страницы: compact, пустые не передаются, search как введён', () => { + assert.deepEqual(buildSvod2PageParams({ orgIds: [], search: '', columnFilters: [], offset: 300 }), { + view: 'compact', + offset: 300, + limit: 300, + }); + assert.deepEqual( + buildSvod2QueryParams({ + orgIds: [3, 5], + search: ' аренда ', + columnFilters: [ + { id: 'org_name', value: 'Филиал Ёлки' }, + { id: 'name', value: 'x' }, + ], + }), + { org_ids: '3,5', search: ' аренда ', org_name: 'Филиал Ёлки' }, + ); + assert.deepEqual( + buildSvod2QueryParams({ + columnFilters: [ + { id: 'smeta_type', value: '' }, + { id: 'smeta_direction', value: null }, + ], + }), + {}, + ); + assert.equal(buildSvod2PageParams({ offset: 0, limit: 50, search: 'a' }).limit, 50); +}); +test('facetsToMaps', () => { + const result = { smeta_type: [{ value: 'КВ', count: 3 }], smeta_direction: [], org_name: [] }; + const maps = facetsToMaps(result); + assert.equal(maps.get('smeta_type').get('КВ'), 3); + assert.equal(maps.get('org_name').size, 0); + assert.notEqual(facetsToMaps(result), maps); + assert.notEqual(facetsToMaps(result).get('smeta_type'), maps.get('smeta_type')); + assert.equal(facetsToMaps(null).get('org_name').size, 0); + assert.ok(Object.isFrozen(EMPTY_FACET_MAP)); +}); +test('«Найдено» — только поиск', () => { + assert.equal(shouldShowFound({ hasCurrentData: true, loading: false, facets: { total: 5563, search_total: 300 } }), true); + assert.equal(shouldShowFound({ hasCurrentData: true, loading: false, facets: { total: 5563, search_total: 5563 } }), false); + assert.equal(shouldShowFound({ hasCurrentData: true, loading: true, facets: { total: 5563, search_total: 300 } }), false); + assert.equal(shouldShowFound({ hasCurrentData: false, loading: false, facets: { total: 5563, search_total: 300 } }), false); + assert.equal(shouldShowFound({ hasCurrentData: true, loading: false, facets: null }), false); +}); +test('getSvod2RowId', () => { + assert.equal(getSvod2RowId({ __placeholder: true, index: 7 }), 'ph-7'); + assert.equal(getSvod2RowId({ data: { header: { source_form: 'f1', source_id: 2 }, line_id: 3 } }), 'f1-2-3'); + assert.equal(isPlaceholderRow({ __placeholder: true, index: 1 }), true); + assert.equal(isPlaceholderRow({ data: {} }), false); + assert.equal(isPlaceholderRow(undefined), false); +});