food-market/tests/e2e/tools/extract-controls.ts
nns 2b751dc129
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
test(uat): измеримое покрытие — прогон + HTML с цифрами (P1–P3 80%, P4 37%)
Доработаны хендлеры браузерного прогона (пагинация со сбросом поиска, обобщённое
создание/сохранение/удаление, период-фильтры, доп. сид: чек/приёмка/оприходование/
списание/инвентаризация/возврат). Экстрактор ловит строковый фолбэк заголовка колонки.
docs/uat-coverage.html — цифры по экрану/приоритету/общим. Цели (P1–P3 ≥95%, P4 ≥70%)
пока НЕ достигнуты (переход на ALL MARKET в приоритете) — остаток задокументирован в
docs/uat-coverage-progress.md, продолжим после перехода.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:09:46 +05:00

166 lines
9.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Статический экстрактор интерактивных элементов web-страниц → denominator для
* измеримого UAT-покрытия. Читает web/src/App.tsx (route → компонент → файл) и
* каждый pages/*.tsx, достаёт: поиск, фильтры (Select/даты/тумблеры), сортируемые
* колонки, пагинацию, экспорт, кнопки действий. Пишет docs/uat-coverage.expected.json.
*
* Запуск: pnpm exec tsx tools/extract-controls.ts (из tests/e2e)
*/
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const HERE = dirname(fileURLToPath(import.meta.url))
const WEB = resolve(HERE, '../../../src/food-market.web/src')
const APP = resolve(WEB, 'App.tsx')
const PAGES = resolve(WEB, 'pages')
const OUT = resolve(HERE, '../../../docs/uat-coverage.expected.json')
// Рекурсивно: имя компонента (без .tsx) → путь файла (pages и подпапки, напр. money/).
function buildFileMap(dir: string, map: Record<string, string> = {}): Record<string, string> {
for (const name of readdirSync(dir)) {
const p = join(dir, name)
if (statSync(p).isDirectory()) buildFileMap(p, map)
else if (name.endsWith('.tsx')) map[name.replace(/\.tsx$/, '')] = p
}
return map
}
const FILES = buildFileMap(PAGES)
type El = { key: string; label: string; type: string }
type Screen = { route: string; page: string; file: string; priority: string; title: string; listUrl: string; elements: El[] }
// Приоритеты по категоризации владельца (UAT P1–P4).
const PRIORITY: Record<string, string> = {
'/catalog/products': 'P1', '/inventory/stock': 'P1', '/sales/retail': 'P1',
'/purchases/supplies': 'P2', '/money/shifts': 'P2',
'/inventory/enters': 'P3', '/inventory/losses': 'P3', '/inventory/inventories': 'P3',
'/inventory/movements': 'P3', '/purchases/supplier-returns': 'P3', '/purchases/invoices': 'P3',
'/money/transactions': 'P3', '/money/supplier-debts': 'P3', '/money/report': 'P3',
'/reports/sales': 'P3', '/reports/stock': 'P3', '/reports/profit': 'P3',
'/catalog/counterparties': 'P3', '/catalog/data-quality': 'P3',
'/settings/employees': 'P4', '/settings/employee-roles': 'P4', '/catalog/stores': 'P4',
'/catalog/retail-points': 'P4', '/money/accounts': 'P4', '/settings/organization': 'P4',
'/money/expense-items': 'P4',
}
// Карта: route (список) → компонент. Парсим App.tsx. element={...} может содержать
// вложенные {} (RoleGuard roles={[…]}, AdvancedModeGuard) — балансируем скобки.
function routeComponentMap(): Record<string, string> {
const src = readFileSync(APP, 'utf8')
const map: Record<string, string> = {}
const re = /<Route\s+path="([^"]+)"\s+element=\{/g
let m: RegExpExecArray | null
while ((m = re.exec(src))) {
const path = m[1]
// Сбалансированный разбор содержимого element={...}.
let i = re.lastIndex, depth = 1
while (i < src.length && depth > 0) { const ch = src[i]; if (ch === '{') depth++; else if (ch === '}') depth--; i++ }
const element = src.slice(re.lastIndex, i - 1)
// Только app-списки: абсолютный путь, без параметров/new, не super-admin/онбординг.
if (!path.startsWith('/') || path.includes(':') || path.endsWith('/new') || path === '*') continue
if (path.startsWith('/super-admin') || path === '/' || path.startsWith('/onboarding') || path === '/help' || path === '/whats-new') continue
if (['/login', '/forgot-password', '/reset-password', '/auth-bridge', '/no-organization'].includes(path)) continue
const comp = [...element.matchAll(/([A-Z][A-Za-z0-9]+Page)\b/g)].map((x) => x[1])
.find((c) => !/Guard|Layout|Protected/.test(c))
if (comp) map[path] = comp
}
return map
}
function slug(s: string): string {
return s.toLowerCase().replace(/[^a-zа-я0-9]+/gi, '-').replace(/^-+|-+$/g, '').slice(0, 24)
}
function extract(route: string, comp: string): Screen | null {
const file = FILES[comp]
if (!file || !existsSync(file)) return null
const src = readFileSync(file, 'utf8')
const els: El[] = []
const add = (key: string, label: string, type: string) => {
if (!els.some((e) => e.key === key)) els.push({ key, label, type })
}
if (/<SearchBar\b/.test(src)) add('search', 'поиск', 'search')
if (/<Pagination\b/.test(src)) add('pagination', 'пагинация', 'pagination')
for (const m of src.matchAll(/<ExportButton[^>]*?url=["']([^"']+)["']/g))
add('export', `экспорт (${m[1]})`, 'export')
if (!els.some((e) => e.key === 'export') && /<ExportButton\b/.test(src)) add('export', 'экспорт', 'export')
// Сортируемые колонки DataTable: sortKey + ближайший header-строка (в т.ч. строковый
// фолбэк в выражении header: expr ?? 'Текст').
for (const m of src.matchAll(/sortKey:\s*['"]([^'"]+)['"]/g)) {
const key = m[1]
const around = src.slice(Math.max(0, m.index! - 260), m.index! + 60)
// header: '...' ИЛИ любая строка-литерал в значении header (фолбэк ?? '...')
const direct = [...around.matchAll(/header:\s*['"]([^'"]+)['"]/g)].pop()
let label = direct?.[1]
if (!label) {
const hExpr = around.match(/header:\s*([^,]*?),/)
const quoted = hExpr?.[1]?.match(/['"]([^'"]{2,})['"]/)
label = quoted?.[1]
}
add(`sort:${key}`, `сортировка: ${label ?? key}`, 'sort')
}
// Фильтры списка: <Select>/<select>, у которого обработчик сбрасывает страницу
// (setPage) — признак именно ФИЛЬТРА СПИСКА, а не поля формы в модалке.
let selIdx = 0
for (const m of src.matchAll(/<[Ss]elect\b([^]*?)<\/[Ss]elect>/g)) {
const block = m[1]
if (!/setPage|setSort|refetch|onFilter/.test(block)) continue // поле формы — пропускаем
selIdx++
const opts = [...block.matchAll(/<option[^>]*>([^<]+)<\/option>/g)].map((o) => o[1].trim())
const label = opts.find((o) => o && !o.includes('{') && !/^[a-z]\./.test(o)) ?? `фильтр ${selIdx}`
const key = label.startsWith('фильтр ') ? `filter:sel${selIdx}` : `filter:${slug(label)}`
add(key, `фильтр: ${label}`, 'filter')
}
// Даты/периоды: только если это фильтр списка/отчёта (есть дата-контрол + отчётная зона).
if ((/type="date"|DateInput|DateRange|MonthInput/i.test(src) && /setFrom|setTo|setPeriod|setDate|setOn|refetch|queryKey/.test(src))
|| /на дату|За период|periodFrom|periodTo/i.test(src))
add('filter:period', 'фильтр: период/дата', 'filter')
// Тумблеры-ФИЛЬТРЫ списка: Checkbox с меткой, чей onChange трогает setPage.
for (const m of src.matchAll(/<Checkbox\b([^]*?)\/>/g)) {
const tag = m[1]
if (!/setPage/.test(tag)) continue
const lbl = tag.match(/label=["']([^"']+)["']/)
if (lbl) add(`toggle:${slug(lbl[1])}`, `переключатель: ${lbl[1]}`, 'toggle')
}
// Кнопки действий (list-страница): по подписям/confirmLabel/мутациям.
const actions: [RegExp, string, string][] = [
[/confirmLabel:\s*['"]Провести['"]|>\s*Провести|\/post['"`]/, 'action:post', 'провести'],
[/Отменить проведение|confirmLabel:\s*['"]Снять['"]|>\s*Снять|\/unpost['"`]/, 'action:unpost', 'снять проведение'],
[/variant="danger"|confirmLabel:\s*['"]Удалить['"]|>\s*Удалить|\.delete\(/, 'action:delete', 'удалить'],
[/window\.print|\/print|>\s*Печать/, 'action:print', 'печать'],
[/на основании|from-supply|createReturn/, 'action:create-from', 'создать на основании'],
[/type="submit"|>\s*Сохранить|confirmLabel:\s*['"]Сохранить['"]/, 'action:save', 'сохранить'],
[/>\s*(Создать|Новый|Добавить|Пригласить)\b|to="[^"]*\/new"|SplitButtonMenu/, 'action:create', 'создать'],
]
for (const [re, key, label] of actions) if (re.test(src)) add(key, `действие: ${label}`, 'action')
// Заголовок страницы (ListPageShell title / h1) и list-URL (useCatalogList/const URL/export).
const title = (src.match(/title="([^"]+)"/) ?? src.match(/<h1[^>]*>([^<]+)<\/h1>/))?.[1]?.trim() ?? comp
const listUrl = (src.match(/useCatalogList<[^>]*>\(\s*([A-Za-z_]+)/)
? (src.match(/const\s+URL\s*=\s*['"]([^'"]+)['"]/) ?? [])[1]
: undefined)
?? (src.match(/useCatalogList<[^>]*>\(\s*['"]([^'"]+)['"]/) ?? [])[1]
?? (src.match(/<ExportButton[^>]*url=["']([^"']+)\/export/) ?? [])[1]
?? (src.match(/api\.get[^(]*\(`?\$?\{?['"]?(\/api\/[a-z/-]+)/) ?? [])[1]
?? ''
return { route, page: comp, file: file.replace(WEB + '/', ''), priority: PRIORITY[route] ?? 'P4', title, listUrl, elements: els }
}
const map = routeComponentMap()
const screens: Screen[] = []
for (const [route, comp] of Object.entries(map)) {
const s = extract(route, comp)
if (s && s.elements.length > 0) screens.push(s)
}
screens.sort((a, b) => (a.priority + a.route).localeCompare(b.priority + b.route))
const total = screens.reduce((n, s) => n + s.elements.length, 0)
writeFileSync(OUT, JSON.stringify({ generatedAt: new Date().toISOString(), totalElements: total, screens }, null, 2))
console.log(`[extract] ${screens.length} экранов, ${total} элементов → ${OUT}`)
for (const s of screens) console.log(` ${s.priority} ${s.route} (${s.page}): ${s.elements.length} — ${s.elements.map((e) => e.key).join(', ')}`)