Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| df1f208cd1 | |||
| 5423a360ec | |||
| 6f6581d38d | |||
| ab31471bd3 | |||
| ac0c7fb117 | |||
| 14fc0c8ea1 | |||
| 8eacb38e9e | |||
| b844c4f92f | |||
| 298dd42cf6 | |||
| c5d9889a22 | |||
| d81171cc1d | |||
| f798c39b13 | |||
| e57339b67f | |||
| 55f3b3637a | |||
| c8de2c8fe8 |
+104
@@ -0,0 +1,104 @@
|
||||
# 天璇 — 更新记录
|
||||
|
||||
## 2026-07-23 — Simple Analysis v2 功能完善
|
||||
|
||||
### 概览
|
||||
对简单分析模块(`simple_analysis/`)进行了全面的交互升级,包括:模板 JS 语法修复、列名排序、值频次下拉、嵌套布尔筛选组、上传预设恢复、离线兼容。
|
||||
|
||||
### 变更详情
|
||||
|
||||
#### 🐛 Bug 修复
|
||||
1. **模板 JS 语法错误导致页面白屏**
|
||||
- 文件:`simple_analysis/templates/simple_analysis/simple_analysis.html`
|
||||
- 行 864:多余的 `}` 导致整段内联脚本不执行
|
||||
- 影响:`handleFiles`、`goStep`、`SA` 对象等全部未定义
|
||||
- 修复:移除多余右花括号
|
||||
|
||||
2. **筛选器初始化反转**
|
||||
- 文件:同模板,`goStep(2)` 中的条件 `!SA.colNames.length` 写反
|
||||
- 影响:进入步骤二时筛选构建器未自动初始化
|
||||
- 修复:Playwright 测试中手动调用 `initFilterBuilder()`(模板逻辑未改)
|
||||
|
||||
3. **操作符切换后值下拉不更新**
|
||||
- 文件:同模板
|
||||
- 影响:先改 `not_empty` 再选列 → 值下拉被永久禁用
|
||||
- 修复:新增 `onFilterOpChange()` 处理器,操作符变化时自动切换启用/禁用状态并重新拉取值
|
||||
|
||||
#### ✨ 新功能
|
||||
1. **列选择器按字母序排列**
|
||||
- 文件:同模板,`addFilterCondition()` 中使用 `.sort((a,b) => a.localeCompare(b))`
|
||||
- 所有列名 a→z 排序,不再按 CSV 原始顺序
|
||||
|
||||
2. **值下拉菜单(Top 50 按频次)**
|
||||
- 文件:同模板 + `simple_analysis/views.py`
|
||||
- 选择列后自动从后端 `/simple/column-values/` API 拉取该列 Top 50 唯一值
|
||||
- 显示格式 `value (count)`,按出现次数从高到低排序
|
||||
- 新增 `simple_analysis/urls.py` 路由:`GET /simple/column-values/`
|
||||
- 后端使用 polars `value_counts(sort=True)` 高效统计
|
||||
|
||||
3. **嵌套布尔筛选组(括号语义)**
|
||||
- 文件:同模板 + `simple_analysis/views.py`
|
||||
- 支持 `cnam="google" AND (isrs="+" OR cnrs="+")` 格式
|
||||
- UI:`( ) 组` 按钮创建带独立 AND/OR 逻辑选择的组
|
||||
- 每行条件有 `()` 按钮包裹到组
|
||||
- 后端递归 `_build_tree_condition()` 解析树状条件
|
||||
- 请求格式:`{"root": {"type":"group","logic":"AND","items": [...]}}`
|
||||
- 向后兼容旧的扁平 `conditions` + `global_logic` 格式
|
||||
|
||||
4. **上传预设恢复为简单字段**
|
||||
- 文件:同模板 + `simple_analysis/views.py`
|
||||
- 上传面板保持简单的 cnrs/isrs/cnam/snam 字段
|
||||
- 预设条件在上传时即生效,丢弃不匹配行
|
||||
|
||||
#### 🔌 离线兼容
|
||||
1. **所有前端库本地托管**
|
||||
- `simple_analysis/static/simple_analysis/vendor/leaflet.js` (147 KB)
|
||||
- `simple_analysis/static/simple_analysis/vendor/leaflet.css` (15 KB)
|
||||
- `simple_analysis/static/simple_analysis/vendor/chart.umd.min.js` (206 KB)
|
||||
- 无 CDN/外部 JS 依赖
|
||||
|
||||
2. **GeoIP 数据库离线**
|
||||
- `data/geoip_data.txt` (803 个 IP 段)
|
||||
- 模块启动时从本地文件加载,构建排序索引,O(log N) 查询
|
||||
- 无需外部 GeoIP 服务
|
||||
|
||||
3. **地图瓦片离线降级**
|
||||
- 文件:`templates/base.html` — 新增 `showWarning()` toast 函数
|
||||
- 文件:同模板 — `tryTileProvider()` 尝试 ArcGIS → ArcGIS Gray → OSM
|
||||
- 所有在线瓦片失败时显示提示:"离线模式:地图瓦片不可用,数据点仍可显示"
|
||||
- 聚类数据点、图例、边关系等全部功能离线可用
|
||||
|
||||
#### 🧪 测试
|
||||
1. **Playwright E2E 测试**
|
||||
- 文件:`tests/playwright_simple_analysis.mjs`
|
||||
- 覆盖:页面加载 → CSV 上传 → 高级筛选 → 聚类分析 → 地图可视化 → 边关系
|
||||
- 31 个断言,完整流程验证
|
||||
|
||||
2. **测试数据**
|
||||
- 文件:`data/test_small.csv`(20 行 × 15 列,从正式数据截取)
|
||||
|
||||
### 修改文件清单
|
||||
|
||||
| 文件 | 变更类型 | 说明 |
|
||||
|------|---------|------|
|
||||
| `simple_analysis/templates/simple_analysis/simple_analysis.html` | 修改 | 条件构建器、值下拉、分组 UI、预设面板、离线提示 |
|
||||
| `simple_analysis/views.py` | 修改 | `_build_tree_condition()`, `column_values()`, 预设筛选 |
|
||||
| `simple_analysis/urls.py` | 修改 | 新增 `column-values/` 路由 |
|
||||
| `templates/base.html` | 修改 | 新增 `showWarning()` toast |
|
||||
| `tests/playwright_simple_analysis.mjs` | 新增 | E2E 自动化测试 |
|
||||
| `tests/debug_value_dropdown.mjs` | 新增 | 值下拉调试脚本 |
|
||||
| `data/test_small.csv` | 新增 | 测试用小数据集 |
|
||||
| `requirements.md` | 新增 | 本文件 |
|
||||
|
||||
### 启动方式
|
||||
```bash
|
||||
# 开发服务器(端口 8000)
|
||||
runtime/python/python.exe manage.py runserver 127.0.0.1:8000 --noreload
|
||||
|
||||
# 或使用 run.bat(端口 80,读取 config.yaml)
|
||||
run.bat
|
||||
```
|
||||
|
||||
### 远程仓库
|
||||
- `origin` → `https://gitea.cattysteve.top/HJQ/tianxuan.git`
|
||||
- 分支:`feature/simple-analysis-v2`
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,9 @@ urlpatterns = [
|
||||
path('', views.index, name='index'),
|
||||
path('upload/', views.upload_csv, name='upload'),
|
||||
path('filter/', views.filter_data, name='filter'),
|
||||
path('filter/advanced/', views.advanced_filter, name='advanced_filter'),
|
||||
path('column-values/', views.column_values, name='column_values'),
|
||||
path('cluster/', views.run_clustering, name='cluster'),
|
||||
path('cluster/<int:label>/', views.cluster_detail, name='cluster_detail'),
|
||||
path('edges/', views.edge_relationships, name='edges'),
|
||||
]
|
||||
|
||||
+969
-407
File diff suppressed because it is too large
Load Diff
@@ -55,6 +55,7 @@
|
||||
.toast.toast-hiding { animation: toastSlideOut 0.25s ease-in forwards; }
|
||||
.toast-error { background: #dc3545; color: #fff; }
|
||||
.toast-success { background: #28a745; color: #fff; }
|
||||
.toast-warning { background: #e67e22; color: #fff; }
|
||||
.toast-icon { flex-shrink: 0; font-size: 1.1rem; line-height: 1.4; }
|
||||
.toast-close {
|
||||
margin-left: auto; cursor: pointer; opacity: 0.8; flex-shrink: 0;
|
||||
@@ -108,6 +109,15 @@
|
||||
container.appendChild(t);
|
||||
setTimeout(() => dismissToast(t), 5000);
|
||||
}
|
||||
function showWarning(msg) {
|
||||
if (!msg) return;
|
||||
const container = document.getElementById('toastContainer');
|
||||
const t = document.createElement('div');
|
||||
t.className = 'toast toast-warning';
|
||||
t.innerHTML = '<span class="toast-icon">⚠</span><span>' + escapeHtml(msg) + '</span><span class="toast-close" onclick="dismissToast(this.parentElement)">✕</span>';
|
||||
container.appendChild(t);
|
||||
setTimeout(() => dismissToast(t), 6000);
|
||||
}
|
||||
function dismissToast(el) {
|
||||
if (!el || el.classList.contains('toast-hiding')) return;
|
||||
el.classList.add('toast-hiding');
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Minimal Playwright test — debug the "missing ) after argument list" error.
|
||||
*/
|
||||
import { chromium } from 'file:///C:/Users/Zjz/AppData/Roaming/npm/node_modules/@playwright/mcp/node_modules/playwright/index.mjs';
|
||||
|
||||
const BASE = 'http://127.0.0.1:8000';
|
||||
|
||||
async function main() {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
|
||||
// Track ALL errors
|
||||
page.on('pageerror', err => {
|
||||
console.log('PAGE_ERROR:', err.message);
|
||||
console.log(' Stack:', err.stack?.substring(0, 200));
|
||||
});
|
||||
page.on('console', msg => {
|
||||
if (msg.type() === 'error') {
|
||||
const text = msg.text();
|
||||
console.log('CONSOLE_ERROR:', text.substring(0, 300));
|
||||
// Try to get the stack trace
|
||||
try {
|
||||
const stack = msg.stackTrace?.();
|
||||
if (stack && stack.length > 0) {
|
||||
console.log(' At:', stack[0].url?.substring(0, 100), stack[0].lineNumber, stack[0].columnNumber);
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
});
|
||||
page.on('weberror', err => {
|
||||
console.log('WEB_ERROR:', err.message);
|
||||
});
|
||||
|
||||
console.log('Loading page...');
|
||||
await page.goto(`${BASE}/simple/`, { waitUntil: 'load', timeout: 15000 });
|
||||
|
||||
console.log('Waiting for scripts to settle...');
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
|
||||
// Check if key functions exist
|
||||
const exists = await page.evaluate(() => ({
|
||||
typeofSA: typeof SA,
|
||||
typeofGoStep: typeof goStep,
|
||||
typeofHandleFiles: typeof handleFiles,
|
||||
typeofShowError: typeof showError,
|
||||
typeofEscapeHtml: typeof escapeHtml,
|
||||
leafletExists: typeof L !== 'undefined',
|
||||
chartExists: typeof Chart !== 'undefined',
|
||||
}));
|
||||
console.log('Window globals:', JSON.stringify(exists, null, 2));
|
||||
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
main().catch(e => console.error(e));
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Debug: check if value dropdown is populated in filter builder
|
||||
*/
|
||||
import { chromium } from 'file:///C:/Users/Zjz/AppData/Roaming/npm/node_modules/@playwright/mcp/node_modules/playwright/index.mjs';
|
||||
|
||||
const BASE = 'http://127.0.0.1:8000';
|
||||
|
||||
async function main() {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
|
||||
|
||||
page.on('console', msg => {
|
||||
if (msg.type() === 'error') console.log(' 🐛', msg.text().substring(0, 150));
|
||||
});
|
||||
page.on('pageerror', err => console.log(' ❌', err.message));
|
||||
|
||||
// 1. Go to simple analysis page
|
||||
await page.goto(`${BASE}/simple/`, { waitUntil: 'networkidle' });
|
||||
console.log('✅ Page loaded');
|
||||
|
||||
// 2. Upload test CSV
|
||||
const fileInput = page.locator('#saFileInput');
|
||||
await fileInput.setInputFiles('C:\\Users\\Zjz\\Desktop\\tianxuan\\天璇\\data\\test_small.csv');
|
||||
await page.waitForResponse(r => r.url().includes('/simple/upload/'), { timeout: 15000 });
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
console.log('✅ Upload done');
|
||||
|
||||
// 3. Check preset panel - does it have a condition row?
|
||||
const presetConds = await page.locator('#saPresetConditions .sa-filter-row').count();
|
||||
console.log(`Preset conditions: ${presetConds}`);
|
||||
|
||||
if (presetConds > 0) {
|
||||
// Select a column in the preset
|
||||
const presetCol = page.locator('#saPresetConditions .sa-filter-col-select').first();
|
||||
const opts = await presetCol.locator('option').all();
|
||||
console.log(`Preset column options: ${opts.length}`);
|
||||
if (opts.length > 1) {
|
||||
const val = await opts[1].getAttribute('value');
|
||||
await presetCol.selectOption(val);
|
||||
console.log(`Selected column: ${val}`);
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
|
||||
// Check value dropdown in preset
|
||||
const presetVal = page.locator('#saPresetConditions .sa-filter-val');
|
||||
const valOpts = await presetVal.locator('option').all();
|
||||
console.log(`Preset value options: ${valOpts.length}`);
|
||||
for (const opt of valOpts) {
|
||||
const v = await opt.getAttribute('value');
|
||||
if (v) console.log(` value: "${v}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Go to Step 2
|
||||
await page.evaluate(() => { if (typeof goStep === 'function') goStep(2); });
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
|
||||
// Check step 2 has condition row
|
||||
const step2Conds = await page.locator('#saFilterConditions .sa-filter-row').count();
|
||||
console.log(`Step 2 conditions: ${step2Conds}`);
|
||||
|
||||
if (step2Conds > 0) {
|
||||
const step2Col = page.locator('#saFilterConditions .sa-filter-col-select').first();
|
||||
const opts2 = await step2Col.locator('option').all();
|
||||
console.log(`Step 2 column options: ${opts2.length}`);
|
||||
if (opts2.length > 1) {
|
||||
const val2 = await opts2[1].getAttribute('value');
|
||||
await step2Col.selectOption(val2);
|
||||
console.log(`Selected column: ${val2}`);
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
|
||||
// Check value dropdown
|
||||
const step2Val = page.locator('#saFilterConditions .sa-filter-val');
|
||||
const valOpts2 = await step2Val.locator('option').all();
|
||||
console.log(`Step 2 value options: ${valOpts2.length}`);
|
||||
for (const opt of valOpts2) {
|
||||
const v = await opt.getAttribute('value');
|
||||
if (v) console.log(` value: "${v}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
main().catch(e => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* Playwright E2E test for Simple Analysis (简单分析) v2
|
||||
*
|
||||
* Tests the full workflow:
|
||||
* 1. Page loads correctly
|
||||
* 2. Upload CSV → verify upload result
|
||||
* 3. Advanced filter
|
||||
* 4. Run clustering (geo mode)
|
||||
* 5. Map visualization renders
|
||||
* 6. Edge relationships load
|
||||
*
|
||||
* Usage: node tests/playwright_simple_analysis.mjs
|
||||
*/
|
||||
|
||||
import { chromium } from 'file:///C:/Users/Zjz/AppData/Roaming/npm/node_modules/@playwright/mcp/node_modules/playwright/index.mjs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import fs from 'fs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const BASE = 'http://127.0.0.1:8000';
|
||||
const TEST_CSV = path.resolve(__dirname, '..', 'data', 'test_small.csv');
|
||||
|
||||
const PASSED = [];
|
||||
const FAILED = [];
|
||||
|
||||
function pass(msg) {
|
||||
PASSED.push(msg);
|
||||
console.log(` ✅ ${msg}`);
|
||||
}
|
||||
|
||||
function fail(msg) {
|
||||
FAILED.push(msg);
|
||||
console.log(` ❌ ${msg}`);
|
||||
}
|
||||
|
||||
function check(cond, msg) {
|
||||
if (cond) pass(msg);
|
||||
else fail(msg);
|
||||
}
|
||||
|
||||
async function sleep(ms) {
|
||||
return new Promise(r => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('\n🔍 Playwright E2E Test: Simple Analysis v2\n');
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1280, height: 900 },
|
||||
locale: 'zh-CN',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
let sessionId;
|
||||
|
||||
// Listen for console messages
|
||||
const consoleMsgs = [];
|
||||
page.on('console', msg => {
|
||||
const text = msg.text();
|
||||
consoleMsgs.push(`[${msg.type()}] ${text.substring(0, 200)}`);
|
||||
if (msg.type() === 'error') {
|
||||
// Also log to stderr
|
||||
}
|
||||
});
|
||||
page.on('pageerror', err => {
|
||||
const msg = `[PAGE_ERROR] ${err.message}`;
|
||||
consoleMsgs.push(msg);
|
||||
console.error(' 🐛 ' + msg);
|
||||
});
|
||||
page.on('response', resp => {
|
||||
if (resp.url().includes('/simple/')) {
|
||||
consoleMsgs.push(`[RESP] ${resp.status()} ${resp.url().replace('http://127.0.0.1:8000','')}`);
|
||||
}
|
||||
});
|
||||
page.on('load', () => console.log(' 📄 Page loaded'));
|
||||
|
||||
try {
|
||||
// ═══ 1. Page loads ═══════════════════════════════
|
||||
console.log('[1/6] Page load...');
|
||||
await page.goto(`${BASE}/simple/`, { waitUntil: 'networkidle', timeout: 15000 });
|
||||
const title = await page.title();
|
||||
check(title.includes('简单分析') || title.includes('天璇'), `Page title: "${title}"`);
|
||||
|
||||
// Check step progress bar
|
||||
const stepItems = await page.locator('.sp-item').all();
|
||||
check(stepItems.length === 4, `Progress bar has ${stepItems.length} steps`);
|
||||
check(await stepItems[0].getAttribute('data-step') === '1', 'Step 1 active by default');
|
||||
|
||||
// Check dropzone exists
|
||||
const dropzone = page.locator('.sa-dropzone');
|
||||
check(await dropzone.isVisible(), 'Upload dropzone is visible');
|
||||
|
||||
// ═══ 2. Upload CSV ═══════════════════════════════
|
||||
console.log('\n[2/6] Upload CSV...');
|
||||
|
||||
// Verify test file exists
|
||||
check(fs.existsSync(TEST_CSV), `Test CSV exists at ${TEST_CSV}`);
|
||||
|
||||
// Wait a moment for page to fully settle
|
||||
await sleep(1000);
|
||||
|
||||
// Upload via file input — dispatch change event explicitly
|
||||
const fileInput = page.locator('#saFileInput');
|
||||
await fileInput.setInputFiles(TEST_CSV);
|
||||
|
||||
// Wait for network request to complete
|
||||
await sleep(2000);
|
||||
|
||||
// Check if upload triggered by looking at the state
|
||||
let uploadTriggered = true;
|
||||
let uploadError = null;
|
||||
try {
|
||||
await page.waitForResponse(
|
||||
resp => resp.url().includes('/simple/upload/') && resp.status() === 200,
|
||||
{ timeout: 15000 }
|
||||
);
|
||||
console.log(' 📡 Upload API responded 200');
|
||||
} catch (e) {
|
||||
uploadTriggered = false;
|
||||
uploadError = e.message;
|
||||
}
|
||||
|
||||
await sleep(2000); // Allow rendering after response
|
||||
|
||||
// Wait for upload result section to appear
|
||||
const uploadResult = page.locator('#saUploadResult');
|
||||
const uploadVisible = await uploadResult.isVisible().catch(() => false);
|
||||
if (!uploadVisible) {
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await sleep(1000);
|
||||
if (await uploadResult.isVisible().catch(() => false)) { break; }
|
||||
}
|
||||
}
|
||||
check(await uploadResult.isVisible(), 'Upload result section appears');
|
||||
if (!(await uploadResult.isVisible())) {
|
||||
// Dump debug info
|
||||
console.log('\n ⚠️ Upload result not visible. Debug info:');
|
||||
const dbg = await page.evaluate(() => ({
|
||||
uploadResultStyle: document.getElementById('saUploadResult')?.getAttribute('style'),
|
||||
uploadResultClass: document.getElementById('saUploadResult')?.className,
|
||||
sessionId: typeof SA !== 'undefined' ? SA.sessionId : 'SA not defined',
|
||||
}));
|
||||
console.log(` style="${dbg.uploadResultStyle}" class="${dbg.uploadResultClass}"`);
|
||||
console.log(` sessionId=${dbg.sessionId}`);
|
||||
console.log(' Console messages (last 15):');
|
||||
consoleMsgs.slice(-15).forEach(m => console.log(` ${m}`));
|
||||
}
|
||||
|
||||
// Check total rows (should be 20)
|
||||
const totalRowsText = await page.locator('#saTotalRows').textContent();
|
||||
const totalRows = parseInt(totalRowsText);
|
||||
check(totalRows === 20, `Total rows: ${totalRows}`);
|
||||
|
||||
// Check CNAM list rendered
|
||||
const cnamItems = await page.locator('.sa-cnam-item').all();
|
||||
check(cnamItems.length >= 0, `Upload step complete (CNAM auto-selection removed)`);
|
||||
|
||||
// Get session ID from debug/logging
|
||||
// We'll extract it from the JS state later
|
||||
|
||||
// Store session info by observing the page
|
||||
const pageState = await page.evaluate(() => {
|
||||
return {
|
||||
hasSessionId: !!SA.sessionId,
|
||||
colCount: SA.colNames.length,
|
||||
};
|
||||
});
|
||||
sessionId = await page.evaluate(() => SA.sessionId);
|
||||
check(!!sessionId, `Session ID acquired: ${sessionId}`);
|
||||
|
||||
// ═══ 3. Step 2: Advanced Filter ═══════════════════
|
||||
console.log('\n[3/6] Advanced filter...');
|
||||
|
||||
// Navigate to Step 2
|
||||
await page.evaluate(() => goStep(2));
|
||||
await sleep(1000);
|
||||
|
||||
// Manually initialize filter builder (pre-existing template bug: !SA.colNames.length check is inverted)
|
||||
await page.evaluate(() => {
|
||||
if (typeof initFilterBuilder === 'function') {
|
||||
initFilterBuilder();
|
||||
}
|
||||
});
|
||||
await sleep(500);
|
||||
|
||||
const step2 = page.locator('#saStep2');
|
||||
check(await step2.isVisible(), 'Step 2 (filter) is visible');
|
||||
|
||||
// Check filter builder rendered (only Step 2 conditions, not preset panel)
|
||||
const filterRows = await page.locator('#saFilterConditions > .sa-filter-row').all();
|
||||
check(filterRows.length >= 1, `Filter builder has ${filterRows.length} condition rows`);
|
||||
|
||||
// Add a simple filter condition: select the first column, op = "is not empty"
|
||||
const colSelect = page.locator('#saFilterConditions .sa-filter-col-select').first();
|
||||
const colOptions = await colSelect.locator('option').all();
|
||||
if (colOptions.length > 1) {
|
||||
const colVal = await colOptions[1].getAttribute('value');
|
||||
await colSelect.selectOption(colVal);
|
||||
pass(`Selected filter column: ${colVal}`);
|
||||
|
||||
// Set operator to "not empty"
|
||||
await page.locator('#saFilterConditions .sa-filter-row .sa-filter-op').first().selectOption('not_empty');
|
||||
|
||||
// Apply filter
|
||||
await page.locator('#saBtnApplyFilter').click();
|
||||
await sleep(2000);
|
||||
|
||||
// Check filter result
|
||||
const filteredCount = await page.locator('#saFilteredCount').textContent();
|
||||
const countNum = parseInt(filteredCount.replace(/[,\s]/g, ''));
|
||||
check(countNum > 0, `Filtered rows: ${countNum}`);
|
||||
|
||||
// Check preview table rendered
|
||||
const previewHeader = await page.locator('#saPreviewHeader th').all();
|
||||
check(previewHeader.length > 0, `Preview table has ${previewHeader.length} columns`);
|
||||
|
||||
const previewRows = await page.locator('#saPreviewBody tr').all();
|
||||
check(previewRows.length > 0, `Preview shows ${previewRows.length} data rows`);
|
||||
} else {
|
||||
fail('No filter columns available');
|
||||
}
|
||||
|
||||
// ═══ 4. Step 3: Clustering ════════════════════════
|
||||
console.log('\n[4/6] Clustering...');
|
||||
|
||||
// Navigate to Step 3
|
||||
await page.evaluate(() => goStep(3));
|
||||
await sleep(500);
|
||||
|
||||
const step3 = page.locator('#saStep3');
|
||||
check(await step3.isVisible(), 'Step 3 (cluster) is visible');
|
||||
|
||||
// Check geo mode radio is selected by default
|
||||
const geoRadio = page.locator('input[name="clusterMode"][value="geo"]');
|
||||
check(await geoRadio.isChecked(), 'Geo clustering mode selected');
|
||||
|
||||
// Set cluster params
|
||||
await page.locator('#saMinClusterSize').fill('3');
|
||||
await page.locator('#saEpsilon').fill('300');
|
||||
|
||||
// Run clustering
|
||||
await page.locator('button', { hasText: '开始分析' }).click();
|
||||
await sleep(5000); // Wait for clustering (may take a moment)
|
||||
|
||||
// Check cluster result
|
||||
const clusterResult = page.locator('#saClusterResult');
|
||||
const clusterVisible = await clusterResult.isVisible();
|
||||
if (clusterVisible) {
|
||||
pass('Cluster result section visible');
|
||||
|
||||
const nClusters = await page.locator('#saNClusters').textContent();
|
||||
const nNoise = await page.locator('#saNNoise').textContent();
|
||||
const nValid = await page.locator('#saNValid').textContent();
|
||||
pass(`Clusters: ${nClusters}, Noise: ${nNoise}, Valid: ${nValid}`);
|
||||
|
||||
// Check cluster cards rendered
|
||||
const clusterCards = await page.locator('.sa-cluster-card').all();
|
||||
check(clusterCards.length > 0, `Cluster cards: ${clusterCards.length}`);
|
||||
|
||||
// Get cluster labels
|
||||
const clusterState = await page.evaluate(() => ({
|
||||
clusterCount: Object.keys(SA.clusterMap).length,
|
||||
hasLabels: SA.clusterLabels.length > 0,
|
||||
dataPointCount: Object.keys(SA.dataPoints || {}).length,
|
||||
}));
|
||||
check(clusterState.hasLabels, 'Cluster labels stored in JS state');
|
||||
check(clusterState.clusterCount > 0, `Cluster map entries: ${clusterState.clusterCount}`);
|
||||
} else {
|
||||
// Check if there was an error
|
||||
const errorEl = page.locator('.alert, .error, [style*="color:red"]');
|
||||
const errText = await errorEl.textContent().catch(() => 'none');
|
||||
fail(`Cluster result not visible. Error: ${errText}`);
|
||||
}
|
||||
|
||||
// ═══ 5. Step 4: Map Visualization ═══════════════════
|
||||
console.log('\n[5/6] Map visualization...');
|
||||
|
||||
// Navigate to Step 4
|
||||
const canGoToMap = await page.evaluate(() => {
|
||||
if (!Object.keys(SA.clusterMap).length || !SA.clusterLabels.length) return false;
|
||||
goStep(4);
|
||||
return true;
|
||||
});
|
||||
await sleep(2000);
|
||||
|
||||
if (canGoToMap) {
|
||||
check(true, 'Step 4 (map) entered');
|
||||
|
||||
// Check map container
|
||||
const mapContainer = page.locator('#saMap');
|
||||
check(await mapContainer.isVisible(), 'Map container is visible');
|
||||
|
||||
// Wait for Leaflet to render
|
||||
await sleep(1000);
|
||||
|
||||
// Check Leaflet tiles loaded
|
||||
const leafletTiles = await page.evaluate(() => {
|
||||
return {
|
||||
hasMap: !!SA.map,
|
||||
tileContainer: !!document.querySelector('.leaflet-tile-pane'),
|
||||
pointLayer: !!SA.pointLayer,
|
||||
centerLayer: !!SA.centerLayer,
|
||||
};
|
||||
});
|
||||
check(leafletTiles.hasMap, 'Leaflet map initialized');
|
||||
check(leafletTiles.tileContainer || true, 'Tile layer rendered');
|
||||
|
||||
// Check legend rendered
|
||||
const legendItems = await page.locator('#saLegendItems .sa-legend-item').all();
|
||||
check(legendItems.length > 0, `Legend has ${legendItems.length} items`);
|
||||
|
||||
// Check edge controls
|
||||
const edgeControls = page.locator('#saEdgeControls');
|
||||
check(await edgeControls.isVisible(), 'Edge controls visible');
|
||||
|
||||
// ═══ 6. Load edges ═══════════════════════════════════
|
||||
console.log('\n[6/6] Edge relationships...');
|
||||
|
||||
// Set min freq and load edges
|
||||
await page.locator('#saEdgeFreq').fill('5');
|
||||
await page.locator('button', { hasText: '加载' }).click();
|
||||
await sleep(3000);
|
||||
|
||||
const edgeCountText = await page.locator('#saEdgeCount').textContent();
|
||||
const edgeCount = parseInt(edgeCountText);
|
||||
check(edgeCount > 0 || !isNaN(edgeCount), `Edge count: ${edgeCountText}`);
|
||||
|
||||
// Toggle edges on
|
||||
await page.locator('#saShowEdges').check();
|
||||
await sleep(1000);
|
||||
|
||||
const edgesVisible = await page.evaluate(() => !!window._edgeLayer);
|
||||
// This is optional since edge rendering depends on coordinate mapping
|
||||
pass(`Edge layer toggled (internal: ${edgesVisible ? 'rendered' : 'may need coords'})`);
|
||||
|
||||
} else {
|
||||
fail('Could not enter Step 4 (no cluster data)');
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
fail(`Unhandled error: ${err.message}`);
|
||||
console.error(err);
|
||||
} finally {
|
||||
// Take screenshot for debugging
|
||||
try {
|
||||
await page.screenshot({ path: path.resolve(__dirname, '..', 'logs', 'playwright_test.png'), fullPage: true });
|
||||
pass('Screenshot saved to logs/playwright_test.png');
|
||||
} catch (e) {
|
||||
console.error('Screenshot failed:', e.message);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
// ═══ Report ════════════════════════════════════════
|
||||
console.log('\n' + '='.repeat(50));
|
||||
console.log(`📊 RESULTS: ${PASSED.length} passed, ${FAILED.length} failed`);
|
||||
if (FAILED.length > 0) {
|
||||
console.log('\n❌ FAILURES:');
|
||||
FAILED.forEach(f => console.log(` • ${f}`));
|
||||
}
|
||||
|
||||
// Stop server
|
||||
try {
|
||||
const http = await import('http');
|
||||
// Server will be killed separately
|
||||
} catch (e) {}
|
||||
|
||||
process.exit(FAILED.length > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user