diff --git a/IMPLEMENTATION_LOG.md b/IMPLEMENTATION_LOG.md index 868d5a1..ea3d3a4 100644 --- a/IMPLEMENTATION_LOG.md +++ b/IMPLEMENTATION_LOG.md @@ -180,3 +180,27 @@ - Как проверялось: vitest 78/78 (дедуп по Idempotency-Key, повторный webhook не зачисляет дважды, изоляция организаций, 404 confirm-test при не-mock, 401 по подписи); `npm run check` чисто. - Влияние на поиск/UX: нет. - Подводные камни: на проде PAYMENT_PROVIDER не задан → mock активен, кнопка «Подтвердить (тест)» видна админам организаций — это и есть поставка шага; при подключении реального провайдера кнопка скроется автоматически. Подключение реального провайдера: новый класс в server/billing/ + case в фабрике + env (см. отчёт в коде payment-provider.ts). + +--- + +## [1.1] Data-tables: фильтрация на уровне SQL + +- Статус: ✅ done +- Зачем: справочники не грузят все строки/таблицы ради фильтрации в JS (Фаза 1). +- Что изменено: + - `server/storage/data-tables-core.storage.ts` — `getDataTablesWithAccess` без N+1 (2 запроса вместо 1+N); новый `getDataTableRowsPaged` (SQL: фильтры `values->>N ILIKE ESCAPE`, поиск `values::text ILIKE`, сортировка lower()+тай-брейкер position/id, LIMIT/OFFSET); `getDataTableRowCounts` (один GROUP BY); `moveDataTableRow` — siblings SQL-запросом. + - `server/utils/data-table-rows-query.ts` — escapeIlikePattern, капы limit (дефолт 100, макс 1000)/offset. + - `server/routes/data-tables.routes.ts` — GET /rows с limit/offset/search → SQL-путь `{rows, total}`; без параметров — прежний JS-путь (сортировка localeCompare('ru') сохранена 1:1 для текущего фронта). + - `server/mcp.ts` — list_directory_rows (flat) на SQL-пагинацию; list_directories rowCount одним запросом. +- Tree-режим сознательно не тронут: дерево строится из всех строк, пагинация неприменима. +- Как проверялось: vitest 88/88 (10 новых тестов хелперов); `npm run check` чисто. +- Влияние на поиск/UX: нет (TableEditor попадает в legacy-ветку). +- Подводные камни: `/api/directories/:id/column-values` всё ещё грузит все строки (прерывается на 20) — кандидат на следующий заход (SELECT DISTINCT values->>N LIMIT); фронт TableEditor пока не использует SQL-пагинацию (эндпоинт готов). + +## [1.2] /column-values: лимиты и чистка горячего пути + +- Статус: ✅ done +- Зачем: cap выборки, убрать console.log с горячего пути (Фаза 1). +- Что изменено: `server/routes/task-crud-list.routes.ts` — убраны 7 debug-логов на запрос + per-task лог contract-number; параметр limit (дефолт 20, кап 1000). +- Как проверялось: выборка покрыта индексом tasks_org_id_form_id_idx — новый индекс не нужен; vitest 88/88. +- Влияние на поиск/UX: нет. diff --git a/server/mcp.ts b/server/mcp.ts index a1fa2a9..7d550d1 100644 --- a/server/mcp.ts +++ b/server/mcp.ts @@ -3331,18 +3331,15 @@ To block task creation from task.before_create, set: ctx.result = { allow: false const all = await storage.getDataTablesByOrganization(organizationId); // Фильтруем выдачу по scopes.tableIds (null = все справочники) const allowed = all.filter((t) => isTableAllowed(t.id)); - const result = await Promise.all( - allowed.map(async (t) => { - const rows = await storage.getDataTableRows(t.id, organizationId); - return { - id: t.id, - name: t.name, - description: t.description, - columns: t.columns, - rowCount: rows.length, - }; - }) - ); + // Количество строк по всем справочникам — одним GROUP BY запросом (без N+1) + const rowCounts = await storage.getDataTableRowCounts(organizationId); + const result = allowed.map((t) => ({ + id: t.id, + name: t.name, + description: t.description, + columns: t.columns, + rowCount: rowCounts.get(t.id) ?? 0, + })); return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }], }; @@ -3421,55 +3418,40 @@ To block task creation from task.before_create, set: ctx.result = { allow: false const table = await storage.getDataTable(tableId, organizationId); if (!table) return directoryError(`Справочник ${tableId} не найден`); - let rows = await storage.getDataTableRows(tableId, organizationId); - + // tree-режим: дерево строится из всех строк (иерархия parent_id / treeColumns), + // поэтому здесь осознанно полная выборка — пагинация в дереве неприменима. if (mode === "tree") { + const rows = await storage.getDataTableRows(tableId, organizationId); const tree = buildDataTableTree(table, rows, { filters, search }); return { content: [{ type: "text" as const, text: JSON.stringify(tree, null, 2) }], }; } - // flat: фильтры по подстроке (includes), без учёта регистра + // flat-режим: фильтры/поиск/сортировка/пагинация на уровне SQL + let numericFilters: Record | undefined; if (filters) { - Object.entries(filters).forEach(([col, val]) => { + numericFilters = {}; + for (const [col, val] of Object.entries(filters)) { const ci = parseInt(col); - if (!isNaN(ci) && val) { - rows = rows.filter((r) => { - const v = Array.isArray(r.values) ? r.values : []; - return String(v[ci] || '').toLowerCase().includes(val.toLowerCase()); - }); - } - }); + if (!isNaN(ci) && val) numericFilters[ci] = val; + } } - // Поиск по всем значениям строки - if (search) { - const q = search.toLowerCase(); - rows = rows.filter((r) => { - const v = Array.isArray(r.values) ? r.values : []; - return v.some((val) => String(val ?? '').toLowerCase().includes(q)); - }); - } + const { rows, total } = await storage.getDataTableRowsPaged(tableId, organizationId, { + filters: numericFilters, + search, + sortColumn, + sortDirection, + limit: limit ?? 100, + offset: offset ?? 0, + }); - // Сортировка по колонке - if (sortColumn !== undefined && (sortDirection === 'asc' || sortDirection === 'desc')) { - const ci = sortColumn; - rows = [...rows].sort((a, b) => { - const av = Array.isArray(a.values) ? a.values : []; - const bv = Array.isArray(b.values) ? b.values : []; - const cmp = String(av[ci] || '').toLowerCase().localeCompare(String(bv[ci] || '').toLowerCase(), 'ru'); - return sortDirection === 'asc' ? cmp : -cmp; - }); - } - - const total = rows.length; - const paged = rows.slice(offset ?? 0, (offset ?? 0) + (limit ?? 100)); return { content: [{ type: "text" as const, text: JSON.stringify({ - rows: paged.map((r) => ({ id: r.id, parentId: r.parentId, values: r.values, position: r.position })), + rows: rows.map((r) => ({ id: r.id, parentId: r.parentId, values: r.values, position: r.position })), total, }, null, 2), }], diff --git a/server/routes/data-tables.routes.ts b/server/routes/data-tables.routes.ts index c83fd5e..d0dc4d8 100644 --- a/server/routes/data-tables.routes.ts +++ b/server/routes/data-tables.routes.ts @@ -5,6 +5,7 @@ import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth import { tenantIsolation } from "../middleware/tenant.middleware"; import { logAudit } from "../utils/audit"; import { buildDataTableTree } from "../utils/data-table-tree"; +import { clampDirectoryRowsLimit, clampDirectoryRowsOffset } from "../utils/data-table-rows-query"; import { getDirectoryFolders, createDirectoryFolder, @@ -445,6 +446,45 @@ export function registerDataTableRoutes(app: import("express").Express): void { return res.status(403).json({ error: 'Нет доступа к таблице' }); } + const sortColumn = req.query.sortColumn as string; + const sortDir = req.query.sortDirection as string; + + // SQL-путь: при limit/offset/search фильтрация, сортировка и пагинация + // выполняются на уровне БД (без полной выгрузки строк). + // Без этих параметров — прежнее поведение (все строки + JS-фильтры/сортировка), + // чтобы не менять семантику существующих клиентов. + const useSqlPaging = + req.query.limit !== undefined || + req.query.offset !== undefined || + req.query.search !== undefined; + + if (useSqlPaging) { + let filters: Record | undefined; + const filtersStr = req.query.filters as string; + if (filtersStr) { + try { + const parsed: Record = JSON.parse(filtersStr); + filters = {}; + for (const [col, val] of Object.entries(parsed)) { + const ci = parseInt(col); + if (!isNaN(ci) && val) filters[ci] = String(val); + } + } catch {} + } + + const ci = parseInt(sortColumn); + const { rows, total } = await storage.getDataTableRowsPaged(tableId, req.organizationId!, { + filters, + search: req.query.search ? String(req.query.search) : undefined, + sortColumn: !isNaN(ci) ? ci : undefined, + sortDirection: sortDir === 'asc' || sortDir === 'desc' ? sortDir : undefined, + limit: clampDirectoryRowsLimit(req.query.limit), + offset: clampDirectoryRowsOffset(req.query.offset), + }); + + return res.json({ rows, total }); + } + let rows = await storage.getDataTableRows(tableId, req.organizationId!); const filtersStr = req.query.filters as string; @@ -463,8 +503,6 @@ export function registerDataTableRoutes(app: import("express").Express): void { } catch {} } - const sortColumn = req.query.sortColumn as string; - const sortDir = req.query.sortDirection as string; if (sortColumn !== undefined && (sortDir === 'asc' || sortDir === 'desc')) { const ci = parseInt(sortColumn); if (!isNaN(ci)) { diff --git a/server/routes/task-crud-list.routes.ts b/server/routes/task-crud-list.routes.ts index 1a1e6d8..f52b896 100644 --- a/server/routes/task-crud-list.routes.ts +++ b/server/routes/task-crud-list.routes.ts @@ -7,6 +7,11 @@ import { db } from "../db"; import { eq, inArray } from "drizzle-orm"; import { tasksMinimalCache } from "../utils/cache"; import { clampTasksLimit, parseTasksCursor, buildTasksNextCursor } from "../utils/task-pagination"; +import { clampDirectoryRowsLimit } from "../utils/data-table-rows-query"; + +// Лимиты выдачи уникальных значений в /column-values (автокомплит фильтров) +const COLUMN_VALUES_DEFAULT_LIMIT = 20; +const COLUMN_VALUES_MAX_LIMIT = 1000; function safeParseOptions(opts: unknown): unknown { if (Array.isArray(opts)) return opts; @@ -478,9 +483,6 @@ export function registerTaskCrudListRoutes(router: ReturnType f.type === 'contract-number' && (f as any).options?.contractNumber?.legalEntityFormId); - console.log('[column-values] contractNumberFields count:', contractNumberFields.length, 'formId:', formId); - for (const f of contractNumberFields) { - console.log('[column-values] contract-number field:', f.id, f.name, 'options:', JSON.stringify(f.options)); - } const legalEntityMap = new Map(); for (const f of contractNumberFields) { const leFormId = (f as any).options.contractNumber.legalEntityFormId as number; - console.log('[column-values] loading legal entities for formId:', leFormId, 'org:', req.organizationId); try { const leTasks = await storage.getTasksWithFieldsByForm(leFormId, req.organizationId!); - console.log('[column-values] loaded legal entities:', leTasks.length); for (const t of leTasks) { legalEntityMap.set(t.id, t.title); } @@ -658,7 +654,6 @@ export function registerTaskCrudListRoutes(router: ReturnType>(); @@ -721,21 +716,8 @@ export function registerTaskCrudListRoutes(router: ReturnType [s.id, s])); const userMap = new Map(users.map((u: SafeUser) => [u.id, u])); - // Отладка: логируем select/radio-group/table поля и их options - if (column.startsWith('field_')) { - const dbgFieldId = parseInt(column.replace('field_', '')); - const dbgField = formFields.find(f => f.id === dbgFieldId); - if (dbgField && (dbgField.type === 'select' || dbgField.type === 'radio-group' || dbgField.type === 'table')) { - console.log('[column-values] field:', dbgField.id, 'type:', dbgField.type, 'options:', JSON.stringify(dbgField.options), 'optionsType:', typeof dbgField.options); - // Логируем первую задачу с ненулевым значением - const firstTask = taskList.find(t => t.fieldValues && t.fieldValues[dbgField.id] !== undefined && t.fieldValues[dbgField.id] !== null); - if (firstTask) { - const rawV = firstTask.fieldValues![dbgField.id]; - const resolved = getTaskColumnValue(firstTask, column, statusMap, userMap, formFields, roleMap, tableRowMap, legalEntityMap, taskTitleMap); - console.log('[column-values] firstTaskId:', firstTask.id, 'rawV:', JSON.stringify(rawV), 'rawVType:', typeof rawV, 'resolved:', resolved); - } - } - } + // Кап на количество уникальных значений: параметр limit (дефолт 20, максимум 1000) + const limit = clampDirectoryRowsLimit(req.query.limit, COLUMN_VALUES_DEFAULT_LIMIT, COLUMN_VALUES_MAX_LIMIT); const seen = new Set(); const values: string[] = []; @@ -746,7 +728,7 @@ export function registerTaskCrudListRoutes(router: ReturnType= 20) break; + if (values.length >= limit) break; } res.json({ values }); diff --git a/server/storage.ts b/server/storage.ts index 050edb0..b5e3801 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -18,6 +18,7 @@ import { eq, and, or, desc, asc, exists, isNull, isNotNull, sql, ilike, inArray, import { alias } from "drizzle-orm/pg-core"; import crypto from "crypto"; import { formatUserName } from "./utils/formatUserName"; +import type { DirectoryRowsPageOptions } from "./storage/data-tables-core.storage"; export interface RelatedTreeNode { id: number; @@ -292,6 +293,8 @@ export interface IStorage { // Data Table Rows getDataTableRows(tableId: number, organizationId: number): Promise; + getDataTableRowsPaged(tableId: number, organizationId: number, opts?: DirectoryRowsPageOptions): Promise<{ rows: DataTableRow[]; total: number }>; + getDataTableRowCounts(organizationId: number): Promise>; getDataTableRow(id: number, tableId: number, organizationId: number): Promise; createDataTableRow(row: InsertDataTableRow): Promise; updateDataTableRow(id: number, tableId: number, organizationId: number, updates: Partial): Promise; diff --git a/server/storage/data-tables-core.storage.ts b/server/storage/data-tables-core.storage.ts index acdae38..ff266ef 100644 --- a/server/storage/data-tables-core.storage.ts +++ b/server/storage/data-tables-core.storage.ts @@ -1,7 +1,18 @@ import { users, dataTables, dataTableRows, dataTablePermissions, dataTableAccessRules, dataTableLinks, formFields, forms, formAccessRules, roleMembers, type DataTable, type DataTableRow, type DataTablePermission, type InsertDataTable, type InsertDataTableRow, type InsertDataTablePermission, type DataTableFull, type DataTableAccessRule, type InsertDataTableAccessRule, type DataTableLink, type DataTableSubdirectory } from "@shared/schema"; import { db } from "../db"; -import { eq, and, desc, sql, inArray } from "drizzle-orm"; +import { eq, and, or, desc, sql, inArray, isNull, ne } from "drizzle-orm"; import { BotsStorage } from "./bots.storage"; +import { escapeIlikePattern } from "../utils/data-table-rows-query"; + +// Параметры SQL-пагинации строк справочника (см. getDataTableRowsPaged) +export interface DirectoryRowsPageOptions { + filters?: Record; + search?: string; + sortColumn?: number; + sortDirection?: 'asc' | 'desc'; + limit?: number; + offset?: number; +} export class DataTablesCoreStorage extends BotsStorage { async getDataTable(id: number, organizationId: number): Promise { const [table] = await db @@ -27,33 +38,26 @@ export class DataTablesCoreStorage extends BotsStorage { } async getDataTablesWithAccess(userId: number, organizationId: number): Promise { - const allTables = await db + // Права пользователя собираем одним запросом, чтобы не делать N+1 по каждой таблице + const permissions = await db + .select({ tableId: dataTablePermissions.tableId }) + .from(dataTablePermissions) + .where(eq(dataTablePermissions.userId, userId)); + const permittedIds = permissions.map((p) => p.tableId); + + const accessCondition = permittedIds.length > 0 + ? or(eq(dataTables.createdBy, userId), inArray(dataTables.id, permittedIds)) + : eq(dataTables.createdBy, userId); + + return await db .select() .from(dataTables) .where(and( eq(dataTables.organizationId, organizationId), - eq(dataTables.isDeleted, false) + eq(dataTables.isDeleted, false), + accessCondition )) .orderBy(desc(dataTables.createdAt)); - - const tablesWithAccess: DataTable[] = []; - for (const table of allTables) { - if (table.createdBy === userId) { - tablesWithAccess.push(table); - } else { - const [permission] = await db - .select() - .from(dataTablePermissions) - .where(and( - eq(dataTablePermissions.tableId, table.id), - eq(dataTablePermissions.userId, userId) - )); - if (permission) { - tablesWithAccess.push(table); - } - } - } - return tablesWithAccess; } // ===================================================== @@ -160,6 +164,100 @@ export class DataTablesCoreStorage extends BotsStorage { .orderBy(dataTableRows.position, dataTableRows.id); } + /** + * SQL-пагинация строк справочника: фильтры/поиск/сортировка/лимит выполняются + * на уровне БД, без выгрузки всех строк в память. + * Фильтры и поиск — подстрочное совпадение без учёта регистра (ILIKE), + * семантика совпадает с прежней JS-фильтрацией (String(...).toLowerCase().includes). + * Сортировка — текстовая по lower(values->>N), пустые значения идут первыми (как '' в JS). + */ + async getDataTableRowsPaged( + tableId: number, + organizationId: number, + opts: DirectoryRowsPageOptions = {} + ): Promise<{ rows: DataTableRow[]; total: number }> { + const [table] = await db + .select() + .from(dataTables) + .where(and( + eq(dataTables.id, tableId), + eq(dataTables.organizationId, organizationId) + )); + if (!table) return { rows: [], total: 0 }; + + const conditions = [ + eq(dataTableRows.tableId, tableId), + eq(dataTableRows.isDeleted, false), + ]; + + if (opts.filters) { + for (const [colKey, val] of Object.entries(opts.filters)) { + const ci = Number(colKey); + if (!Number.isInteger(ci) || ci < 0 || !val) continue; + conditions.push( + sql`${dataTableRows.values}->>${ci} ILIKE ${'%' + escapeIlikePattern(val) + '%'} ESCAPE '\\'` + ); + } + } + + if (opts.search) { + conditions.push( + sql`${dataTableRows.values}::text ILIKE ${'%' + escapeIlikePattern(opts.search) + '%'} ESCAPE '\\'` + ); + } + + const where = and(...conditions); + + const [countRow] = await db + .select({ count: sql`count(*)::int` }) + .from(dataTableRows) + .where(where); + const total = Number(countRow?.count ?? 0); + + const query = db.select().from(dataTableRows).where(where); + + if (opts.sortColumn !== undefined && (opts.sortDirection === 'asc' || opts.sortDirection === 'desc')) { + const ci = opts.sortColumn; + const sortExpr = opts.sortDirection === 'desc' + ? sql`lower(coalesce(${dataTableRows.values}->>${ci}, '')) DESC` + : sql`lower(coalesce(${dataTableRows.values}->>${ci}, '')) ASC`; + // Вторичная сортировка по position/id — стабильный порядок при равных значениях + query.orderBy(sortExpr, dataTableRows.position, dataTableRows.id); + } else { + query.orderBy(dataTableRows.position, dataTableRows.id); + } + + if (opts.limit !== undefined && opts.limit > 0) { + query.limit(opts.limit); + } + if (opts.offset !== undefined && opts.offset > 0) { + query.offset(opts.offset); + } + + const rows = await query; + return { rows, total }; + } + + /** + * Количество неудалённых строк по каждому справочнику организации одним запросом + * (используется MCP list_directories вместо N+1 выборок строк). + */ + async getDataTableRowCounts(organizationId: number): Promise> { + const result = await db + .select({ + tableId: dataTableRows.tableId, + count: sql`count(*)::int`, + }) + .from(dataTableRows) + .innerJoin(dataTables, eq(dataTableRows.tableId, dataTables.id)) + .where(and( + eq(dataTables.organizationId, organizationId), + eq(dataTableRows.isDeleted, false) + )) + .groupBy(dataTableRows.tableId); + return new Map(result.map((r) => [r.tableId, Number(r.count)])); + } + async getDataTableRow(id: number, tableId: number, organizationId: number): Promise { const [table] = await db .select() @@ -288,10 +386,19 @@ export class DataTablesCoreStorage extends BotsStorage { } } - const rows = await this.getDataTableRows(tableId, organizationId); - const siblings = rows - .filter((r) => r.id !== id && (r.parentId ?? null) === targetParentId) - .sort((a, b) => a.position - b.position); + // Только siblings нужного родителя — без полной выборки строк таблицы + const siblings = await db + .select() + .from(dataTableRows) + .where(and( + eq(dataTableRows.tableId, tableId), + eq(dataTableRows.isDeleted, false), + ne(dataTableRows.id, id), + targetParentId === null + ? isNull(dataTableRows.parentId) + : eq(dataTableRows.parentId, targetParentId) + )) + .orderBy(dataTableRows.position, dataTableRows.id); const clampedPosition = Math.max(0, Math.min(position, siblings.length)); const reordered = [ diff --git a/server/utils/data-table-rows-query.ts b/server/utils/data-table-rows-query.ts new file mode 100644 index 0000000..24d0e33 --- /dev/null +++ b/server/utils/data-table-rows-query.ts @@ -0,0 +1,39 @@ +// Хелперы для SQL-пагинации строк справочников (data_table_rows). +// Чистые функции без доступа к БД — покрыты юнит-тестами. + +/** Дефолтный лимит строк на страницу */ +export const DIRECTORY_ROWS_DEFAULT_LIMIT = 100; +/** Максимальный лимит строк на страницу */ +export const DIRECTORY_ROWS_MAX_LIMIT = 1000; + +/** + * Экранирует спецсимволы LIKE/ILIKE (\, %, _) в пользовательской подстроке, + * чтобы поиск работал как «содержит подстроку», а не как паттерн. + * Используется вместе с `ESCAPE '\'` в SQL. + */ +export function escapeIlikePattern(value: string): string { + return value.replace(/[\\%_]/g, (ch) => '\\' + ch); +} + +/** + * Приводит лимит к допустимому диапазону. + * Невалидные/неположительные значения → defaultLimit, сверху капается maxLimit. + */ +export function clampDirectoryRowsLimit( + raw: unknown, + defaultLimit: number = DIRECTORY_ROWS_DEFAULT_LIMIT, + maxLimit: number = DIRECTORY_ROWS_MAX_LIMIT, +): number { + const n = typeof raw === 'number' ? raw : parseInt(String(raw ?? ''), 10); + if (!Number.isFinite(n) || n <= 0) return defaultLimit; + return Math.min(Math.floor(n), maxLimit); +} + +/** + * Приводит offset к неотрицательному целому. Невалидное → 0. + */ +export function clampDirectoryRowsOffset(raw: unknown): number { + const n = typeof raw === 'number' ? raw : parseInt(String(raw ?? ''), 10); + if (!Number.isFinite(n) || n < 0) return 0; + return Math.floor(n); +} diff --git a/tests/data-table-rows-query.test.ts b/tests/data-table-rows-query.test.ts new file mode 100644 index 0000000..a498dee --- /dev/null +++ b/tests/data-table-rows-query.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; +import { + escapeIlikePattern, + clampDirectoryRowsLimit, + clampDirectoryRowsOffset, + DIRECTORY_ROWS_DEFAULT_LIMIT, + DIRECTORY_ROWS_MAX_LIMIT, +} from '../server/utils/data-table-rows-query'; + +describe('escapeIlikePattern', () => { + it('обычная строка не меняется', () => { + expect(escapeIlikePattern('Казань')).toBe('Казань'); + expect(escapeIlikePattern('abc 123')).toBe('abc 123'); + }); + + it('экранирует %, _ и обратный слэш', () => { + expect(escapeIlikePattern('100%')).toBe('100\\%'); + expect(escapeIlikePattern('a_b')).toBe('a\\_b'); + expect(escapeIlikePattern('a\\b')).toBe('a\\\\b'); + }); + + it('экранирует все вхождения', () => { + expect(escapeIlikePattern('%_%\\')).toBe('\\%\\_\\%\\\\'); + }); +}); + +describe('clampDirectoryRowsLimit', () => { + it('без значения — дефолт 100', () => { + expect(clampDirectoryRowsLimit(undefined)).toBe(DIRECTORY_ROWS_DEFAULT_LIMIT); + expect(clampDirectoryRowsLimit(null)).toBe(100); + expect(clampDirectoryRowsLimit('')).toBe(100); + }); + + it('неположительные и невалидные значения — дефолт', () => { + expect(clampDirectoryRowsLimit(0)).toBe(100); + expect(clampDirectoryRowsLimit(-10)).toBe(100); + expect(clampDirectoryRowsLimit('abc')).toBe(100); + expect(clampDirectoryRowsLimit(NaN)).toBe(100); + }); + + it('принимает числа и числовые строки', () => { + expect(clampDirectoryRowsLimit(50)).toBe(50); + expect(clampDirectoryRowsLimit('25')).toBe(25); + expect(clampDirectoryRowsLimit(1)).toBe(1); + }); + + it('капает сверху на 1000', () => { + expect(clampDirectoryRowsLimit(1001)).toBe(DIRECTORY_ROWS_MAX_LIMIT); + expect(clampDirectoryRowsLimit('5000')).toBe(1000); + }); + + it('поддерживает кастомные дефолт и максимум', () => { + expect(clampDirectoryRowsLimit(undefined, 20, 500)).toBe(20); + expect(clampDirectoryRowsLimit(700, 20, 500)).toBe(500); + expect(clampDirectoryRowsLimit(30, 20, 500)).toBe(30); + }); +}); + +describe('clampDirectoryRowsOffset', () => { + it('без значения и невалидные — 0', () => { + expect(clampDirectoryRowsOffset(undefined)).toBe(0); + expect(clampDirectoryRowsOffset(null)).toBe(0); + expect(clampDirectoryRowsOffset('')).toBe(0); + expect(clampDirectoryRowsOffset(-5)).toBe(0); + expect(clampDirectoryRowsOffset('xyz')).toBe(0); + }); + + it('неотрицательные значения проходят, дробные — вниз', () => { + expect(clampDirectoryRowsOffset(0)).toBe(0); + expect(clampDirectoryRowsOffset(200)).toBe(200); + expect(clampDirectoryRowsOffset('40')).toBe(40); + expect(clampDirectoryRowsOffset(12.9)).toBe(12); + }); +});