perf(web): свод 2 — чистая логика окна страниц, параметров и вариантов
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2421f7ddd5
commit
a9a05c2360
112
web/src/pages/Svod2Page/svod2Paging.js
Normal file
112
web/src/pages/Svod2Page/svod2Paging.js
Normal file
@ -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}`;
|
||||
}
|
||||
154
web/src/pages/Svod2Page/svod2Paging.test.js
Normal file
154
web/src/pages/Svod2Page/svod2Paging.test.js
Normal file
@ -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);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user