test: свод 2 — JS-оракул поиска, фильтров и вариантов экрана «до» и векторы

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-28 21:09:29 +04:00
parent dbf90477d2
commit adcb9d1d3b
4 changed files with 1257 additions and 0 deletions

View File

@ -0,0 +1,86 @@
[
{
"input": "0",
"js": "0"
},
{
"input": "-0",
"js": "0"
},
{
"input": "1",
"js": "1"
},
{
"input": "-1",
"js": "-1"
},
{
"input": "100",
"js": "100"
},
{
"input": "100.00",
"js": "100"
},
{
"input": "1234.50",
"js": "1234.5"
},
{
"input": "12.34",
"js": "12.34"
},
{
"input": "0.5",
"js": "0.5"
},
{
"input": "0.30000000000000004",
"js": "0.30000000000000004"
},
{
"input": "0.000001",
"js": "0.000001"
},
{
"input": "1e-7",
"js": "1e-7"
},
{
"input": "-1.5e-10",
"js": "-1.5e-10"
},
{
"input": "123e-20",
"js": "1.23e-18"
},
{
"input": "1e15",
"js": "1000000000000000"
},
{
"input": "1e20",
"js": "100000000000000000000"
},
{
"input": "1e21",
"js": "1e+21"
},
{
"input": "123456789012345680000",
"js": "123456789012345680000"
},
{
"input": "9007199254740993",
"js": "9007199254740992"
},
{
"input": "5e-324",
"js": "5e-324"
},
{
"input": "1.7976931348623157e308",
"js": "1.7976931348623157e+308"
}
]

View File

