food-market/tests/e2e/tools/extract-controls.ts
nns 3f416e71ad test(uat): измеримое покрытие — экстрактор элементов из кода + браузерный прогон
- tools/extract-controls.ts: статически из web/src/pages достаёт все интерактивные
  элементы каждого экрана (поиск/фильтры/сортировки/пагинация/экспорт/тумблеры/
  действия) → docs/uat-coverage.expected.json (36 экранов, 195 элементов; приоритеты).
- scenarios/stage-ui-uat-coverage.spec.ts: читает expected, в браузере задействует
  каждый элемент (фиксирует запрос с параметром/действие), пишет reports/uat-covered.json.
- tools/build-coverage.ts: сводит → docs/uat-coverage.{json,html} с % по экрану/
  приоритету/общим (цель P1–P3 ≥95%, P4 ≥70%).

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

159 lines
9.6 KiB
TypeScript
Raw 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-строка.
for (const m of src.matchAll(/sortKey:\s*['"]([^'"]+)['"]/g)) {
const key = m[1]
// ищем header: '...' в пределах 220 символов вокруг
const around = src.slice(Math.max(0, m.index! - 220), m.index! + 60)
const h = [...around.matchAll(/header:\s*['"]([^'"]+)['"]/g)].pop()
add(`sort:${key}`, `сортировка: ${h ? h[1] : 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(', ')}`)