bone_2026/tests/browser/ui_check.js

222 lines
12 KiB
JavaScript
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.

/**
* Проверка панели деталей веб-интерфейса в реальном браузере.
*
* Загружает несколько DICOM-файлов, кликает по строкам таблицы и снимает
* значения из панели деталей, чтобы убедиться, что они меняются при
* переключении строк (это был основной симптом бага: панель показывала одни
* и те же значения).
*
* Требуется запущенный сервер:
* python -m uvicorn src.main:app --port 8123
* node tests/browser/ui_check.js
*
* Переменные окружения:
* BASE_URL адрес сервера (по умолчанию http://127.0.0.1:8123)
* PLAYWRIGHT путь к playwright-core (по умолчанию берётся из bundled
* Browser Use в установке Qwen Code)
*
* Используется временный профиль Chrome через launchPersistentContext,
* профиль пользователя не затрагивается.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
function resolvePlaywright() {
if (process.env.PLAYWRIGHT) return process.env.PLAYWRIGHT;
// Ищем playwright-core в bundled Browser Use установки Qwen Code.
const base = path.join(os.homedir(), '.qwen', 'updates', 'npm');
if (!fs.existsSync(base)) return 'playwright-core';
const versions = fs.readdirSync(path.join(base, fs.readdirSync(base)[0], 'versions')).sort();
for (const v of versions.reverse()) {
const candidate = path.join(
base, fs.readdirSync(base)[0], 'versions', v,
'node_modules/@qwen-code/qwen-code/bundled/browser-use/runtime/node_modules/playwright-core'
);
if (fs.existsSync(candidate)) return candidate;
}
return 'playwright-core';
}
const { chromium } = require(resolvePlaywright());
const BASE = process.env.BASE_URL || 'http://127.0.0.1:8123';
const REPO = path.resolve(__dirname, '..', '..');
const FILES = [
path.join(REPO, 'dataset_hack/Для теста/spine_01.dcm'),
path.join(REPO, 'dataset_hack/Для теста/l_hip_01.dcm'),
path.join(REPO, 'dataset_hack/Для теста/r_hip_01.dcm'),
];
async function panelState(page) {
return page.evaluate(() => {
const txt = (id) => {
const el = document.getElementById(id);
return el ? el.innerText.replace(/\s+/g, ' ').trim() : null;
};
const cards = (id) => {
const el = document.getElementById(id);
if (!el) return null;
return Array.from(el.children).map((c) => c.innerText.replace(/\s+/g, ' ').trim());
};
const imgs = Array.from(document.querySelectorAll('#detailImage img, #detailMask img'))
.map((i) => ({ src: (i.getAttribute('src') || '').slice(0, 22), w: i.naturalWidth, h: i.naturalHeight }));
return {
detailVisible: !document.getElementById('detailSection').classList.contains('hidden'),
badge: txt('detailBadge'),
basic: txt('detailBasic'),
quality: txt('detailQuality'),
measurements: cards('detailMotion'),
roiCards: cards('detailArtifacts'),
spineHidden: document.getElementById('spineSection').classList.contains('hidden'),
spine: txt('detailSpine'),
hipHidden: document.getElementById('hipSection').classList.contains('hidden'),
hip: txt('detailHip'),
reason: txt('detailReason'),
note: txt('detailMetricsNote'),
images: imgs,
};
});
}
(async () => {
const userDataDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'dxa-ui-'));
// launchPersistentContext с временным профилем: профиль пользователя не затрагивается.
const context = await chromium.launchPersistentContext(userDataDir, { channel: 'chrome', headless: true });
const browser = context;
const page = context.pages()[0] || await context.newPage();
await page.setViewportSize({ width: 1400, height: 1100 });
const consoleErrors = [];
page.on('console', (m) => { if (m.type() === 'error') consoleErrors.push(m.text()); });
page.on('pageerror', (e) => consoleErrors.push('PAGEERROR ' + e.message));
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
console.log('title:', await page.title());
console.log('status banner:', (await page.locator('#statusText').innerText()).trim());
// Загружаем файлы через input (drag-and-drop эмулировать не нужно).
await page.setInputFiles('#fileInput', FILES);
await page.waitForFunction(
() => document.querySelectorAll('#resultsTable tr').length === 3,
{ timeout: 60000 }
);
console.log('rows rendered:', await page.locator('#resultsTable tr').count());
console.log('stats:', await page.evaluate(() => ({
total: document.getElementById('totalCount').innerText,
ok: document.getElementById('okCount').innerText,
violation: document.getElementById('violationCount').innerText,
avg: document.getElementById('meanConfidence').innerText,
})));
// Подсказка о кликабельности строк: пользователи не догадывались, что по
// строке можно нажать, поэтому она должна быть видна и содержать инструкцию.
const hint = await page.evaluate(() => {
const el = document.getElementById('tableHint');
return {
visible: el && !el.classList.contains('hidden') && el.offsetHeight > 0,
text: el ? el.innerText.replace(/\s+/g, ' ').trim() : '',
openButtons: document.querySelectorAll('#resultsTable button').length,
};
});
console.log('hint:', hint);
if (!hint.visible) throw new Error('подсказка о кликабельности строк не видна');
if (!/нажмите на/i.test(hint.text)) throw new Error('в подсказке нет инструкции нажать на строку');
if (hint.openButtons !== 3) throw new Error(`кнопок «Открыть» в строках: ${hint.openButtons}, ожидалось 3`);
// Кнопка «Открыть» в строке должна открывать панель деталей так же, как клик.
// Проверяем до сценария с кликом по строке, чтобы не мешать его подсчётам.
await page.locator('#resultsTable tr').first().locator('button').click();
await page.waitForSelector('#detailSection:not(.hidden)', { timeout: 15000 });
console.log('кнопка «Открыть» открыла панель деталей: да');
await page.locator('#closeDetail').click();
// Пустой фильтр: подсказка о кликабельности не должна висеть над «Нет результатов».
await page.fill('#searchInput', 'заведомо-нет-такого-файла');
await page.waitForSelector('#emptyResults:not(.hidden)', { timeout: 10000 });
const emptyState = await page.evaluate(() => ({
hintHidden: document.getElementById('tableHint').classList.contains('hidden'),
rows: document.querySelectorAll('#resultsTable tr').length,
}));
console.log('пустой фильтр:', emptyState);
if (!emptyState.hintHidden) throw new Error('подсказка осталась видимой при пустом результате');
await page.fill('#searchInput', '');
await page.waitForFunction(
() => document.querySelectorAll('#resultsTable tr').length === 3,
{ timeout: 10000 }
);
// Панель «О модели»: происхождение модели, метрики с интервалами и словарь
// типов нарушений приходят с /api/v1/model, поэтому проверяем, что раздел
// действительно наполняется, а не остаётся пустым каркасом.
await page.locator('#modelInfoToggle').click();
await page.waitForSelector('#modelMetrics table', { timeout: 15000 });
const modelPanel = await page.evaluate(() => ({
summary: document.getElementById('modelSummary').innerText.replace(/\s+/g, ' ').slice(0, 200),
metricRows: document.querySelectorAll('#modelMetrics tbody tr').length,
violationRows: document.querySelectorAll('#modelViolations tbody tr').length,
limitationItems: document.querySelectorAll('#modelLimitations li').length,
datasetCards: document.querySelectorAll('#modelDataset > div').length,
}));
console.log('model panel:', modelPanel);
if (modelPanel.metricRows < 3) {
throw new Error(`панель «О модели»: ожидалось >=3 строк метрик, получено ${modelPanel.metricRows}`);
}
if (modelPanel.violationRows < 5) {
throw new Error(`панель «О модели»: ожидалось >=5 типов нарушений, получено ${modelPanel.violationRows}`);
}
await page.locator('#modelInfoToggle').click();
const shots = [];
const states = [];
for (const idx of [0, 1, 2]) {
await page.locator('#resultsTable tr').nth(idx).click();
await page.waitForTimeout(1200);
const state = await panelState(page);
states.push(state);
const file = path.join(os.tmpdir(), `dxa_panel_row${idx}.png`);
await page.screenshot({ path: file, fullPage: false });
shots.push({ idx, file, measurements: state.measurements, region: state.basic });
// Прокручиваем к панели, чтобы она попала в кадр
await page.locator('#detailSection').scrollIntoViewIfNeeded();
await page.screenshot({ path: path.join(os.tmpdir(), `dxa_detail_row${idx}.png`) });
}
console.log('\n=== ПАНЕЛЬ ДЕТАЛЕЙ ПО СТРОКАМ ===');
for (const s of shots) {
console.log(`\nстрока ${s.idx}:`);
console.log(' basic:', s.region);
console.log(' измерения:', s.measurements);
}
console.log('\n=== СЕКЦИИ / ВИЗУАЛИЗАЦИЯ / ЗАКЛЮЧЕНИЕ ===');
states.forEach((s, i) => {
console.log(`\nстрока ${i}: visible=${s.detailVisible}`);
console.log(' badge:', s.badge);
console.log(' quality:', s.quality);
console.log(' roi:', s.roiCards);
console.log(' spine hidden:', s.spineHidden, '| hip hidden:', s.hipHidden);
if (!s.spineHidden) console.log(' spine:', s.spine);
if (!s.hipHidden) console.log(' hip:', s.hip);
console.log(' note:', (s.note || '').slice(0, 80));
console.log(' images:', s.images);
});
// Проверка, что значения действительно различаются между строками
const uniq = (arr) => new Set(arr.filter(Boolean)).size;
console.log('\n=== РАЗЛИЧИМОСТЬ ЗНАЧЕНИЙ МЕЖДУ СТРОКАМИ ===');
console.log('разных basic:', uniq(states.map((s) => s.basic)));
console.log('разных measurements:', uniq(states.map((s) => JSON.stringify(s.measurements))));
console.log('разных quality:', uniq(states.map((s) => s.quality)));
console.log('разных roi:', uniq(states.map((s) => JSON.stringify(s.roiCards))));
console.log('разных reason:', uniq(states.map((s) => s.reason)));
console.log('\nconsole errors:', consoleErrors.length ? consoleErrors : 'none');
fs.writeFileSync(path.join(os.tmpdir(), 'dxa_ui_states.json'), JSON.stringify(states, null, 2));
console.log('states saved to', path.join(os.tmpdir(), 'dxa_ui_states.json'));
console.log('screenshots:', shots.map((s) => s.file).join(', '));
await browser.close();
fs.rmSync(userDataDir, { recursive: true, force: true });
})().catch((e) => { console.error('FAILED:', e.message); process.exit(1); });