@ -0,0 +1,962 @@
{
"rows": [
{
"data": {
"line_id": 1,
"header": {
"name": "Аренда помещения",
"smeta_type": "КВ",
"smeta_direction": "Развитие",
"org_name": "Филиал Ёлки"
},
"contract_summary": {
"counterparty": "ООО Ромашка"
},
"contract_detail": {
"counterparty": null,
"ceiling_amount": 1234.5
}
}
},
{
"data": {
"line_id": 2,
"header": {
"name": "АРЕНДА склада",
"smeta_type": "АХР",
"smeta_direction": "Поддержка",
"org_name": "Филиал Ёлки"
},
"contract_summary": {},
"contract_detail": {
"counterparty": "ООО Ромашка Плюс",
"ceiling_amount": 100
}
}
},
{
"data": {
"line_id": 3,
"header": {
"name": "Ёлка новогодняя",
"smeta_type": "КВ",
"smeta_direction": null,
"org_name": "филиал ёлки "
},
"contract_summary": {
"counterparty": "ИП Ёж"
},
"contract_detail": {
"ceiling_amount": 0
}
}
},
{
"data": {
"line_id": 4,
"header": {
"name": "ёжик",
"smeta_type": "кв ",
"smeta_direction": "Развитие",
"org_name": "Центр"
},
"contract_summary": {},
"contract_detail": {
"ceiling_amount": 1e-7
}
}
},
{
"data": {
"line_id": 5,
"header": {
"name": "Услуги связи",
"smeta_type": "КВ",
"smeta_direction": "Поддержка",
"org_name": "Центр"
},
"contract_summary": {},
"contract_detail": {
"note": "a b"
}
}
},
{
"data": {
"line_id": 6,
"header": {
"name": "Без маркера",
"smeta_type": "АХР",
"smeta_direction": "Развитие",
"org_name": "Север"
},
"contract_summary": {},
"contract_detail": {
"note": "xy"
}
}
},
{
"data": {
"line_id": 7,
"header": {
"name": "Флаг",
"smeta_type": "КВ",
"smeta_direction": "Развитие",
"org_name": "Север",
"is_active": true
},
"contract_summary": {
"flag": true
},
"contract_detail": {
"note": null
}
}
},
{
"data": {
"line_id": 8,
"header": {
"name": null,
"smeta_type": null,
"smeta_direction": null,
"org_name": null
},
"contract_summary": {},
"contract_detail": {}
}
},
{
"data": {
"line_id": 9,
"header": {
"name": "Итого 1",
"smeta_type": "АХР",
"smeta_direction": "Поддержка",
"org_name": "Центр"
},
"contract_summary": {
"counterparty": "ООО Ромашка"
},
"contract_detail": {
"counterparty": "ООО Ромашка",
"ceiling_amount": 1234.5
}
}
},
{
"data": {
"line_id": 10,
"header": {
"name": "Прочее",
"smeta_type": "ПР",
"smeta_direction": "Развитие",
"org_name": "Филиал Ёлки"
},
"contract_summary": {},
"contract_detail": {
"ceiling_amount": 1e-7,
"note": "Договор №1"
}
}
},
{
"data": {
"line_id": 11,
"header": {
"name": "Лицензии",
"smeta_type": "КВ",
"smeta_direction": "Поддержка",
"org_name": "Юг"
},
"contract_summary": {},
"contract_detail": {
"ceiling_amount": 100
}
}
}
],
"cases": [
{
"search": "",
"filters": {},
"line_ids": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11
],
"found": {
"found": 11,
"total": 11
},
"facets": {
"total": 11,
"search_total": 11,
"smeta_type": [
[
"АХР",
3
],
[
"КВ",
5
],
[
"кв ",
1
],
[
"ПР",
1
]
],
"smeta_direction": [
[
"Поддержка",
4
],
[
"Развитие",
5
]
],
"org_name": [
[
"Север",
2
],
[
"Филиал Ёлки",
3
],
[
"филиал ёлки ",
1
],
[
"Центр",
3
],
[
"Юг",
1
]
]
}
},
{
"search": "аРЕНДА",
"filters": {},
"line_ids": [
1,
2
],
"found": {
"found": 2,
"total": 11
},
"facets": {
"total": 11,
"search_total": 2,
"smeta_type": [
[
"АХР",
1
],
[
"КВ",
1
]
],
"smeta_direction": [
[
"Поддержка",
1
],
[
"Развитие",
1
]
],
"org_name": [
[
"Филиал Ёлки",
2
]
]
}
},
{
"search": "ё",
"filters": {},
"line_ids": [
1,
2,
3,
4,
10
],
"found": {
"found": 5,
"total": 11
},
"facets": {
"total": 11,
"search_total": 5,
"smeta_type": [
[
"АХР",
1
],
[
"КВ",
2
],
[
"кв ",
1
],
[
"ПР",
1
]
],
"smeta_direction": [
[
"Поддержка",
1
],
[
"Развитие",
3
]
],
"org_name": [
[
"Филиал Ёлки",
3
],
[
"филиал ёлки ",
1
],
[
"Центр",
1
]
]
}
},
{
"search": "ромаш",
"filters": {},
"line_ids": [
2,
9
],
"found": {
"found": 2,
"total": 11
},
"facets": {
"total": 11,
"search_total": 2,
"smeta_type": [
[
"АХР",
2
]
],
"smeta_direction": [
[
"Поддержка",
2
]
],
"org_name": [
[
"Филиал Ёлки",
1
],
[
"Центр",
1
]
]
}
},
{
"search": "1234.5",
"filters": {},
"line_ids": [
1,
9
],
"found": {
"found": 2,
"total": 11
},
"facets": {
"total": 11,
"search_total": 2,
"smeta_type": [
[
"АХР",
1
],
[
"КВ",
1
]
],
"smeta_direction": [
[
"Поддержка",
1
],
[
"Развитие",
1
]
],
"org_name": [
[
"Филиал Ёлки",
1
],
[
"Центр",
1
]
]
}
},
{
"search": "1234.50",
"filters": {},
"line_ids": [],
"found": {
"found": 0,
"total": 11
},
"facets": {
"total": 11,
"search_total": 0,
"smeta_type": [],
"smeta_direction": [],
"org_name": []
}
},
{
"search": " аренда ",
"filters": {},
"line_ids": [
1,
2
],
"found": {
"found": 2,
"total": 11
},
"facets": {
"total": 11,
"search_total": 2,
"smeta_type": [
[
"АХР",
1
],
[
"КВ",
1
]
],
"smeta_direction": [
[
"Поддержка",
1
],
[
"Развитие",
1
]
],
"org_name": [
[
"Филиал Ёлки",
2
]
]
}
},
{
"search": " ",
"filters": {},
"line_ids": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11
],
"found": {
"found": 11,
"total": 11
},
"facets": {
"total": 11,
"search_total": 11,
"smeta_type": [
[
"АХР",
3
],
[
"КВ",
5
],
[
"кв ",
1
],
[
"ПР",
1
]
],
"smeta_direction": [
[
"Поддержка",
4
],
[
"Развитие",
5
]
],
"org_name": [
[
"Север",
2
],
[
"Филиал Ёлки",
3
],
[
"филиал ёлки ",
1
],
[
"Центр",
3
],
[
"Юг",
1
]
]
}
},
{
"search": "1",
"filters": {},
"line_ids": [
1,
2,
4,
9,
10,
11
],
"found": {
"found": 6,
"total": 11
},
"facets": {
"total": 11,
"search_total": 6,
"smeta_type": [
[
"АХР",
2
],
[
"КВ",
2
],
[
"кв ",
1
],
[
"ПР",
1
]
],
"smeta_direction": [
[
"Поддержка",
3
],
[
"Развитие",
3
]
],
"org_name": [
[
"Филиал Ёлки",
3
],
[
"Центр",
2
],
[
"Юг",
1
]
]
}
},
{
"search": "",
"filters": {
"smeta_type": "кв "
},
"line_ids": [
1,
3,
4,
5,
7,
11
],
"found": {
"found": 11,
"total": 11
},
"facets": {
"total": 11,
"search_total": 11,
"smeta_type": [
[
"АХР",
3
],
[
"КВ",
5
],
[
"кв ",
1
],
[
"ПР",
1
]
],
"smeta_direction": [
[
"Поддержка",
2
],
[
"Развитие",
3
]
],
"org_name": [
[
"Север",
1
],
[
"Филиал Ёлки",
1
],
[
"филиал ёлки ",
1
],
[
"Центр",
2
],
[
"Юг",
1
]
]
}
},
{
"search": "",
"filters": {
"org_name": "Филиал Ёлки"
},
"line_ids": [
1,
2,
3,
10
],
"found": {
"found": 11,
"total": 11
},
"facets": {
"total": 11,
"search_total": 11,
"smeta_type": [
[
"АХР",
1
],
[
"КВ",
2
],
[
"ПР",
1
]
],
"smeta_direction": [
[
"Поддержка",
1
],
[
"Развитие",
2
]
],
"org_name": [
[
"Север",
2
],
[
"Филиал Ёлки",
3
],
[
"филиал ёлки ",
1
],
[
"Центр",
3
],
[
"Юг",
1
]
]
}
},
{
"search": "",
"filters": {
"smeta_type": "КВ",
"smeta_direction": "Развитие"
},
"line_ids": [
1,
4,
7
],
"found": {
"found": 11,
"total": 11
},
"facets": {
"total": 11,
"search_total": 11,
"smeta_type": [
[
"АХР",
1
],
[
"КВ",
2
],
[
"кв ",
1
],
[
"ПР",
1
]
],
"smeta_direction": [
[
"Поддержка",
2
],
[
"Развитие",
3
]
],
"org_name": [
[
"Север",
1
],
[
"Филиал Ёлки",
1
],
[
"Центр",
1
]
]
}
},
{
"search": "ё",
"filters": {
"smeta_type": "КВ"
},
"line_ids": [
1,
3,
4
],
"found": {
"found": 5,
"total": 11
},
"facets": {
"total": 11,
"search_total": 5,
"smeta_type": [
[
"АХР",
1
],
[
"КВ",
2
],
[
"кв ",
1
],
[
"ПР",
1
]
],
"smeta_direction": [
[
"Развитие",
2
]
],
"org_name": [
[
"Филиал Ёлки",
1
],
[
"филиал ёлки ",
1
],
[
"Центр",
1
]
]
}
},
{
"search": "",
"filters": {
"org_name": "филиал ёлки"
},
"line_ids": [
1,
2,
3,
10
],
"found": {
"found": 11,
"total": 11
},
"facets": {
"total": 11,
"search_total": 11,
"smeta_type": [
[
"АХР",
1
],
[
"КВ",
2
],
[
"ПР",
1
]
],
"smeta_direction": [
[
"Поддержка",
1
],
[
"Развитие",
2
]
],
"org_name": [
[
"Север",
2
],
[
"Филиал Ёлки",
3
],
[
"филиал ёлки ",
1
],
[
"Центр",
3
],
[
"Юг",
1
]
]
}
}
]
}

