Step8/9 UX: feature_start 改 QComboBox + 多源 CSV 优先回退

This commit is contained in:
DXC
2026-06-29 14:40:39 +08:00
parent b82efc1e52
commit e121b02e34
3 changed files with 474 additions and 37 deletions

View File

@ -0,0 +1,228 @@
# -*- coding: utf-8 -*-
"""
Smoke test: step8_ml_train_panel + step9_ml_predict_panel 的新 CSV→QComboBox 联动
& update_from_config 多源文件优先回退 逻辑。
不真正 import 整个 panel避免 osgeo 重依赖),只 import 类本身,验证:
1) Step8MlTrainPanel.feature_start 变成 QComboBox
2) Step8._on_training_csv_changed 正确读 CSV 表头 + 默认选中 374.285
3) Step8._resolve_training_csv_from_workdir 优先级Step7 > 10_WaterIndex > Step6 > Step7 兜底)
4) Step8.get_config / set_config / get_training_params 用 QComboBox API 不崩
5) Step9._resolve_latest_wqi_test_csv 找到正确的 WQI 测试集
6) Step9.update_from_config 优先级WQI CSV > Step4 raw
用 offscreen 模式避免弹窗,完整可重复跑。
"""
import os
import sys
import tempfile
from pathlib import Path
# 避免 panel 顶部 osgeo 导入阻塞
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
# 让 src 目录可 import
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import pandas as pd
import numpy as np
from PyQt5.QtWidgets import QApplication
# 必须先 create QApplication 才能 new widget
app = QApplication.instance() or QApplication(sys.argv)
# ============================================================
# 准备 fixture模拟工作目录
# ============================================================
def make_fake_csv(path, n_cols_50_band, extra_wqi_cols=None, n_rows=10):
"""生成模拟 CSV50 个纯数字波段列 + 任意额外 WQI 列。"""
band_cols = [374.285 + i for i in range(n_cols_50_band)]
# 浮点列名会被 pandas 自动转成 374.285 / 374.285001 / ...
cols = band_cols + (extra_wqi_cols or [])
data = np.random.RandomState(42).rand(n_rows, len(cols))
df = pd.DataFrame(data, columns=cols)
df.to_csv(path, index=False)
return df
with tempfile.TemporaryDirectory() as tmp:
work_dir = tmp
print(f"[fixture] work_dir = {work_dir}")
# 1) Step 6 原始光谱
os.makedirs(os.path.join(work_dir, "6_Spectral_Feature_Extraction"), exist_ok=True)
step6_csv = os.path.join(work_dir, "6_Spectral_Feature_Extraction", "training_spectra.csv")
make_fake_csv(step6_csv, 50)
# 2) Step 7 WQI 增强版
os.makedirs(os.path.join(work_dir, "7_Water_Quality_Indices"), exist_ok=True)
step7_csv = os.path.join(work_dir, "7_Water_Quality_Indices", "training_spectra_indices.csv")
wqi_names = [f"WQI_Chla_{i}" for i in range(10)] + [f"WQI_TP_{i}" for i in range(5)]
make_fake_csv(step7_csv, 50, wqi_names)
# 3) 10_WaterIndex_CSV 下的 training csv较旧 mtime
os.makedirs(os.path.join(work_dir, "10_WaterIndex_CSV"), exist_ok=True)
step10_csv = os.path.join(work_dir, "10_WaterIndex_CSV", "training_watercolor_summary.csv")
make_fake_csv(step10_csv, 50, wqi_names)
# 把 mtime 调成 3 年前,确认 7_Water_Quality_Indices 那个更新
old_time = (pd.Timestamp.now() - pd.Timedelta(days=365 * 3)).timestamp()
os.utime(step10_csv, (old_time, old_time))
# 4) Step 4 raw sampling
os.makedirs(os.path.join(work_dir, "4_sampling"), exist_ok=True)
step4_csv = os.path.join(work_dir, "4_sampling", "sampling_spectra.csv")
make_fake_csv(step4_csv, 50)
# 5) WQI 测试集10_WaterIndex_CSV 下另一个 mtime 较新的 csv
wqi_test_csv = os.path.join(work_dir, "10_WaterIndex_CSV", "sampling_watercolor_summary.csv")
make_fake_csv(wqi_test_csv, 50, wqi_names)
# 设为 1 小时前mtime 比 step7_csv 新)
new_time = (pd.Timestamp.now() - pd.Timedelta(hours=1)).timestamp()
os.utime(wqi_test_csv, (new_time, new_time))
# 6) 9_ML_Prediction 输出目录
os.makedirs(os.path.join(work_dir, "9_ML_Prediction"), exist_ok=True)
# ============================================================
# 测试 1Step8 加载 + QComboBox 化验证
# ============================================================
from src.gui.panels.step8_ml_train_panel import Step8MlTrainPanel
from PyQt5.QtWidgets import QComboBox
panel8 = Step8MlTrainPanel()
assert isinstance(panel8.feature_start, QComboBox), \
f"FAIL: feature_start 应为 QComboBox实际为 {type(panel8.feature_start)}"
print(f"[OK] Step8.feature_start 类型: {type(panel8.feature_start).__name__}")
# ============================================================
# 测试 2CSV 变更槽 — 模拟用户选 WQI CSV自动填表头
# ============================================================
panel8._on_training_csv_changed(step7_csv)
assert panel8.feature_start.count() > 0
head_text = panel8.feature_start.currentText()
print(f"[OK] Step8 选中 step7_csv 后feature_start.currentText() = {head_text!r}")
# 默认选中:第一个纯数字波段列 "374.285"
assert head_text.replace('.', '').lstrip('-').isdigit(), \
f"FAIL: 默认应选数字波段,实际 = {head_text!r}"
print(f"[OK] 默认选中数字波段列: {head_text!r}")
# 验证前 50 列已填充
n_items = panel8.feature_start.count()
assert n_items >= 50, f"FAIL: 应至少 50 项,实际 {n_items}"
print(f"[OK] Step8.feature_start 填充了 {n_items} 个候选列(前 50 列)")
# ============================================================
# 测试 3CSV 不存在时combo 回到占位项
# ============================================================
panel8._on_training_csv_changed("/nonexistent/path.csv")
assert panel8.feature_start.count() == 1
assert "请先选择训练 CSV" in panel8.feature_start.itemText(0)
print(f"[OK] Step8 CSV 不存在时combo 回退到占位项: {panel8.feature_start.itemText(0)!r}")
# ============================================================
# 测试 4get_config / set_config / get_training_params 用 QComboBox API 不崩
# ============================================================
panel8._on_training_csv_changed(step7_csv)
cfg = panel8.get_config()
assert 'feature_start_column' in cfg
assert isinstance(cfg['feature_start_column'], str)
assert cfg['feature_start_column'].replace('.', '').lstrip('-').isdigit()
print(f"[OK] Step8.get_config()['feature_start_column'] = {cfg['feature_start_column']!r}")
# set_config 试一下(先选个不存在的列名,应该走兜底 addItem 分支)
panel8.set_config({'feature_start_column': '999.999_unknown'})
# 重新触发 CSV 加载,验证回放不污染
panel8._on_training_csv_changed(step7_csv)
panel8.set_config({'feature_start_column': '374.285'})
assert panel8.feature_start.currentText() == '374.285'
print(f"[OK] Step8.set_config() 正确把 374.285 选中")
# get_training_params
params = panel8.get_training_params()
assert 'feature_start' in params
assert isinstance(params['feature_start'], float)
print(f"[OK] Step8.get_training_params()['feature_start'] = {params['feature_start']}")
# ============================================================
# 测试 5_resolve_training_csv_from_workdir 优先级
# ============================================================
panel8.work_dir = work_dir
resolved = panel8._resolve_training_csv_from_workdir()
expected = step7_csv.replace('\\', '/')
assert resolved == expected, f"FAIL: 应优先 step7, 实际 {resolved!r}, 期望 {expected!r}"
print(f"[OK] Step8._resolve_training_csv_from_workdir() = {resolved}")
print(f" 优先级 1: Step 7 WQI 增强版 ✓")
# 模拟用户没跑过 Step 7删掉 step7_csv 目录)— 但 10_WaterIndex_CSV 还在
import shutil
shutil.rmtree(os.path.join(work_dir, "7_Water_Quality_Indices"))
resolved = panel8._resolve_training_csv_from_workdir()
# 用户明确提到 10_WaterIndex_CSV 目录下的结果是有效来源,应作为第二优先级
expected_step10 = str(Path(step10_csv)).replace('\\', '/')
assert resolved == expected_step10, \
f"FAIL: 应取 10_WaterIndex_CSV/*training*, 实际 {resolved!r}, 期望 {expected_step10!r}"
print(f"[OK] Step 7 缺省时,回退到 10_WaterIndex_CSV/*training*: {resolved}")
print(f" 优先级 2: 10_WaterIndex_CSV/*training* ✓")
# 再删 10_WaterIndex_CSV 才回退到 Step 6
shutil.rmtree(os.path.join(work_dir, "10_WaterIndex_CSV"))
resolved = panel8._resolve_training_csv_from_workdir()
assert resolved == step6_csv.replace('\\', '/'), \
f"FAIL: 应回退 step6, 实际 {resolved!r}, 期望 {step6_csv!r}"
print(f"[OK] Step 7 / 10_WaterIndex_CSV 都缺省时,回退到 Step 6: {resolved}")
print(f" 优先级 3: Step 6 原始光谱 ✓")
# ============================================================
# 测试 6Step9 加载
# ============================================================
from src.gui.panels.step9_ml_predict_panel import Step9MlPredictPanel
panel9 = Step9MlPredictPanel()
print(f"[OK] Step9MlPredictPanel 实例化成功")
# 重建 10_WaterIndex_CSVWQI 测试集 fixturetest 5 中被删了)
os.makedirs(os.path.join(work_dir, "10_WaterIndex_CSV"), exist_ok=True)
wqi_test_csv = os.path.join(work_dir, "10_WaterIndex_CSV", "sampling_watercolor_summary.csv")
make_fake_csv(wqi_test_csv, 50, wqi_names)
new_time = (pd.Timestamp.now() - pd.Timedelta(hours=1)).timestamp()
os.utime(wqi_test_csv, (new_time, new_time))
# ============================================================
# 测试 7Step9._resolve_latest_wqi_test_csv 优先 10_WaterIndex_CSV
# ============================================================
panel9.work_dir = work_dir
resolved = panel9._resolve_latest_wqi_test_csv()
# 应该是 sampling_watercolor_summary.csvmtime 较新)
expected = wqi_test_csv.replace('\\', '/')
assert resolved == expected, \
f"FAIL: 应取 mtime 最新的 wqi 测试集, 实际 {resolved!r}, 期望 {expected!r}"
print(f"[OK] Step9._resolve_latest_wqi_test_csv() = {resolved}")
print(f" 优先级10_WaterIndex_CSV/sampling_watercolor_summary.csv ✓")
# ============================================================
# 测试 8Step9.update_from_config 优先级WQI CSV > Step4 raw
# ============================================================
# 注Step 4 回退路径依赖 main_window._panel_factoryoffline 测试无 main_window
# 故仅验证 WQI CSV 优先级主路径Step 4 路径与原代码同源,逻辑没变)
# 有 WQI 时优先 WQI
os.makedirs(os.path.join(work_dir, "10_WaterIndex_CSV"), exist_ok=True)
wqi_test_csv2 = os.path.join(work_dir, "10_WaterIndex_CSV", "sampling_watercolor_summary.csv")
make_fake_csv(wqi_test_csv2, 50, wqi_names)
panel9.update_from_config(work_dir=work_dir, pipeline=None)
path = panel9.sampling_csv_file.get_path()
assert wqi_test_csv2.replace('\\', '/') in path, \
f"FAIL: 应优先 WQI CSV, 实际 {path!r}"
print(f"[OK] Step9.update_from_config() 优先 WQI CSV: {path}")
# ============================================================
# 测试 9work_dir 不存在时,所有 helper 安全返回
# ============================================================
panel9.work_dir = ""
assert panel9._resolve_latest_wqi_test_csv() == ""
print(f"[OK] work_dir 空时 _resolve_latest_wqi_test_csv 安全返回空串")
print("\n" + "=" * 60)
print("ALL SMOKE TESTS PASSED")
print("=" * 60)