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
Доработаны хендлеры браузерного прогона (пагинация со сбросом поиска, обобщённое создание/сохранение/удаление, период-фильтры, доп. сид: чек/приёмка/оприходование/ списание/инвентаризация/возврат). Экстрактор ловит строковый фолбэк заголовка колонки. 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>
166 lines
9.9 KiB
TypeScript
166 lines
9.9 KiB
TypeScript
/**
|
||
* Статический экстрактор интерактивных элементов 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(', ')}`)
|