View File

@ -0,0 +1,149 @@
// Оракул экрана «до» свода 2 (Svod2Page на коммите 399234a + material-react-table). Копии дословные.
// matchesSearch: Svod2Page.jsx:283-294; equals: material-react-table/dist/index.esm.js:663-667.
import { mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const matchesSearch = (row, query) => {
const searchableData = {
...row.data?.header,
...row.data?.contract_summary,
...row.data?.contract_detail,
};
return Object.values(searchableData).some((value) =>
String(value ?? '')
.toLowerCase()
.includes(query),
);
};
const equals = (row, id, filterValue) => {
var _a;
return ((_a = row.getValue(id)) === null || _a === void 0 ? void 0 : _a.toString().toLowerCase().trim()) ===
filterValue.toString().toLowerCase().trim();
};
equals.autoRemove = (val) => !val;
export { matchesSearch, equals };
export const FILTER_COLUMNS = ['smeta_type', 'smeta_direction', 'org_name'];
// accessorFn колонок: valueAt(row, ['header', id])
const asTableRow = (row, id) => ({ getValue: () => row.data?.header?.[id] });
const cellValue = (row, id) => row.data?.header?.[id];
const activeFilters = (filters, skipId) =>
FILTER_COLUMNS.filter((id) => id !== skipId && filters[id] && !equals.autoRemove(filters[id]));
const bySearch = (rows, search) => {
const normalizedSearch = String(search ?? '').trim().toLowerCase();
return normalizedSearch ? rows.filter((row) => matchesSearch(row, normalizedSearch)) : rows;
};
const byFilters = (rows, filters, skipId) => {
const ids = activeFilters(filters, skipId);
return rows.filter((row) => ids.every((id) => equals({ getValue: () => cellValue(row, id) }, id, filters[id])));
};
export function screenRows(rows, { search = '', filters = {} } = {}) {
return byFilters(bySearch(rows, search), filters, null);
}
export function screenFound(rows, search) {
return { found: bySearch(rows, search).length, total: rows.length };
}
export function screenFacets(rows, { search = '', filters = {} } = {}) {
const searched = bySearch(rows, search);
const result = { total: rows.length, search_total: searched.length };
for (const id of FILTER_COLUMNS) {
const counts = new Map();
for (const row of byFilters(searched, filters, id)) {
const value = asTableRow(row, id).getValue(id);
counts.set(value, (counts.get(value) ?? 0) + 1);
}
result[id] = Array.from(counts.keys())
.filter((value) => value !== null && value !== undefined)
.sort((a, b) => a.localeCompare(b))
.map((value) => [value, counts.get(value)]);
}
return result;
}
export const NUMBER_VECTOR_INPUTS = [
'0', '-0', '1', '-1', '100', '100.00', '1234.50', '12.34', '0.5', '0.30000000000000004',
'0.000001', '1e-7', '-1.5e-10', '123e-20', '1e15', '1e20', '1e21', '123456789012345680000',
'9007199254740993', '5e-324', '1.7976931348623157e308',
];
// Числа в строках берём через JSON.parse, чтобы запись была как у JSON-ответа API.
const J = (text) => JSON.parse(text);
export const SYNTHETIC_ROWS = [
{ data: { line_id: 1, header: { name: 'Аренда помещения', smeta_type: 'КВ', smeta_direction: 'Развитие', org_name: 'Филиал Ёлки' },
contract_summary: { counterparty: 'ООО Ромашка' }, contract_detail: { counterparty: null, ceiling_amount: J('1234.50') } } },
{ data: { line_id: 2, header: { name: 'АРЕНДА склада', smeta_type: 'АХР', smeta_direction: 'Поддержка', org_name: 'Филиал Ёлки' },
contract_summary: {}, contract_detail: { counterparty: 'ООО Ромашка Плюс', ceiling_amount: J('100') } } },
{ data: { line_id: 3, header: { name: 'Ёлка новогодняя', smeta_type: 'КВ', smeta_direction: null, org_name: 'филиал ёлки ' },
contract_summary: { counterparty: 'ИП Ёж' }, contract_detail: { ceiling_amount: J('0') } } },
{ data: { line_id: 4, header: { name: 'ёжик', smeta_type: 'кв ', smeta_direction: 'Развитие', org_name: 'Центр' },
contract_summary: {}, contract_detail: { ceiling_amount: J('1e-7') } } },
{ data: { line_id: 5, header: { name: 'Услуги связи', smeta_type: 'КВ', smeta_direction: 'Поддержка', org_name: 'Центр' },
contract_summary: {}, contract_detail: { note: 'a b' } } },
{ data: { line_id: 6, header: { name: 'Без маркера', smeta_type: 'АХР', smeta_direction: 'Развитие', org_name: 'Север' },
contract_summary: {}, contract_detail: { note: 'xy' } } },
{ data: { line_id: 7, header: { name: 'Флаг', smeta_type: 'КВ', smeta_direction: 'Развитие', org_name: 'Север', is_active: true },
contract_summary: { flag: true }, contract_detail: { note: null } } },
{ data: { line_id: 8, header: { name: null, smeta_type: null, smeta_direction: null, org_name: null },
contract_summary: {}, contract_detail: {} } },
{ data: { line_id: 9, header: { name: 'Итого 1', smeta_type: 'АХР', smeta_direction: 'Поддержка', org_name: 'Центр' },
contract_summary: { counterparty: 'ООО Ромашка' }, contract_detail: { counterparty: 'ООО Ромашка', ceiling_amount: J('1234.5') } } },
{ data: { line_id: 10, header: { name: 'Прочее', smeta_type: 'ПР', smeta_direction: 'Развитие', org_name: 'Филиал Ёлки' },
contract_summary: {}, contract_detail: { ceiling_amount: J('1e-7'), note: 'Договор №1' } } },
{ data: { line_id: 11, header: { name: 'Лицензии', smeta_type: 'КВ', smeta_direction: 'Поддержка', org_name: 'Юг' },
contract_summary: {}, contract_detail: { ceiling_amount: J('100.00') } } },
];
export const SCREEN_CASES = [
{ search: '', filters: {} },
{ search: 'аРЕНДА', filters: {} },
{ search: 'ё', filters: {} },
{ search: 'ромаш', filters: {} },
{ search: '1234.5', filters: {} },
{ search: '1234.50', filters: {} },
{ search: ' аренда ', filters: {} },
{ search: ' ', filters: {} },
{ search: '1', filters: {} },
{ search: '', filters: { smeta_type: 'кв ' } },
{ search: '', filters: { org_name: 'Филиал Ёлки' } },
{ search: '', filters: { smeta_type: 'КВ', smeta_direction: 'Развитие' } },
{ search: 'ё', filters: { smeta_type: 'КВ' } },
{ search: '', filters: { org_name: 'филиал ёлки' } },
];
export function emitNumberVectors() {
return NUMBER_VECTOR_INPUTS.map((input) => ({ input, js: String(JSON.parse(input)) }));
}
export function emitScreenVectors() {
return {
rows: SYNTHETIC_ROWS,
cases: SCREEN_CASES.map(({ search, filters }) => ({
search,
filters,
line_ids: screenRows(SYNTHETIC_ROWS, { search, filters }).map((r) => r.data.line_id),
found: screenFound(SYNTHETIC_ROWS, search),
facets: screenFacets(SYNTHETIC_ROWS, { search, filters }),
})),
};
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const i = process.argv.indexOf('--emit-vectors');
if (i > 0 && process.argv[i + 1]) {
const dir = process.argv[i + 1];
mkdirSync(dir, { recursive: true });
writeFileSync(path.join(dir, 'js_number_text_vectors.json'), `${JSON.stringify(emitNumberVectors(), null, 2)}\n`);
writeFileSync(path.join(dir, 'svod2_screen_vectors.json'), `${JSON.stringify(emitScreenVectors(), null, 2)}\n`);
}
}

View File

@ -0,0 +1,60 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
emitNumberVectors,
emitScreenVectors,
equals,
matchesSearch,
screenFacets,
screenFound,
screenRows,
} from './svod2_search_oracle.mjs';
const mk = (id, type, dir, org) => ({ data: { line_id: id, header: { smeta_type: type, smeta_direction: dir, org_name: org } } });
const rows2 = [mk(1, 'КВ', 'Развитие', 'А'), mk(2, 'АХР', 'Поддержка', 'Б')];
const rows3 = [mk(1, 'КВ', 'Р', 'Филиал Ёлки'), mk(2, 'КВ', 'Р', 'Филиал Ёлки'), mk(3, 'КВ', 'Р', null)];
test('перекрытое поле contract_summary не ищется', () => {
const row = { data: { header: { name: 'x' }, contract_summary: { counterparty: 'ООО Ромашка' }, contract_detail: { counterparty: null } } };
assert.equal(matchesSearch(row, 'ромаш'), false);
});
test('число ищется в записи JS', () => {
const row = { data: { header: {}, contract_summary: {}, contract_detail: JSON.parse('{"ceiling_amount": 1234.50}') } };
assert.equal(matchesSearch(row, '1234.5'), true);
assert.equal(matchesSearch(row, '1234.50'), false);
});
test('screenRows: trim + lower, NBSP по краям', () => {
const rows = [{ data: { line_id: 1, header: { name: 'Аренда помещения' } } }];
assert.deepEqual(screenRows(rows, { search: ' АРЕНДА ' }).map((r) => r.data.line_id), [1]);
assert.deepEqual(screenRows(rows, { search: ' аренда ' }).map((r) => r.data.line_id), [1]);
});
test('equals: регистр и пробелы по краям, null не проходит', () => {
const r = (v) => ({ getValue: () => v });
assert.equal(equals(r('КВ'), 'smeta_type', 'кв '), true);
assert.equal(equals(r(null), 'smeta_type', 'кв'), false);
});
test('screenFacets: свой фильтр не применяется, чужие — да', () => {
const f = screenFacets(rows2, { filters: { smeta_type: 'КВ' } });
assert.deepEqual(f.smeta_type, [['АХР', 1], ['КВ', 1]]);
assert.deepEqual(f.smeta_direction, [['Развитие', 1]]);
});
test('screenFacets: одинаковые названия — один вариант, null не выдаётся', () => {
assert.deepEqual(screenFacets(rows3).org_name, [['Филиал Ёлки', 2]]);
});
test('screenFound считает только поиск', () => {
assert.deepEqual(screenFound(rows2, ''), { found: 2, total: 2 });
});
test('векторы чисел', () => {
const v = Object.fromEntries(emitNumberVectors().map(({ input, js }) => [input, js]));
assert.equal(v['1e21'], '1e+21');
assert.equal(v['-0'], '0');
assert.equal(v['1e-7'], '1e-7');
assert.equal(v['0.000001'], '0.000001');
assert.equal(v['100.00'], '100');
assert.equal(v['9007199254740993'], '9007199254740992');
});
test('векторы экрана: не меньше 12 случаев', () => {
const s = emitScreenVectors();
assert.ok(s.cases.length >= 12);
assert.ok(s.rows.length >= 10 && s.rows.length <= 15);
});