Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6373c243b | ||
|
|
dc7c8ada96 | ||
|
|
0741c961e6 | ||
|
|
9769d397b9 |
@@ -102,3 +102,46 @@ The "Preferences" button opens the native macOS `NSPrintPanel` (not CUPS web UI
|
|||||||
- Epson color bypass: `EPIJ_CMat=3` (Off / No Color Adjustment)
|
- Epson color bypass: `EPIJ_CMat=3` (Off / No Color Adjustment)
|
||||||
- Canon color bypass: `CNIJIntent2=4` or `CNIJIntent=4`
|
- Canon color bypass: `CNIJIntent2=4` or `CNIJIntent=4`
|
||||||
- Gutenprint: `StpColorCorrection=Uncorrected`
|
- Gutenprint: `StpColorCorrection=Uncorrected`
|
||||||
|
|
||||||
|
## Verification History & Printer Drift Tracking (#95)
|
||||||
|
|
||||||
|
- Historical verification runs are stored in `verification_history.json` in the app data directory.
|
||||||
|
- Record schema (`VerificationRecord` in `src-tauri/src/quality_store.rs`):
|
||||||
|
- `id`: unique record identifier in the format `vr-<epoch_millis>-<seq>`.
|
||||||
|
- `profile_name`: target profile filename.
|
||||||
|
- `printer`: device name captured at print spooling (`wizardState.printerName`), or "Unknown".
|
||||||
|
- `avg_de`, `max_de`, `rms_de`: CIEDE2000 metrics from `profcheck` (using `-u` JSON summary).
|
||||||
|
- `patch_count`: number of test patches evaluated.
|
||||||
|
- `status`: classified status using **ICCery verification bands (issue #95)**:
|
||||||
|
- `< 1.0`: "Excellent" (`badge-excellent`)
|
||||||
|
- `< 2.0`: "Good" (`badge-good`)
|
||||||
|
- `< 3.5`: "Acceptable" (`badge-acceptable`)
|
||||||
|
- `>= 3.5`: "Warning" (`badge-poor`)
|
||||||
|
- `timestamp`: ISO-8601 UTC string.
|
||||||
|
- Max capacity is 500 records; oldest records evicted on overflow.
|
||||||
|
- Atomic file writes (`.tmp` write followed by `rename`) prevent data corruption.
|
||||||
|
- Tauri IPC command casing:
|
||||||
|
- Nested struct fields (`VerificationRecord`) serialize with `snake_case`.
|
||||||
|
- Top-level Tauri command arguments use `camelCase` (e.g. `savePath`, `record`, `profileName`).
|
||||||
|
- Drift history UI in Stage 5 features an interactive SVG trend chart with ICCery verification reference bands, consecutive-breach alert card (requires $\ge 2$ consecutive runs $\ge 3.5$ on distinct calendar days or $\ge 1$ hour apart), and RFC-4180 compliant CSV export.
|
||||||
|
|
||||||
|
## Stage 3 XY Automated Scanning Tables (#93)
|
||||||
|
|
||||||
|
- Supports automated XY scanning tables (GretagMacbeth SpectroScan, X-Rite i1iO) in Stage 3 `chartread`.
|
||||||
|
- Hardware detection in `instlist` flags devices matching `/spectro\s?scan|i1io/i` with `data-xy="1"` and `· XY Table` label suffix.
|
||||||
|
- Runtime auto-detection activates when any XY-specific prompt is classified from `chartread` stdout (supporting i1iO units reporting as i1Pro).
|
||||||
|
- XY State Machine additions:
|
||||||
|
- `STATE.TABLE_PLACE_SHEET`: Prompts user to place sheet on table; button displays "✓ Sheet Placed — Continue".
|
||||||
|
- `STATE.TABLE_ALIGN`: Prompts user to align measurement head with target fiducial patches (`locate patch <ID> with sight`); button displays "✓ Aligned — Continue".
|
||||||
|
- Two-line prompt handling & sticky state:
|
||||||
|
- Argyll `chartread.c` splits XY prompts across two lines (prompt line followed by `hit return to continue...`).
|
||||||
|
- While in `TABLE_PLACE_SHEET` or `TABLE_ALIGN`, subsequent continuation lines remain sticky in that table state, preserving the custom button label and preventing regression to generic `PROMPT_CONTINUE`.
|
||||||
|
- Button behaviors:
|
||||||
|
- `btnAccept`: in `TABLE_*` states, sends `\n` without forcing `STATE.READING`; the state machine advances naturally when Argyll emits the next prompt.
|
||||||
|
- `btnCancel`: in `TABLE_*` states or when an XY table is active, sends `q\n` first to allow the hardware to park its measurement head gracefully before terminating the process.
|
||||||
|
- Multi-sheet and final sheet notice:
|
||||||
|
- Multi-sheet targets are measured within a single `chartread` process lifecycle; sheet changes transition through `TABLE_PLACE_SHEET` without opening the Stage 3 multi-pass averaging panel.
|
||||||
|
- `Please remove last sheet from table` is emitted by Argyll right before writing `.ti3` and exiting; it is classified as an info-only notice (`isRemoveSheetNotice: true`) and does not prompt for user input.
|
||||||
|
- Testing:
|
||||||
|
- Pure line classification unit tests live in `src/js/chartread.test.js` (executable directly in Node or browser console).
|
||||||
|
- Unix/macOS mock script `src-tauri/argyll/mocks/chartread.mock` supports `--xy` flag (or `MOCK_XY_TABLE=1`) with blocking `read` calls simulating calibration, sheet placement, fiducial alignment, and scanning.
|
||||||
|
|||||||
+4
-4
@@ -97,7 +97,6 @@ ICCery is a native, cross-platform desktop application built with:
|
|||||||
### Milestone 9 — macOS Native Support & Enhanced Print Spooling (`v0.4.0`)
|
### Milestone 9 — macOS Native Support & Enhanced Print Spooling (`v0.4.0`)
|
||||||
- [ ] **macOS Platform Bundle**: Build and sign universal macOS `.dmg` bundles with notarization.
|
- [ ] **macOS Platform Bundle**: Build and sign universal macOS `.dmg` bundles with notarization.
|
||||||
- [ ] **macOS Raw Spooling**: Native CoreGraphics/CUPS raw print dialog bypass.
|
- [ ] **macOS Raw Spooling**: Native CoreGraphics/CUPS raw print dialog bypass.
|
||||||
- [ ] **SpectroScan & Automated Table Support (#93)**: Support XY automated scanning tables (i1iO / SpectroScan) in `chartread` (deferred).
|
|
||||||
|
|
||||||
### Milestone 13 — UI/UX & Workflow Polish
|
### Milestone 13 — UI/UX & Workflow Polish
|
||||||
|
|
||||||
@@ -108,6 +107,7 @@ ICCery is a native, cross-platform desktop application built with:
|
|||||||
- [x] **3D Gamut Viewer Controls (#185)**: Camera reset, opacity sliders, keyboard shortcut, and full public-API JSDoc.
|
- [x] **3D Gamut Viewer Controls (#185)**: Camera reset, opacity sliders, keyboard shortcut, and full public-API JSDoc.
|
||||||
- [x] **Gamut / Profcheck Hardening (#179)**: Validate `.gam` vertex/face parsing, improved `profcheck` regex fallbacks for legacy text output, and user-visible parser warnings.
|
- [x] **Gamut / Profcheck Hardening (#179)**: Validate `.gam` vertex/face parsing, improved `profcheck` regex fallbacks for legacy text output, and user-visible parser warnings.
|
||||||
|
|
||||||
### Milestone 12 — Future Workflow & Advanced Analytics (Deferred)
|
### Milestone 12 — Future Workflow & Advanced Analytics
|
||||||
- [ ] **Batch Verification & Drift Tracking (#95)**: Track printer drift over time by comparing periodic verification measurements against a baseline profile.
|
- [x] **Printer Drift Tracking & Verification Analytics (#95)**: Track longitudinal printer drift in Stage 5 over time with CIEDE2000 trend charts, breach alert banners, RFC-4180 CSV export, and history storage.
|
||||||
- [ ] **Multi-Language Localization (#96)**: Full UI internationalization (English, German, French, Japanese).
|
- [x] **XY Automated Scanning Tables (#93)**: Full Stage 3 support for automated XY scanning tables (SpectroScan, i1iO) with multi-line prompt classification, fiducial alignment, and sheet placement checklist.
|
||||||
|
- [ ] ~~**Multi-Language Localization (#96)**~~: *Closed — Won't Fix* (English UI retained as standard color-management terminology).
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "iccery",
|
"name": "iccery",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.8.1",
|
"version": "0.8.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"fetch-argyll": "node scripts/fetch-argyll.mjs",
|
"fetch-argyll": "node scripts/fetch-argyll.mjs",
|
||||||
|
|||||||
Generated
+1
-1
@@ -1423,7 +1423,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iccery"
|
name = "iccery"
|
||||||
version = "0.8.1"
|
version = "0.8.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"image",
|
"image",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "iccery"
|
name = "iccery"
|
||||||
version = "0.8.1"
|
version = "0.8.2"
|
||||||
description = "Modern Printer Profiling UI frontend for ArgyllCMS"
|
description = "Modern Printer Profiling UI frontend for ArgyllCMS"
|
||||||
authors = ["Gordon"]
|
authors = ["Gordon"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -2,6 +2,51 @@
|
|||||||
# Mock script for chartread -u
|
# Mock script for chartread -u
|
||||||
# This script simulates the behaviour of chartread for testing purposes.
|
# This script simulates the behaviour of chartread for testing purposes.
|
||||||
|
|
||||||
|
# Check for --xy argument or MOCK_XY_TABLE environment variable
|
||||||
|
IS_XY=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
if [ "$arg" = "--xy" ]; then
|
||||||
|
IS_XY=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$IS_XY" = "1" ] || [ "${MOCK_XY_TABLE}" = "1" ]; then
|
||||||
|
echo "Place instrument on calibration tile and hit [Space] to calibrate."
|
||||||
|
read -r _calib
|
||||||
|
echo "Calibration successful."
|
||||||
|
|
||||||
|
echo "Please place sheet 1 of 1 on the table"
|
||||||
|
echo "hit return to continue, Esc or 'q' to give up"
|
||||||
|
read -r _sheet1
|
||||||
|
|
||||||
|
echo "locate patch A1 with the sight,"
|
||||||
|
echo "then hit return to continue"
|
||||||
|
read -r _fid1
|
||||||
|
|
||||||
|
echo "locate patch B24 with the sight,"
|
||||||
|
echo "then hit return to continue"
|
||||||
|
read -r _fid2
|
||||||
|
|
||||||
|
echo "Reading sheet 1..."
|
||||||
|
sleep 0.5
|
||||||
|
|
||||||
|
# Emit mock JSON for strip A
|
||||||
|
cat << 'EOF'
|
||||||
|
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2, "patch_count": 3, "patches": [{"id": "1", "loc": "A1", "is_pad": false, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}}, {"id": "2", "loc": "A2", "is_pad": false, "device": [10.0, 60.0, 90.0], "expected": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}}, {"id": "3", "loc": "A3", "is_pad": true, "device": [100.0, 100.0, 100.0]}]}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Emit mock JSON for strip B
|
||||||
|
cat << 'EOF'
|
||||||
|
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2, "patch_count": 2, "patches": [{"id": "4", "loc": "B1", "is_pad": false, "device": [100.0, 0.0, 0.0], "expected": {"Lab": [40.0, 40.0, 40.0]}, "measured": {"Lab": [38.0, 41.0, 39.0]}}, {"id": "5", "loc": "B2", "is_pad": false, "device": [0.0, 100.0, 0.0], "expected": {"Lab": [80.0, -50.0, 50.0]}, "measured": {"Lab": [79.0, -49.0, 51.0]}}]}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "Sheet 1 of 1 read OK"
|
||||||
|
echo "Please remove last sheet from table"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Handheld / strip reader simulation
|
||||||
echo "Place instrument on calibration tile and hit [Space] to calibrate."
|
echo "Place instrument on calibration tile and hit [Space] to calibrate."
|
||||||
|
|
||||||
# We don't really wait for input, just wait 1 second
|
# We don't really wait for input, just wait 1 second
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Mock script for profcheck
|
# Mock script for profcheck
|
||||||
# Simulates profcheck verification output
|
# Simulates real ArgyllCMS profcheck -v -k -s -u output
|
||||||
|
|
||||||
echo "profcheck: Checking profile accuracy..."
|
echo "profcheck: Checking profile accuracy..."
|
||||||
|
echo "No of test patches = 52"
|
||||||
sleep 1
|
sleep 1
|
||||||
cat << 'EOF'
|
cat << 'EOF'
|
||||||
{"event": "profcheck_complete", "avg_de": 0.85, "max_de": 2.41, "rms_de": 1.02}
|
{"event": "report", "peak_de2000": 2.41, "avg_de2000": 0.85, "rms": 1.02}
|
||||||
EOF
|
EOF
|
||||||
echo "Summary:"
|
echo "Profile check complete, errors(CIEDE2000): max. = 2.41, avg. = 0.85, RMS = 1.02"
|
||||||
echo " avg. dE = 0.85"
|
|
||||||
echo " max. dE = 2.41"
|
|
||||||
echo " rms. dE = 1.02"
|
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -425,6 +425,34 @@ pub async fn select_target_file(
|
|||||||
rx.await.map_err(|e| format!("Dialog channel error: {}", e))
|
rx.await.map_err(|e| format!("Dialog channel error: {}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn select_csv_save_path(
|
||||||
|
app: AppHandle,
|
||||||
|
default_name: Option<String>,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
|
||||||
|
let mut builder = app.dialog().file().add_filter("CSV", &["csv"]);
|
||||||
|
if let Some(ref name) = default_name {
|
||||||
|
if !name.trim().is_empty() {
|
||||||
|
let filename = if name.to_lowercase().ends_with(".csv") {
|
||||||
|
name.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}.csv", name)
|
||||||
|
};
|
||||||
|
builder = builder.set_file_name(filename);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
|
builder.save_file(move |file_path| {
|
||||||
|
let res = file_path.map(|p| p.to_string());
|
||||||
|
let _ = tx.send(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
rx.await.map_err(|e| format!("Dialog channel error: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn select_directory(
|
pub async fn select_directory(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
@@ -1084,6 +1112,7 @@ pub fn build_profcheck_args(config: &ProfcheckConfig) -> Vec<String> {
|
|||||||
"-v".to_string(),
|
"-v".to_string(),
|
||||||
"-k".to_string(),
|
"-k".to_string(),
|
||||||
"-s".to_string(),
|
"-s".to_string(),
|
||||||
|
"-u".to_string(),
|
||||||
config.ti3_path.clone(),
|
config.ti3_path.clone(),
|
||||||
config.icc_path.clone(),
|
config.icc_path.clone(),
|
||||||
]
|
]
|
||||||
@@ -1868,7 +1897,7 @@ mod tests {
|
|||||||
cwd: "/home/user".to_string(),
|
cwd: "/home/user".to_string(),
|
||||||
};
|
};
|
||||||
let args = build_profcheck_args(&config);
|
let args = build_profcheck_args(&config);
|
||||||
assert_eq!(args, vec!["-v", "-k", "-s", "my_profile.ti3", "my_profile.icc"]);
|
assert_eq!(args, vec!["-v", "-k", "-s", "-u", "my_profile.ti3", "my_profile.icc"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ mod commands;
|
|||||||
mod events;
|
mod events;
|
||||||
mod print;
|
mod print;
|
||||||
mod process_manager;
|
mod process_manager;
|
||||||
|
mod quality_store;
|
||||||
mod settings;
|
mod settings;
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
@@ -74,6 +75,11 @@ pub fn run() {
|
|||||||
commands::get_printer_capabilities,
|
commands::get_printer_capabilities,
|
||||||
commands::show_printer_properties,
|
commands::show_printer_properties,
|
||||||
commands::print_target_native,
|
commands::print_target_native,
|
||||||
|
commands::select_csv_save_path,
|
||||||
|
quality_store::save_verification_record,
|
||||||
|
quality_store::get_verification_history,
|
||||||
|
quality_store::clear_verification_history,
|
||||||
|
quality_store::export_verification_history_csv,
|
||||||
settings::load_settings,
|
settings::load_settings,
|
||||||
settings::save_settings,
|
settings::save_settings,
|
||||||
settings::get_all_presets,
|
settings::get_all_presets,
|
||||||
|
|||||||
@@ -0,0 +1,421 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
use tauri::{AppHandle, Manager};
|
||||||
|
|
||||||
|
static SEQ_COUNTER: AtomicU64 = AtomicU64::new(1);
|
||||||
|
|
||||||
|
const HISTORY_CAP: usize = 1000;
|
||||||
|
const STORE_VERSION: u32 = 1;
|
||||||
|
const MAX_STRING_LEN: usize = 200;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||||
|
pub struct VerificationRecord {
|
||||||
|
pub id: String, // backend-generated only: "vr-<epoch_millis>-<seq>"
|
||||||
|
pub timestamp: String, // ISO 8601 string
|
||||||
|
pub printer_name: String,
|
||||||
|
pub profile_name: String,
|
||||||
|
pub avg_de: f64,
|
||||||
|
pub max_de: f64,
|
||||||
|
pub rms_de: f64,
|
||||||
|
pub patch_count: u32,
|
||||||
|
pub status: String, // backend ALWAYS fills via classify_status
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||||
|
pub struct VerificationHistoryStore {
|
||||||
|
pub version: u32,
|
||||||
|
pub records: Vec<VerificationRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for VerificationHistoryStore {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
version: STORE_VERSION,
|
||||||
|
records: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generates a unique record identifier: "vr-<epoch_millis>-<seq>"
|
||||||
|
pub fn generate_record_id() -> String {
|
||||||
|
let millis = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_millis())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let seq = SEQ_COUNTER.fetch_add(1, Ordering::SeqCst);
|
||||||
|
format!("vr-{}-{}", millis, seq)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classifies status into ICCery verification bands (issue #95):
|
||||||
|
/// - < 1.0: "excellent"
|
||||||
|
/// - < 2.0: "good"
|
||||||
|
/// - < 3.5: "acceptable"
|
||||||
|
/// - >= 3.5: "warning"
|
||||||
|
pub fn classify_status(avg_de: f64) -> &'static str {
|
||||||
|
if avg_de < 1.0 {
|
||||||
|
"excellent"
|
||||||
|
} else if avg_de < 2.0 {
|
||||||
|
"good"
|
||||||
|
} else if avg_de < 3.5 {
|
||||||
|
"acceptable"
|
||||||
|
} else {
|
||||||
|
"warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates record data constraints.
|
||||||
|
pub fn validate_record(record: &VerificationRecord) -> Result<(), String> {
|
||||||
|
if record.profile_name.trim().is_empty() {
|
||||||
|
return Err("Profile name cannot be empty.".to_string());
|
||||||
|
}
|
||||||
|
if record.profile_name.len() > MAX_STRING_LEN {
|
||||||
|
return Err(format!("Profile name exceeds maximum length of {} characters.", MAX_STRING_LEN));
|
||||||
|
}
|
||||||
|
if record.printer_name.len() > MAX_STRING_LEN {
|
||||||
|
return Err(format!("Printer name exceeds maximum length of {} characters.", MAX_STRING_LEN));
|
||||||
|
}
|
||||||
|
if record.timestamp.trim().is_empty() {
|
||||||
|
return Err("Timestamp cannot be empty.".to_string());
|
||||||
|
}
|
||||||
|
if !record.avg_de.is_finite() || record.avg_de < 0.0 {
|
||||||
|
return Err("Average ΔE must be a finite non-negative number.".to_string());
|
||||||
|
}
|
||||||
|
if !record.max_de.is_finite() || record.max_de < 0.0 {
|
||||||
|
return Err("Peak ΔE must be a finite non-negative number.".to_string());
|
||||||
|
}
|
||||||
|
if !record.rms_de.is_finite() || record.rms_de < 0.0 {
|
||||||
|
return Err("RMS ΔE must be a finite non-negative number.".to_string());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads verification history from a JSON file.
|
||||||
|
/// Missing, unreadable, or corrupted files safely return an empty vector.
|
||||||
|
pub fn load_history_file(path: &Path) -> Vec<VerificationRecord> {
|
||||||
|
if !path.exists() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let content = match fs::read_to_string(path) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => return Vec::new(),
|
||||||
|
};
|
||||||
|
match serde_json::from_str::<VerificationHistoryStore>(&content) {
|
||||||
|
Ok(store) => store.records,
|
||||||
|
Err(_) => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes verification records to the specified JSON path.
|
||||||
|
/// Automatically creates parent directories if needed.
|
||||||
|
/// Evicts oldest records by timestamp if count exceeds HISTORY_CAP (1,000).
|
||||||
|
pub fn write_history_file(path: &Path, records: &[VerificationRecord]) -> Result<(), String> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut bounded_records: Vec<VerificationRecord> = records.to_vec();
|
||||||
|
// Sort by timestamp ascending
|
||||||
|
bounded_records.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
|
||||||
|
|
||||||
|
if bounded_records.len() > HISTORY_CAP {
|
||||||
|
let excess = bounded_records.len() - HISTORY_CAP;
|
||||||
|
bounded_records.drain(0..excess);
|
||||||
|
}
|
||||||
|
|
||||||
|
let store = VerificationHistoryStore {
|
||||||
|
version: STORE_VERSION,
|
||||||
|
records: bounded_records,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string_pretty(&store)
|
||||||
|
.map_err(|e| format!("Failed to serialize verification history: {}", e))?;
|
||||||
|
fs::write(path, json).map_err(|e| format!("Failed to write verification history: {}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper to escape CSV text fields according to RFC-4180.
|
||||||
|
fn escape_csv_field(val: &str) -> String {
|
||||||
|
if val.contains('"') || val.contains(',') || val.contains('\n') || val.contains('\r') {
|
||||||
|
format!("\"{}\"", val.replace('"', "\"\""))
|
||||||
|
} else {
|
||||||
|
val.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats records into standard RFC-4180 CSV string.
|
||||||
|
pub fn to_csv(records: &[VerificationRecord]) -> String {
|
||||||
|
let mut csv = String::from("id,timestamp,printer_name,profile_name,avg_de,max_de,rms_de,patch_count,status\r\n");
|
||||||
|
for r in records {
|
||||||
|
csv.push_str(&format!(
|
||||||
|
"{},{},{},{},{:.4},{:.4},{:.4},{},{}\r\n",
|
||||||
|
escape_csv_field(&r.id),
|
||||||
|
escape_csv_field(&r.timestamp),
|
||||||
|
escape_csv_field(&r.printer_name),
|
||||||
|
escape_csv_field(&r.profile_name),
|
||||||
|
r.avg_de,
|
||||||
|
r.max_de,
|
||||||
|
r.rms_de,
|
||||||
|
r.patch_count,
|
||||||
|
escape_csv_field(&r.status),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
csv
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates CSV destination file path.
|
||||||
|
pub fn validate_csv_dest(dest: &str) -> Result<PathBuf, String> {
|
||||||
|
let trimmed = dest.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err("Destination path cannot be empty.".to_string());
|
||||||
|
}
|
||||||
|
let mut path = PathBuf::from(trimmed);
|
||||||
|
if path.extension().is_none() || path.extension().unwrap_or_default() != "csv" {
|
||||||
|
path.set_extension("csv");
|
||||||
|
}
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_store_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||||
|
let app_data = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||||
|
Ok(app_data.join("verification_history.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tauri Commands
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn save_verification_record(
|
||||||
|
app: AppHandle,
|
||||||
|
mut record: VerificationRecord,
|
||||||
|
) -> Result<VerificationRecord, String> {
|
||||||
|
record.id = generate_record_id();
|
||||||
|
record.status = classify_status(record.avg_de).to_string();
|
||||||
|
validate_record(&record)?;
|
||||||
|
|
||||||
|
let path = get_store_path(&app)?;
|
||||||
|
let mut records = load_history_file(&path);
|
||||||
|
records.push(record.clone());
|
||||||
|
write_history_file(&path, &records)?;
|
||||||
|
|
||||||
|
Ok(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_verification_history(
|
||||||
|
app: AppHandle,
|
||||||
|
profile_name: Option<String>,
|
||||||
|
printer_name: Option<String>,
|
||||||
|
) -> Result<Vec<VerificationRecord>, String> {
|
||||||
|
let path = get_store_path(&app)?;
|
||||||
|
let mut records = load_history_file(&path);
|
||||||
|
|
||||||
|
if let Some(ref prof) = profile_name {
|
||||||
|
let prof_clean = prof.trim();
|
||||||
|
if !prof_clean.is_empty() {
|
||||||
|
records.retain(|r| r.profile_name == prof_clean);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref prn) = printer_name {
|
||||||
|
let prn_clean = prn.trim();
|
||||||
|
if !prn_clean.is_empty() {
|
||||||
|
records.retain(|r| r.printer_name == prn_clean);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return chronological order (ascending timestamp)
|
||||||
|
records.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
|
||||||
|
Ok(records)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn clear_verification_history(app: AppHandle) -> Result<(), String> {
|
||||||
|
let path = get_store_path(&app)?;
|
||||||
|
write_history_file(&path, &[])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn export_verification_history_csv(
|
||||||
|
app: AppHandle,
|
||||||
|
dest_path: String,
|
||||||
|
profile_name: Option<String>,
|
||||||
|
printer_name: Option<String>,
|
||||||
|
) -> Result<usize, String> {
|
||||||
|
let validated_path = validate_csv_dest(&dest_path)?;
|
||||||
|
let records = get_verification_history(app, profile_name, printer_name)?;
|
||||||
|
let count = records.len();
|
||||||
|
let csv_content = to_csv(&records);
|
||||||
|
|
||||||
|
if let Some(parent) = validated_path.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| format!("Failed to create CSV parent directory: {}", e))?;
|
||||||
|
}
|
||||||
|
fs::write(&validated_path, csv_content)
|
||||||
|
.map_err(|e| format!("Failed to write CSV file: {}", e))?;
|
||||||
|
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Unit Tests (Path-based, no AppHandle required)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
fn sample_record(id: &str, ts: &str, avg: f64) -> VerificationRecord {
|
||||||
|
VerificationRecord {
|
||||||
|
id: id.to_string(),
|
||||||
|
timestamp: ts.to_string(),
|
||||||
|
printer_name: "Canon PRO-1000".to_string(),
|
||||||
|
profile_name: "ProLustre_Photo".to_string(),
|
||||||
|
avg_de: avg,
|
||||||
|
max_de: avg * 2.0,
|
||||||
|
rms_de: avg * 1.2,
|
||||||
|
patch_count: 50,
|
||||||
|
status: classify_status(avg).to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_classify_status_bands() {
|
||||||
|
// Boundaries: <1.0 excellent, <2.0 good, <3.5 acceptable, >=3.5 warning
|
||||||
|
assert_eq!(classify_status(0.0), "excellent");
|
||||||
|
assert_eq!(classify_status(0.999), "excellent");
|
||||||
|
assert_eq!(classify_status(1.0), "good");
|
||||||
|
assert_eq!(classify_status(1.999), "good");
|
||||||
|
assert_eq!(classify_status(2.0), "acceptable");
|
||||||
|
assert_eq!(classify_status(3.499), "acceptable");
|
||||||
|
assert_eq!(classify_status(3.5), "warning");
|
||||||
|
assert_eq!(classify_status(5.2), "warning");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_record_valid_and_invalid() {
|
||||||
|
let valid = sample_record("vr-1", "2026-09-05T12:00:00Z", 0.85);
|
||||||
|
assert!(validate_record(&valid).is_ok());
|
||||||
|
|
||||||
|
let mut empty_prof = valid.clone();
|
||||||
|
empty_prof.profile_name = " ".to_string();
|
||||||
|
assert!(validate_record(&empty_prof).is_err());
|
||||||
|
|
||||||
|
let mut empty_ts = valid.clone();
|
||||||
|
empty_ts.timestamp = "".to_string();
|
||||||
|
assert!(validate_record(&empty_ts).is_err());
|
||||||
|
|
||||||
|
let mut negative_de = valid.clone();
|
||||||
|
negative_de.avg_de = -0.1;
|
||||||
|
assert!(validate_record(&negative_de).is_err());
|
||||||
|
|
||||||
|
let mut nan_de = valid.clone();
|
||||||
|
nan_de.max_de = f64::NAN;
|
||||||
|
assert!(validate_record(&nan_de).is_err());
|
||||||
|
|
||||||
|
let mut inf_de = valid.clone();
|
||||||
|
inf_de.rms_de = f64::INFINITY;
|
||||||
|
assert!(validate_record(&inf_de).is_err());
|
||||||
|
|
||||||
|
let mut long_name = valid.clone();
|
||||||
|
long_name.profile_name = "a".repeat(201);
|
||||||
|
assert!(validate_record(&long_name).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_store_load_missing_and_corrupt() {
|
||||||
|
let temp_dir = std::env::temp_dir().join("iccery_test_quality_store_corrupt");
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
|
||||||
|
let missing_path = temp_dir.join("nonexistent.json");
|
||||||
|
let records = load_history_file(&missing_path);
|
||||||
|
assert!(records.is_empty());
|
||||||
|
|
||||||
|
let corrupt_path = temp_dir.join("corrupt.json");
|
||||||
|
fs::write(&corrupt_path, "{ broken json ... ").unwrap();
|
||||||
|
let records_corrupt = load_history_file(&corrupt_path);
|
||||||
|
assert!(records_corrupt.is_empty());
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_store_write_and_roundtrip() {
|
||||||
|
let temp_dir = std::env::temp_dir().join("iccery_test_quality_store_roundtrip");
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
|
||||||
|
let file_path = temp_dir.join("verification_history.json");
|
||||||
|
let rec1 = sample_record("vr-1", "2026-09-01T10:00:00Z", 0.75);
|
||||||
|
let rec2 = sample_record("vr-2", "2026-09-02T10:00:00Z", 1.85);
|
||||||
|
|
||||||
|
write_history_file(&file_path, &[rec1.clone(), rec2.clone()]).unwrap();
|
||||||
|
let loaded = load_history_file(&file_path);
|
||||||
|
assert_eq!(loaded.len(), 2);
|
||||||
|
assert_eq!(loaded[0], rec1);
|
||||||
|
assert_eq!(loaded[1], rec2);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cap_eviction_drops_oldest_by_timestamp() {
|
||||||
|
let temp_dir = std::env::temp_dir().join("iccery_test_quality_store_eviction");
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
|
||||||
|
let file_path = temp_dir.join("capped_history.json");
|
||||||
|
|
||||||
|
// Generate 1005 records with disordered timestamps
|
||||||
|
let mut records = Vec::new();
|
||||||
|
for i in 0..1005 {
|
||||||
|
// ts ranges from 1000 to 2004
|
||||||
|
let ts = format!("2026-01-01T{:04}Z", i);
|
||||||
|
records.push(sample_record(&format!("vr-{}", i), &ts, 1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shuffle slightly so input is out of order
|
||||||
|
records.swap(0, 500);
|
||||||
|
|
||||||
|
write_history_file(&file_path, &records).unwrap();
|
||||||
|
let loaded = load_history_file(&file_path);
|
||||||
|
|
||||||
|
assert_eq!(loaded.len(), HISTORY_CAP); // 1000 records
|
||||||
|
// Oldest 5 records (ts 0000..0004) should have been evicted
|
||||||
|
assert_eq!(loaded[0].timestamp, "2026-01-01T0005Z");
|
||||||
|
assert_eq!(loaded[loaded.len() - 1].timestamp, "2026-01-01T1004Z");
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_to_csv_escaping_and_formatting() {
|
||||||
|
let mut rec1 = sample_record("vr-1", "2026-09-05T12:00:00Z", 0.85);
|
||||||
|
rec1.printer_name = "Canon \"Pro\" 1000, Tray 1".to_string(); // quotes + comma
|
||||||
|
rec1.profile_name = "FineArt, Velvet".to_string();
|
||||||
|
|
||||||
|
let csv = to_csv(&[rec1]);
|
||||||
|
let lines: Vec<&str> = csv.split("\r\n").filter(|l| !l.is_empty()).collect();
|
||||||
|
assert_eq!(lines.len(), 2);
|
||||||
|
assert_eq!(lines[0], "id,timestamp,printer_name,profile_name,avg_de,max_de,rms_de,patch_count,status");
|
||||||
|
assert!(lines[1].contains("\"Canon \"\"Pro\"\" 1000, Tray 1\""));
|
||||||
|
assert!(lines[1].contains("\"FineArt, Velvet\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_csv_dest() {
|
||||||
|
assert!(validate_csv_dest(" ").is_err());
|
||||||
|
|
||||||
|
let p1 = validate_csv_dest("/tmp/history.csv").unwrap();
|
||||||
|
assert_eq!(p1.extension().unwrap(), "csv");
|
||||||
|
|
||||||
|
let p2 = validate_csv_dest("/tmp/history").unwrap();
|
||||||
|
assert_eq!(p2.extension().unwrap(), "csv");
|
||||||
|
assert_eq!(p2.to_string_lossy(), "/tmp/history.csv");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "ICCery",
|
"productName": "ICCery",
|
||||||
"version": "0.8.1",
|
"version": "0.8.2",
|
||||||
"identifier": "com.gronod.iccery",
|
"identifier": "com.gronod.iccery",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../src"
|
"frontendDist": "../src"
|
||||||
|
|||||||
+58
-1
@@ -559,6 +559,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- XY Scanning Table Panel -->
|
||||||
|
<div id="xyTableHint" class="notification-banner info hidden">
|
||||||
|
<span>ℹ️</span>
|
||||||
|
<span>SpectroScan → use the SS layout in Stage 2; i1iO → use i1/i1Pro2 layout — the table is auto-detected when chartread starts.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="xyTablePanel" class="xy-table-panel hidden">
|
||||||
|
<div class="xy-table-header">
|
||||||
|
<h4>XY Automated Scanning Table Sequence</h4>
|
||||||
|
<span id="xyTableActiveStepBadge" class="status-badge badge-idle">Standby</span>
|
||||||
|
</div>
|
||||||
|
<ol class="xy-steps-list">
|
||||||
|
<li id="xyStepPlace" class="xy-step">Place sheet on table</li>
|
||||||
|
<li id="xyStepAlign" class="xy-step">Align reference patches with sight</li>
|
||||||
|
<li id="xyStepScan" class="xy-step">Automated scan in progress</li>
|
||||||
|
<li id="xyStepRemove" class="xy-step">Remove sheet from table</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- State & Prompt Area -->
|
<!-- State & Prompt Area -->
|
||||||
<div class="chartread-status">
|
<div class="chartread-status">
|
||||||
<div class="status-label">State: <span id="chartreadState">IDLE</span></div>
|
<div class="status-label">State: <span id="chartreadState">IDLE</span></div>
|
||||||
@@ -804,6 +823,44 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Verification History & Drift Tracking -->
|
||||||
|
<details class="log-container drift-history" id="driftHistorySection">
|
||||||
|
<summary>Verification History & Drift Tracking</summary>
|
||||||
|
<div class="notification-banner warning hidden" id="driftAlertCard">
|
||||||
|
<span id="driftAlertIcon">⚠️</span><span id="driftAlertText"></span>
|
||||||
|
</div>
|
||||||
|
<div class="drift-filter-row hidden" id="driftFilterRow">
|
||||||
|
<label for="driftPrinterFilter">Filter by Printer:</label>
|
||||||
|
<select id="driftPrinterFilter">
|
||||||
|
<option value="">All Printers</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="driftChartWrap" class="drift-chart-wrap">
|
||||||
|
<svg id="driftTrendChart" role="img" aria-label="Printer Drift Verification History"></svg>
|
||||||
|
</div>
|
||||||
|
<p id="driftEmptyState" class="help-hint">No verification history yet for this profile.</p>
|
||||||
|
<div class="drift-table-wrap">
|
||||||
|
<table id="verificationHistoryTable" class="drift-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Printer</th>
|
||||||
|
<th>Avg ΔE₀₀</th>
|
||||||
|
<th>Peak ΔE₀₀</th>
|
||||||
|
<th>RMS ΔE₀₀</th>
|
||||||
|
<th>Patches</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="verificationHistoryTbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button type="button" class="secondary" id="btnExportHistoryCsv" disabled>Export CSV…</button>
|
||||||
|
<button type="button" class="danger" id="btnClearHistory" disabled>Clear History</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
<!-- 3D Gamut Viewer -->
|
<!-- 3D Gamut Viewer -->
|
||||||
<div class="gamut-viewer-section">
|
<div class="gamut-viewer-section">
|
||||||
<div class="gamut-viewer-header">
|
<div class="gamut-viewer-header">
|
||||||
@@ -950,7 +1007,7 @@
|
|||||||
<img src="./assets/ICCery-logo.svg" alt="ICCery Logo" class="about-logo" />
|
<img src="./assets/ICCery-logo.svg" alt="ICCery Logo" class="about-logo" />
|
||||||
</div>
|
</div>
|
||||||
<h2>About ICCery</h2>
|
<h2>About ICCery</h2>
|
||||||
<p><strong>Version:</strong> <span id="aboutVersion">v0.8.1</span> • <strong>Build Date:</strong> <span id="aboutBuildDate">September 2026</span></p>
|
<p><strong>Version:</strong> <span id="aboutVersion">v0.8.2</span> • <strong>Build Date:</strong> <span id="aboutBuildDate">September 2026</span></p>
|
||||||
<p><strong>Copyright © 2026 Gordon Bolton. All rights reserved.</strong></p>
|
<p><strong>Copyright © 2026 Gordon Bolton. All rights reserved.</strong></p>
|
||||||
<h3>Licences & EULA</h3>
|
<h3>Licences & EULA</h3>
|
||||||
<div class="license-text-container">
|
<div class="license-text-container">
|
||||||
|
|||||||
+423
-50
@@ -45,7 +45,7 @@ export function populateStage3TargetContext(metadata) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// State machine states
|
// State machine states
|
||||||
const STATE = {
|
export const STATE = {
|
||||||
IDLE: "IDLE",
|
IDLE: "IDLE",
|
||||||
CALIBRATING: "CALIBRATING",
|
CALIBRATING: "CALIBRATING",
|
||||||
AWAITING_STRIP: "AWAITING_STRIP",
|
AWAITING_STRIP: "AWAITING_STRIP",
|
||||||
@@ -53,13 +53,242 @@ const STATE = {
|
|||||||
ALL_STRIPS_READ: "ALL_STRIPS_READ",
|
ALL_STRIPS_READ: "ALL_STRIPS_READ",
|
||||||
WARNING: "WARNING",
|
WARNING: "WARNING",
|
||||||
PROMPT_CONTINUE: "PROMPT_CONTINUE",
|
PROMPT_CONTINUE: "PROMPT_CONTINUE",
|
||||||
|
TABLE_PLACE_SHEET: "TABLE_PLACE_SHEET",
|
||||||
|
TABLE_ALIGN: "TABLE_ALIGN",
|
||||||
ERROR: "ERROR",
|
ERROR: "ERROR",
|
||||||
FINISHED: "FINISHED",
|
FINISHED: "FINISHED",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classifies a line of stdout from chartread into a state transition, prompt, and metadata.
|
||||||
|
* Pure function with no side-effects or DOM interaction.
|
||||||
|
*
|
||||||
|
* @param {string} line - Raw stdout line
|
||||||
|
* @param {string} currentState - The current STATE value
|
||||||
|
* @returns {{ state: string, prompt: string, matched: boolean, meta?: object }}
|
||||||
|
*/
|
||||||
|
export function classifyChartreadLine(line, currentState = STATE.IDLE) {
|
||||||
|
if (typeof line !== "string") {
|
||||||
|
return { state: currentState, prompt: "", matched: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineTrim = line.trim();
|
||||||
|
const lineLower = lineTrim.toLowerCase();
|
||||||
|
|
||||||
|
if (!lineTrim) {
|
||||||
|
return { state: currentState, prompt: "", matched: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Info-only: Remove last sheet notice (emitted by Argyll before writing .ti3 and exiting)
|
||||||
|
if (lineLower.includes("remove last sheet from table") || lineLower.includes("remove last sheet")) {
|
||||||
|
return {
|
||||||
|
state: currentState,
|
||||||
|
prompt: lineTrim,
|
||||||
|
matched: true,
|
||||||
|
meta: { isRemoveSheetNotice: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Info-only: Sheet read OK
|
||||||
|
const sheetOkMatch = lineTrim.match(/sheet\s+(\d+)\s+of\s+(\d+)\s+read\s+ok/i);
|
||||||
|
if (sheetOkMatch) {
|
||||||
|
return {
|
||||||
|
state: currentState,
|
||||||
|
prompt: lineTrim,
|
||||||
|
matched: true,
|
||||||
|
meta: {
|
||||||
|
sheetOk: true,
|
||||||
|
sheet: parseInt(sheetOkMatch[1], 10),
|
||||||
|
totalSheets: parseInt(sheetOkMatch[2], 10),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fiducial alignment prompt (XY table)
|
||||||
|
// e.g. "locate patch A1 with the sight," or "locate patch 1 with the sight"
|
||||||
|
const alignMatch = lineTrim.match(/locate\s+patch\s+([A-Za-z0-9_]+)\s+with\s+(?:the\s+)?sight/i);
|
||||||
|
if (alignMatch) {
|
||||||
|
return {
|
||||||
|
state: STATE.TABLE_ALIGN,
|
||||||
|
prompt: lineTrim,
|
||||||
|
matched: true,
|
||||||
|
meta: { patch: alignMatch[1] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (lineLower.includes("locate patch") && lineLower.includes("sight")) {
|
||||||
|
const fallbackMatch = lineTrim.match(/locate\s+patch\s+([^\s,]+)/i);
|
||||||
|
return {
|
||||||
|
state: STATE.TABLE_ALIGN,
|
||||||
|
prompt: lineTrim,
|
||||||
|
matched: true,
|
||||||
|
meta: { patch: fallbackMatch ? fallbackMatch[1] : "" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Sheet placement prompt (XY table)
|
||||||
|
// e.g. "Please place sheet 1 of 1 on the table" or "Please remove previous sheet and place sheet 2 of 2 on the table"
|
||||||
|
const placeMatch = lineTrim.match(/place\s+sheet\s+(\d+)\s+of\s+(\d+)/i);
|
||||||
|
if (placeMatch) {
|
||||||
|
return {
|
||||||
|
state: STATE.TABLE_PLACE_SHEET,
|
||||||
|
prompt: lineTrim,
|
||||||
|
matched: true,
|
||||||
|
meta: {
|
||||||
|
sheet: parseInt(placeMatch[1], 10),
|
||||||
|
totalSheets: parseInt(placeMatch[2], 10),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (lineLower.includes("place sheet") || lineLower.includes("remove previous sheet")) {
|
||||||
|
return {
|
||||||
|
state: STATE.TABLE_PLACE_SHEET,
|
||||||
|
prompt: lineTrim,
|
||||||
|
matched: true,
|
||||||
|
meta: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Continuation lines ("hit return to continue...", etc.)
|
||||||
|
// Real Argyll XY prompts are two lines:
|
||||||
|
// Line 1: "locate patch A1 with the sight,"
|
||||||
|
// Line 2: "then hit return to continue"
|
||||||
|
// When line 2 arrives, if we are in TABLE_PLACE_SHEET or TABLE_ALIGN, we MUST remain sticky in that table state!
|
||||||
|
const isContinuePrompt =
|
||||||
|
(lineLower.includes("hit return to continue") ||
|
||||||
|
lineLower.includes("then hit return to continue") ||
|
||||||
|
lineLower.includes("hit return to continue, esc or 'q' to give up")) &&
|
||||||
|
!lineLower.includes("use it anyway");
|
||||||
|
|
||||||
|
if (isContinuePrompt) {
|
||||||
|
if (currentState === STATE.TABLE_PLACE_SHEET) {
|
||||||
|
return {
|
||||||
|
state: STATE.TABLE_PLACE_SHEET,
|
||||||
|
prompt: lineTrim,
|
||||||
|
matched: true,
|
||||||
|
meta: { isContinuation: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (currentState === STATE.TABLE_ALIGN) {
|
||||||
|
return {
|
||||||
|
state: STATE.TABLE_ALIGN,
|
||||||
|
prompt: lineTrim,
|
||||||
|
matched: true,
|
||||||
|
meta: { isContinuation: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
state: STATE.PROMPT_CONTINUE,
|
||||||
|
prompt: lineTrim,
|
||||||
|
matched: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Strip / measurement completed signals
|
||||||
|
if (
|
||||||
|
lineLower.includes("'d' if done") ||
|
||||||
|
lineLower.includes("'d' when done") ||
|
||||||
|
lineLower.includes("d if done") ||
|
||||||
|
lineLower.includes("d when done") ||
|
||||||
|
lineLower.includes("d to finish") ||
|
||||||
|
lineLower.includes("d to save") ||
|
||||||
|
lineLower.includes("all strips read") ||
|
||||||
|
lineLower.includes("all patches read") ||
|
||||||
|
lineLower.includes("done reading")
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
state: STATE.ALL_STRIPS_READ,
|
||||||
|
prompt: lineTrim,
|
||||||
|
matched: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Warning prompts (e.g. unexpected response, use it anyway)
|
||||||
|
if (
|
||||||
|
lineLower.includes("(warning)") ||
|
||||||
|
lineLower.includes("use it anyway") ||
|
||||||
|
lineLower.includes("seem to have read strip pass") ||
|
||||||
|
lineLower.includes("unexpected response")
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
state: STATE.WARNING,
|
||||||
|
prompt: lineTrim,
|
||||||
|
matched: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Calibration prompts
|
||||||
|
if (
|
||||||
|
((lineLower.includes("place") &&
|
||||||
|
(lineLower.includes("reference") ||
|
||||||
|
lineLower.includes("white") ||
|
||||||
|
lineLower.includes("calibrat") ||
|
||||||
|
lineLower.includes("standard"))) ||
|
||||||
|
lineLower.includes("hit any key to continue") ||
|
||||||
|
lineLower.includes("calibration")) &&
|
||||||
|
!lineLower.includes("place sheet") &&
|
||||||
|
!lineLower.includes("locate patch")
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
state: STATE.CALIBRATING,
|
||||||
|
prompt: lineTrim,
|
||||||
|
matched: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Ready to read / Strip trigger (handheld / strip readers)
|
||||||
|
if (
|
||||||
|
((lineLower.includes("hit") && lineLower.includes("read") && lineLower.includes("strip")) ||
|
||||||
|
lineLower.includes("ready to read") ||
|
||||||
|
(lineLower.includes("read") && lineLower.includes("strip") && lineLower.includes("key"))) &&
|
||||||
|
!lineLower.includes("all strips read")
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
state: STATE.AWAITING_STRIP,
|
||||||
|
prompt: lineTrim,
|
||||||
|
matched: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. Reading / Scanning
|
||||||
|
if (
|
||||||
|
lineLower.includes("reading strip") ||
|
||||||
|
lineLower.includes("processing") ||
|
||||||
|
lineLower.includes("scanning") ||
|
||||||
|
lineLower.includes("reading sheet")
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
state: STATE.READING,
|
||||||
|
prompt: lineTrim,
|
||||||
|
matched: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11. Errors
|
||||||
|
if (
|
||||||
|
lineLower.includes("error") ||
|
||||||
|
lineLower.includes("too fast") ||
|
||||||
|
lineLower.includes("too slow") ||
|
||||||
|
lineLower.includes("misread") ||
|
||||||
|
lineLower.includes("failed to read")
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
state: STATE.ERROR,
|
||||||
|
prompt: lineTrim,
|
||||||
|
matched: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: currentState,
|
||||||
|
prompt: lineTrim,
|
||||||
|
matched: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
let currentState = STATE.IDLE;
|
let currentState = STATE.IDLE;
|
||||||
let currentProcessId = "";
|
let currentProcessId = "";
|
||||||
let measurementInProgress = false;
|
let measurementInProgress = false;
|
||||||
|
let xyTableDetected = false;
|
||||||
let currentPassIndex = 0;
|
let currentPassIndex = 0;
|
||||||
const recordedPasses = [];
|
const recordedPasses = [];
|
||||||
|
|
||||||
@@ -83,6 +312,82 @@ export function initChartread() {
|
|||||||
const passCounterBadge = document.getElementById("passCounterBadge");
|
const passCounterBadge = document.getElementById("passCounterBadge");
|
||||||
const btnMeasureAnotherSheet = document.getElementById("btnMeasureAnotherSheet");
|
const btnMeasureAnotherSheet = document.getElementById("btnMeasureAnotherSheet");
|
||||||
const btnFinishAndAverage = document.getElementById("btnFinishAndAverage");
|
const btnFinishAndAverage = document.getElementById("btnFinishAndAverage");
|
||||||
|
const xyTableHint = document.getElementById("xyTableHint");
|
||||||
|
const xyTablePanel = document.getElementById("xyTablePanel");
|
||||||
|
const xyTableActiveStepBadge = document.getElementById("xyTableActiveStepBadge");
|
||||||
|
const xyStepPlace = document.getElementById("xyStepPlace");
|
||||||
|
const xyStepAlign = document.getElementById("xyStepAlign");
|
||||||
|
const xyStepScan = document.getElementById("xyStepScan");
|
||||||
|
const xyStepRemove = document.getElementById("xyStepRemove");
|
||||||
|
|
||||||
|
function setXyTableVisible(visible) {
|
||||||
|
if (xyTableHint) {
|
||||||
|
if (visible) xyTableHint.classList.remove("hidden");
|
||||||
|
else xyTableHint.classList.add("hidden");
|
||||||
|
}
|
||||||
|
if (xyTablePanel) {
|
||||||
|
if (visible) xyTablePanel.classList.remove("hidden");
|
||||||
|
else xyTablePanel.classList.add("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateXyTableSequence(phase) {
|
||||||
|
if (!xyTablePanel) return;
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{ el: xyStepPlace, name: "place" },
|
||||||
|
{ el: xyStepAlign, name: "align" },
|
||||||
|
{ el: xyStepScan, name: "scan" },
|
||||||
|
{ el: xyStepRemove, name: "remove" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const phaseOrder = ["place", "align", "scan", "remove", "done"];
|
||||||
|
const targetIndex = phaseOrder.indexOf(phase);
|
||||||
|
|
||||||
|
steps.forEach((step, idx) => {
|
||||||
|
if (!step.el) return;
|
||||||
|
step.el.classList.remove("active", "completed");
|
||||||
|
if (targetIndex >= 0) {
|
||||||
|
if (idx < targetIndex) {
|
||||||
|
step.el.classList.add("completed");
|
||||||
|
} else if (idx === targetIndex && phase !== "done") {
|
||||||
|
step.el.classList.add("active");
|
||||||
|
} else if (phase === "done") {
|
||||||
|
step.el.classList.add("completed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (xyTableActiveStepBadge) {
|
||||||
|
xyTableActiveStepBadge.className = "status-badge";
|
||||||
|
switch (phase) {
|
||||||
|
case "place":
|
||||||
|
xyTableActiveStepBadge.textContent = "Step 1: Place Sheet";
|
||||||
|
xyTableActiveStepBadge.classList.add("badge-primary");
|
||||||
|
break;
|
||||||
|
case "align":
|
||||||
|
xyTableActiveStepBadge.textContent = "Step 2: Align Patches";
|
||||||
|
xyTableActiveStepBadge.classList.add("badge-primary");
|
||||||
|
break;
|
||||||
|
case "scan":
|
||||||
|
xyTableActiveStepBadge.textContent = "Step 3: Scanning";
|
||||||
|
xyTableActiveStepBadge.classList.add("badge-primary");
|
||||||
|
break;
|
||||||
|
case "remove":
|
||||||
|
xyTableActiveStepBadge.textContent = "Step 4: Remove Sheet";
|
||||||
|
xyTableActiveStepBadge.classList.add("badge-primary");
|
||||||
|
break;
|
||||||
|
case "done":
|
||||||
|
xyTableActiveStepBadge.textContent = "Complete";
|
||||||
|
xyTableActiveStepBadge.classList.add("badge-good");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
xyTableActiveStepBadge.textContent = "Standby";
|
||||||
|
xyTableActiveStepBadge.classList.add("badge-idle");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setMeasurementBusy(busy) {
|
function setMeasurementBusy(busy) {
|
||||||
measurementInProgress = busy;
|
measurementInProgress = busy;
|
||||||
@@ -182,7 +487,13 @@ export function initChartread() {
|
|||||||
const opt = document.createElement("option");
|
const opt = document.createElement("option");
|
||||||
// Port value for -c switch. If port is 1 or auto, empty string leaves -c omitted for default port
|
// Port value for -c switch. If port is 1 or auto, empty string leaves -c omitted for default port
|
||||||
opt.value = inst.port && inst.port !== "1" ? inst.port : "";
|
opt.value = inst.port && inst.port !== "1" ? inst.port : "";
|
||||||
|
const isXy = /spectro\s?scan|i1io/i.test(inst.name) || /spectro\s?scan|i1io/i.test(inst.type);
|
||||||
|
if (isXy) {
|
||||||
|
opt.dataset.xy = "1";
|
||||||
|
opt.textContent = `${inst.type || inst.name}${inst.port ? ` (Port ${inst.port})` : ""} · XY Table`;
|
||||||
|
} else {
|
||||||
opt.textContent = `${inst.type || inst.name}${inst.port ? ` (Port ${inst.port})` : ""}`;
|
opt.textContent = `${inst.type || inst.name}${inst.port ? ` (Port ${inst.port})` : ""}`;
|
||||||
|
}
|
||||||
instrumentSelect.appendChild(opt);
|
instrumentSelect.appendChild(opt);
|
||||||
});
|
});
|
||||||
instrumentSelect.value = "";
|
instrumentSelect.value = "";
|
||||||
@@ -202,6 +513,21 @@ export function initChartread() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (instrumentSelect) {
|
||||||
|
instrumentSelect.addEventListener("change", () => {
|
||||||
|
const selectedOpt = instrumentSelect.selectedOptions && instrumentSelect.selectedOptions[0];
|
||||||
|
const isXy = Boolean(selectedOpt && selectedOpt.dataset && selectedOpt.dataset.xy === "1");
|
||||||
|
if (isXy) {
|
||||||
|
xyTableDetected = true;
|
||||||
|
setXyTableVisible(true);
|
||||||
|
updateXyTableSequence("standby");
|
||||||
|
} else if (!measurementInProgress) {
|
||||||
|
xyTableDetected = false;
|
||||||
|
setXyTableVisible(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function setState(newState) {
|
function setState(newState) {
|
||||||
currentState = newState;
|
currentState = newState;
|
||||||
if (stateLabel) stateLabel.textContent = newState;
|
if (stateLabel) stateLabel.textContent = newState;
|
||||||
@@ -287,6 +613,22 @@ export function initChartread() {
|
|||||||
}
|
}
|
||||||
if (btnCancel) btnCancel.classList.remove("hidden");
|
if (btnCancel) btnCancel.classList.remove("hidden");
|
||||||
break;
|
break;
|
||||||
|
case STATE.TABLE_PLACE_SHEET:
|
||||||
|
if (btnAccept) {
|
||||||
|
btnAccept.disabled = false;
|
||||||
|
btnAccept.textContent = "✓ Sheet Placed — Continue";
|
||||||
|
btnAccept.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
if (btnCancel) btnCancel.classList.remove("hidden");
|
||||||
|
break;
|
||||||
|
case STATE.TABLE_ALIGN:
|
||||||
|
if (btnAccept) {
|
||||||
|
btnAccept.disabled = false;
|
||||||
|
btnAccept.textContent = "✓ Aligned — Continue";
|
||||||
|
btnAccept.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
if (btnCancel) btnCancel.classList.remove("hidden");
|
||||||
|
break;
|
||||||
case STATE.ERROR:
|
case STATE.ERROR:
|
||||||
if (btnRetry) {
|
if (btnRetry) {
|
||||||
btnRetry.disabled = false;
|
btnRetry.disabled = false;
|
||||||
@@ -339,6 +681,15 @@ export function initChartread() {
|
|||||||
setState(STATE.CALIBRATING);
|
setState(STATE.CALIBRATING);
|
||||||
setPrompt("Starting chartread... waiting for instrument calibration prompt.");
|
setPrompt("Starting chartread... waiting for instrument calibration prompt.");
|
||||||
|
|
||||||
|
const selectedOpt = instrumentSelect && instrumentSelect.selectedOptions && instrumentSelect.selectedOptions[0];
|
||||||
|
if (selectedOpt && selectedOpt.dataset && selectedOpt.dataset.xy === "1") {
|
||||||
|
xyTableDetected = true;
|
||||||
|
setXyTableVisible(true);
|
||||||
|
updateXyTableSequence("standby");
|
||||||
|
} else {
|
||||||
|
xyTableDetected = false;
|
||||||
|
}
|
||||||
|
|
||||||
const selectedPort = instrumentSelect && instrumentSelect.value ? instrumentSelect.value : null;
|
const selectedPort = instrumentSelect && instrumentSelect.value ? instrumentSelect.value : null;
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
@@ -365,64 +716,66 @@ export function initChartread() {
|
|||||||
logPre.textContent += line + "\n";
|
logPre.textContent += line + "\n";
|
||||||
logPre.scrollTop = logPre.scrollHeight;
|
logPre.scrollTop = logPre.scrollHeight;
|
||||||
|
|
||||||
// Parse prompts for state transitions
|
const classified = classifyChartreadLine(line, currentState);
|
||||||
const lineLower = line.toLowerCase();
|
|
||||||
|
|
||||||
|
if (classified.matched) {
|
||||||
if (
|
if (
|
||||||
lineLower.includes("'d' if done") ||
|
classified.state === STATE.TABLE_PLACE_SHEET ||
|
||||||
lineLower.includes("'d' when done") ||
|
classified.state === STATE.TABLE_ALIGN ||
|
||||||
lineLower.includes("d if done") ||
|
(classified.meta && (classified.meta.isRemoveSheetNotice || classified.meta.sheetOk))
|
||||||
lineLower.includes("d when done") ||
|
|
||||||
lineLower.includes("d to finish") ||
|
|
||||||
lineLower.includes("d to save") ||
|
|
||||||
lineLower.includes("all strips read") ||
|
|
||||||
lineLower.includes("all patches read") ||
|
|
||||||
lineLower.includes("done reading")
|
|
||||||
) {
|
|
||||||
setState(STATE.ALL_STRIPS_READ);
|
|
||||||
setPrompt(`🎉 ${line.trim()} — Click 'Done & Save .ti3' to save.`);
|
|
||||||
} else if (
|
|
||||||
lineLower.includes("(warning)") ||
|
|
||||||
lineLower.includes("use it anyway") ||
|
|
||||||
lineLower.includes("seem to have read strip pass") ||
|
|
||||||
lineLower.includes("unexpected response") ||
|
|
||||||
lineLower.includes("hit return to use it anyway")
|
|
||||||
) {
|
) {
|
||||||
|
if (!xyTableDetected) {
|
||||||
|
xyTableDetected = true;
|
||||||
|
setXyTableVisible(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (xyTableDetected) {
|
||||||
|
if (classified.state === STATE.TABLE_PLACE_SHEET) {
|
||||||
|
updateXyTableSequence("place");
|
||||||
|
} else if (classified.state === STATE.TABLE_ALIGN) {
|
||||||
|
updateXyTableSequence("align");
|
||||||
|
} else if (classified.state === STATE.READING) {
|
||||||
|
updateXyTableSequence("scan");
|
||||||
|
} else if (classified.meta && classified.meta.isRemoveSheetNotice) {
|
||||||
|
updateXyTableSequence("remove");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (classified.state !== currentState) {
|
||||||
|
setState(classified.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (classified.state === STATE.TABLE_PLACE_SHEET) {
|
||||||
|
const sheetInfo = (classified.meta && classified.meta.sheet && classified.meta.totalSheets)
|
||||||
|
? ` (Sheet ${classified.meta.sheet} of ${classified.meta.totalSheets})`
|
||||||
|
: "";
|
||||||
|
setPrompt(`📋 ${classified.prompt}${sheetInfo}`);
|
||||||
|
} else if (classified.state === STATE.TABLE_ALIGN) {
|
||||||
|
const patchInfo = (classified.meta && classified.meta.patch)
|
||||||
|
? ` [Patch ${classified.meta.patch}]`
|
||||||
|
: "";
|
||||||
|
setPrompt(`🎯 ${classified.prompt}${patchInfo}`);
|
||||||
|
} else if (classified.meta && classified.meta.isRemoveSheetNotice) {
|
||||||
|
setPrompt(`ℹ️ ${classified.prompt}`);
|
||||||
|
} else if (classified.meta && classified.meta.sheetOk) {
|
||||||
|
setPrompt(`✅ ${classified.prompt}`);
|
||||||
|
} else if (classified.state === STATE.ALL_STRIPS_READ) {
|
||||||
|
setPrompt(`🎉 ${classified.prompt} — Click 'Done & Save .ti3' to save.`);
|
||||||
|
} else if (classified.state === STATE.WARNING) {
|
||||||
const previousPrompt = promptText ? promptText.textContent.trim() : "";
|
const previousPrompt = promptText ? promptText.textContent.trim() : "";
|
||||||
|
const lineLower = line.toLowerCase();
|
||||||
const isContinuationPrompt = lineLower.includes("hit return to use it anyway") || lineLower.includes("use it anyway");
|
const isContinuationPrompt = lineLower.includes("hit return to use it anyway") || lineLower.includes("use it anyway");
|
||||||
setState(STATE.WARNING);
|
|
||||||
if (currentState === STATE.WARNING && isContinuationPrompt && previousPrompt && !previousPrompt.includes(line.trim())) {
|
if (currentState === STATE.WARNING && isContinuationPrompt && previousPrompt && !previousPrompt.includes(line.trim())) {
|
||||||
setPrompt(`${previousPrompt}\n${line.trim()}`);
|
setPrompt(`${previousPrompt}\n${line.trim()}`);
|
||||||
} else {
|
} else {
|
||||||
setPrompt(line.trim());
|
setPrompt(classified.prompt);
|
||||||
|
}
|
||||||
|
} else if (classified.state === STATE.ERROR) {
|
||||||
|
setPrompt("⚠️ " + classified.prompt);
|
||||||
|
} else {
|
||||||
|
setPrompt(classified.prompt);
|
||||||
}
|
}
|
||||||
} else if (
|
|
||||||
lineLower.includes("place sheet") ||
|
|
||||||
lineLower.includes("remove previous sheet") ||
|
|
||||||
(lineLower.includes("hit return to continue") && !lineLower.includes("use it anyway"))
|
|
||||||
) {
|
|
||||||
setState(STATE.PROMPT_CONTINUE);
|
|
||||||
setPrompt(line.trim());
|
|
||||||
} else if (
|
|
||||||
(lineLower.includes("place") && (lineLower.includes("reference") || lineLower.includes("white") || lineLower.includes("calibrat") || lineLower.includes("standard"))) ||
|
|
||||||
lineLower.includes("hit any key to continue") ||
|
|
||||||
lineLower.includes("calibration")
|
|
||||||
) {
|
|
||||||
setState(STATE.CALIBRATING);
|
|
||||||
setPrompt(line.trim());
|
|
||||||
} else if (
|
|
||||||
(lineLower.includes("hit") && lineLower.includes("read") && lineLower.includes("strip")) ||
|
|
||||||
lineLower.includes("ready to read") ||
|
|
||||||
(lineLower.includes("read") && lineLower.includes("strip") && lineLower.includes("key"))
|
|
||||||
) {
|
|
||||||
setState(STATE.AWAITING_STRIP);
|
|
||||||
setPrompt(line.trim());
|
|
||||||
} else if (lineLower.includes("reading strip") || lineLower.includes("processing")) {
|
|
||||||
setState(STATE.READING);
|
|
||||||
setPrompt(line.trim());
|
|
||||||
} else if (lineLower.includes("error") || lineLower.includes("too fast") || lineLower.includes("too slow") || lineLower.includes("misread") || lineLower.includes("failed to read")) {
|
|
||||||
setState(STATE.ERROR);
|
|
||||||
setPrompt("⚠️ " + line.trim());
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -444,6 +797,9 @@ export function initChartread() {
|
|||||||
stopSwatchListener();
|
stopSwatchListener();
|
||||||
|
|
||||||
if (event.payload.code === 0) {
|
if (event.payload.code === 0) {
|
||||||
|
if (xyTableDetected) {
|
||||||
|
updateXyTableSequence("done");
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const passIndex = currentPassIndex + 1;
|
const passIndex = currentPassIndex + 1;
|
||||||
const filename = await invoke("snapshot_ti3", {
|
const filename = await invoke("snapshot_ti3", {
|
||||||
@@ -615,8 +971,12 @@ export function initChartread() {
|
|||||||
try {
|
try {
|
||||||
btnAccept.disabled = true;
|
btnAccept.disabled = true;
|
||||||
await invoke("send_stdin", { id: currentProcessId, input: "\n" });
|
await invoke("send_stdin", { id: currentProcessId, input: "\n" });
|
||||||
|
if (currentState === STATE.TABLE_PLACE_SHEET || currentState === STATE.TABLE_ALIGN) {
|
||||||
|
setPrompt("Continuing XY table sequence...");
|
||||||
|
} else {
|
||||||
setState(STATE.READING);
|
setState(STATE.READING);
|
||||||
setPrompt("Accepted. Processing...");
|
setPrompt("Accepted. Processing...");
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("send_stdin error:", e);
|
console.error("send_stdin error:", e);
|
||||||
btnAccept.disabled = false;
|
btnAccept.disabled = false;
|
||||||
@@ -682,14 +1042,27 @@ export function initChartread() {
|
|||||||
if (btnCancel) {
|
if (btnCancel) {
|
||||||
btnCancel.addEventListener("click", async () => {
|
btnCancel.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
|
btnCancel.disabled = true;
|
||||||
|
if (currentState === STATE.TABLE_PLACE_SHEET || currentState === STATE.TABLE_ALIGN || xyTableDetected) {
|
||||||
|
// For XY table states, send 'q\n' first to allow the table to park its measurement head gracefully
|
||||||
|
try {
|
||||||
|
await invoke("send_stdin", { id: currentProcessId, input: "q\n" });
|
||||||
|
} catch (_) {}
|
||||||
|
// Brief pause before kill to allow graceful parking
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
await invoke("kill_process", { id: currentProcessId });
|
await invoke("kill_process", { id: currentProcessId });
|
||||||
stopSwatchListener();
|
stopSwatchListener();
|
||||||
setState(recordedPasses.length > 0 ? STATE.FINISHED : STATE.IDLE);
|
setState(recordedPasses.length > 0 ? STATE.FINISHED : STATE.IDLE);
|
||||||
setPrompt("Measurement cancelled.");
|
setPrompt("Measurement cancelled.");
|
||||||
|
if (xyTableDetected) {
|
||||||
|
updateXyTableSequence("standby");
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("kill_process error:", e);
|
console.error("kill_process error:", e);
|
||||||
setPrompt(`Cancel failed: ${e}`);
|
setPrompt(`Cancel failed: ${e}`);
|
||||||
} finally {
|
} finally {
|
||||||
|
btnCancel.disabled = false;
|
||||||
setMeasurementBusy(false);
|
setMeasurementBusy(false);
|
||||||
stopSwatchListener();
|
stopSwatchListener();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
// Unit & console tests for chartread.js stdout classifier and state machine transitions.
|
||||||
|
// Can be run in browser devtools console:
|
||||||
|
// import('./chartread.test.js').then(m => m.runAll())
|
||||||
|
// Or in Node:
|
||||||
|
// node src/js/chartread.test.js
|
||||||
|
|
||||||
|
// Node environment polyfill for browser globals
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
globalThis.window = {
|
||||||
|
__TAURI__: {
|
||||||
|
core: { invoke: () => Promise.resolve() },
|
||||||
|
event: {
|
||||||
|
listen: () => Promise.resolve(() => {}),
|
||||||
|
emit: () => Promise.resolve(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
addEventListener: () => {},
|
||||||
|
dispatchEvent: () => {},
|
||||||
|
};
|
||||||
|
globalThis.document = {
|
||||||
|
getElementById: () => null,
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
createElement: () => ({
|
||||||
|
classList: { add: () => {}, remove: () => {} },
|
||||||
|
style: {},
|
||||||
|
appendChild: () => {},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { STATE, classifyChartreadLine } = await import('./chartread.js');
|
||||||
|
|
||||||
|
export function runAll() {
|
||||||
|
console.group('Chartread Classifier & XY Table Tests');
|
||||||
|
let passed = 0;
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
function assert(actual, expected, message) {
|
||||||
|
total++;
|
||||||
|
const ok = JSON.stringify(actual) === JSON.stringify(expected);
|
||||||
|
if (ok) {
|
||||||
|
console.log('PASS:', message);
|
||||||
|
passed++;
|
||||||
|
} else {
|
||||||
|
console.error('FAIL:', message, '\nExpected:', expected, '\nGot:', actual);
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. XY Sheet Placement prompts
|
||||||
|
const place1 = classifyChartreadLine("Please place sheet 1 of 1 on the table", STATE.CALIBRATING);
|
||||||
|
assert(place1.state, STATE.TABLE_PLACE_SHEET, 'sheet 1 of 1 state TABLE_PLACE_SHEET');
|
||||||
|
assert(place1.matched, true, 'sheet 1 of 1 matched');
|
||||||
|
assert(place1.meta?.sheet, 1, 'sheet 1 of 1 sheet number 1');
|
||||||
|
assert(place1.meta?.totalSheets, 1, 'sheet 1 of 1 total sheets 1');
|
||||||
|
|
||||||
|
const place2 = classifyChartreadLine("Please remove previous sheet and place sheet 2 of 2 on the table", STATE.READING);
|
||||||
|
assert(place2.state, STATE.TABLE_PLACE_SHEET, 'sheet 2 of 2 state TABLE_PLACE_SHEET');
|
||||||
|
assert(place2.meta?.sheet, 2, 'sheet 2 of 2 sheet number 2');
|
||||||
|
assert(place2.meta?.totalSheets, 2, 'sheet 2 of 2 total sheets 2');
|
||||||
|
|
||||||
|
const placeGeneric = classifyChartreadLine("place sheet on table", STATE.IDLE);
|
||||||
|
assert(placeGeneric.state, STATE.TABLE_PLACE_SHEET, 'generic place sheet state TABLE_PLACE_SHEET');
|
||||||
|
|
||||||
|
// 2. XY Fiducial patch alignment prompts
|
||||||
|
const fid1 = classifyChartreadLine("locate patch A1 with the sight,", STATE.TABLE_PLACE_SHEET);
|
||||||
|
assert(fid1.state, STATE.TABLE_ALIGN, 'locate patch A1 state TABLE_ALIGN');
|
||||||
|
assert(fid1.matched, true, 'locate patch A1 matched');
|
||||||
|
assert(fid1.meta?.patch, "A1", 'locate patch A1 meta patch');
|
||||||
|
|
||||||
|
const fid2 = classifyChartreadLine("locate patch B24 with the sight", STATE.TABLE_ALIGN);
|
||||||
|
assert(fid2.state, STATE.TABLE_ALIGN, 'locate patch B24 state TABLE_ALIGN');
|
||||||
|
assert(fid2.meta?.patch, "B24", 'locate patch B24 meta patch');
|
||||||
|
|
||||||
|
const fid3 = classifyChartreadLine("locate patch 1 with sight", STATE.TABLE_ALIGN);
|
||||||
|
assert(fid3.state, STATE.TABLE_ALIGN, 'locate patch 1 state TABLE_ALIGN');
|
||||||
|
assert(fid3.meta?.patch, "1", 'locate patch 1 meta patch');
|
||||||
|
|
||||||
|
// 3. Two-line prompt sticky transitions
|
||||||
|
// A continuation line arriving while in TABLE_PLACE_SHEET must remain in TABLE_PLACE_SHEET
|
||||||
|
const contSheet1 = classifyChartreadLine("hit return to continue, Esc or 'q' to give up", STATE.TABLE_PLACE_SHEET);
|
||||||
|
assert(contSheet1.state, STATE.TABLE_PLACE_SHEET, 'sheet continuation remains in TABLE_PLACE_SHEET');
|
||||||
|
assert(contSheet1.meta?.isContinuation, true, 'sheet continuation flag set');
|
||||||
|
|
||||||
|
const contSheet2 = classifyChartreadLine("then hit return to continue", STATE.TABLE_PLACE_SHEET);
|
||||||
|
assert(contSheet2.state, STATE.TABLE_PLACE_SHEET, 'then hit return remains in TABLE_PLACE_SHEET');
|
||||||
|
|
||||||
|
// A continuation line arriving while in TABLE_ALIGN must remain in TABLE_ALIGN
|
||||||
|
const contAlign1 = classifyChartreadLine("then hit return to continue", STATE.TABLE_ALIGN);
|
||||||
|
assert(contAlign1.state, STATE.TABLE_ALIGN, 'align continuation remains in TABLE_ALIGN');
|
||||||
|
assert(contAlign1.meta?.isContinuation, true, 'align continuation flag set');
|
||||||
|
|
||||||
|
const contAlign2 = classifyChartreadLine("hit return to continue, Esc or 'q' to give up", STATE.TABLE_ALIGN);
|
||||||
|
assert(contAlign2.state, STATE.TABLE_ALIGN, 'align esc/q continuation remains in TABLE_ALIGN');
|
||||||
|
|
||||||
|
// Continuation prompt in strip mode becomes generic PROMPT_CONTINUE
|
||||||
|
const contStrip = classifyChartreadLine("hit return to continue", STATE.READING);
|
||||||
|
assert(contStrip.state, STATE.PROMPT_CONTINUE, 'strip continue transitions to PROMPT_CONTINUE');
|
||||||
|
|
||||||
|
// 4. Final sheet removal notice (Info-only, does not change state or trigger stdin prompt)
|
||||||
|
const removeSheet = classifyChartreadLine("Please remove last sheet from table", STATE.READING);
|
||||||
|
assert(removeSheet.state, STATE.READING, 'remove last sheet notice preserves currentState');
|
||||||
|
assert(removeSheet.matched, true, 'remove last sheet notice is matched');
|
||||||
|
assert(removeSheet.meta?.isRemoveSheetNotice, true, 'remove last sheet notice flag set');
|
||||||
|
|
||||||
|
// 5. Sheet read OK notices
|
||||||
|
const sheetOk = classifyChartreadLine("Sheet 1 of 1 read OK", STATE.READING);
|
||||||
|
assert(sheetOk.matched, true, 'sheet read OK matched');
|
||||||
|
assert(sheetOk.meta?.sheetOk, true, 'sheet read OK flag');
|
||||||
|
assert(sheetOk.meta?.sheet, 1, 'sheet read OK sheet 1');
|
||||||
|
assert(sheetOk.meta?.totalSheets, 1, 'sheet read OK totalSheets 1');
|
||||||
|
|
||||||
|
// 6. Strip mode regressions
|
||||||
|
const calib = classifyChartreadLine("Place instrument on calibration tile and hit [Space] to calibrate.", STATE.IDLE);
|
||||||
|
assert(calib.state, STATE.CALIBRATING, 'strip calibration prompt transitions to CALIBRATING');
|
||||||
|
|
||||||
|
const awaitStrip = classifyChartreadLine("Hit [Space] to read strip A (or 's' to skip).", STATE.CALIBRATING);
|
||||||
|
assert(awaitStrip.state, STATE.AWAITING_STRIP, 'strip prompt transitions to AWAITING_STRIP');
|
||||||
|
|
||||||
|
const readingStrip = classifyChartreadLine("Reading strip A...", STATE.AWAITING_STRIP);
|
||||||
|
assert(readingStrip.state, STATE.READING, 'reading strip transitions to READING');
|
||||||
|
|
||||||
|
const readingSheet = classifyChartreadLine("Reading sheet 1...", STATE.TABLE_ALIGN);
|
||||||
|
assert(readingSheet.state, STATE.READING, 'reading sheet transitions to READING');
|
||||||
|
|
||||||
|
const warning = classifyChartreadLine("Warning: unexpected response from instrument", STATE.READING);
|
||||||
|
assert(warning.state, STATE.WARNING, 'unexpected response transitions to WARNING');
|
||||||
|
|
||||||
|
const warnAnyway = classifyChartreadLine("Hit return to use it anyway", STATE.WARNING);
|
||||||
|
assert(warnAnyway.state, STATE.WARNING, 'use it anyway stays in WARNING');
|
||||||
|
|
||||||
|
const allStripsDone = classifyChartreadLine("All strips read. Hit 'd' when done", STATE.READING);
|
||||||
|
assert(allStripsDone.state, STATE.ALL_STRIPS_READ, 'all strips read transitions to ALL_STRIPS_READ');
|
||||||
|
|
||||||
|
const err = classifyChartreadLine("Fatal error: instrument communication failed", STATE.READING);
|
||||||
|
assert(err.state, STATE.ERROR, 'instrument communication failed transitions to ERROR');
|
||||||
|
|
||||||
|
const unrecognized = classifyChartreadLine("some debug log output [1234]", STATE.READING);
|
||||||
|
assert(unrecognized.state, STATE.READING, 'unrecognized line preserves currentState');
|
||||||
|
assert(unrecognized.matched, false, 'unrecognized line matched is false');
|
||||||
|
|
||||||
|
console.log(`\nResults: ${passed} / ${total} tests passed.`);
|
||||||
|
console.groupEnd();
|
||||||
|
|
||||||
|
if (passed !== total) {
|
||||||
|
throw new Error(`Chartread tests failed: ${total - passed} failure(s)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-run if executed in Node.js
|
||||||
|
if (typeof process !== 'undefined' && process.argv && process.argv[1]?.endsWith('chartread.test.js')) {
|
||||||
|
runAll();
|
||||||
|
}
|
||||||
@@ -574,6 +574,7 @@ export function initPrinttarg() {
|
|||||||
showNotification("error", "Please select a destination printer first.");
|
showNotification("error", "Please select a destination printer first.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
wizardState.printerName = printerName;
|
||||||
|
|
||||||
const options = getSelectedPrintOptions();
|
const options = getSelectedPrintOptions();
|
||||||
const origBtnContent = triggeringButton ? triggeringButton.innerHTML : "";
|
const origBtnContent = triggeringButton ? triggeringButton.innerHTML : "";
|
||||||
@@ -619,6 +620,7 @@ export function initPrinttarg() {
|
|||||||
showNotification("error", "Please select a destination printer first.");
|
showNotification("error", "Please select a destination printer first.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
wizardState.printerName = printerName;
|
||||||
|
|
||||||
const options = getSelectedPrintOptions();
|
const options = getSelectedPrintOptions();
|
||||||
const cwd = stage1Cwd;
|
const cwd = stage1Cwd;
|
||||||
|
|||||||
+525
-107
@@ -14,6 +14,423 @@ export function setStage4Result(basename, cwd) {
|
|||||||
profileBasename = basename || wizardState.basename;
|
profileBasename = basename || wizardState.basename;
|
||||||
profileCwd = cwd || wizardState.cwd;
|
profileCwd = cwd || wizardState.cwd;
|
||||||
wizardState.setTarget(profileBasename, profileCwd);
|
wizardState.setTarget(profileBasename, profileCwd);
|
||||||
|
loadVerificationHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse profcheck output for Average, Peak, and RMS delta-E values and patch count.
|
||||||
|
* Supports Argyll's -u JSON summary object, text summary line, and legacy plain-text output.
|
||||||
|
* @param {string} stdout - Full profcheck stdout.
|
||||||
|
* @returns {{ avgDe: number, maxDe: number, rmsDe: number, patchCount: number, warnings: string[] }}
|
||||||
|
*/
|
||||||
|
export function parseProfcheckReport(stdout) {
|
||||||
|
let avgDe = 0.0;
|
||||||
|
let maxDe = 0.0;
|
||||||
|
let rmsDe = 0.0;
|
||||||
|
let patchCount = 0;
|
||||||
|
const warnings = [];
|
||||||
|
|
||||||
|
if (!stdout || typeof stdout !== 'string') {
|
||||||
|
return { avgDe, maxDe, rmsDe, patchCount, warnings: ['No stdout received from profcheck.'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse patch count from "No of test patches = (\d+)"
|
||||||
|
const patchMatch = stdout.match(/No\s+of\s+test\s+patches\s*=\s*(\d+)/i);
|
||||||
|
if (patchMatch) {
|
||||||
|
patchCount = parseInt(patchMatch[1], 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Argyll's JSON output can appear either as a compact object on a single
|
||||||
|
// line or embedded inside larger text. Accept objects with event === "report"
|
||||||
|
// or containing any of avg_de, avg_de2000, peak_de, peak_de2000, rms, rms_de.
|
||||||
|
const jsonObjects = [];
|
||||||
|
const re = /\{[\s\S]*?\}/g;
|
||||||
|
let m;
|
||||||
|
while ((m = re.exec(stdout)) !== null) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(m[0]);
|
||||||
|
if (typeof parsed === 'object' && parsed !== null) {
|
||||||
|
if (
|
||||||
|
parsed.event === 'report' ||
|
||||||
|
'avg_de' in parsed ||
|
||||||
|
'avg_de2000' in parsed ||
|
||||||
|
'peak_de' in parsed ||
|
||||||
|
'peak_de2000' in parsed ||
|
||||||
|
'rms' in parsed ||
|
||||||
|
'rms_de' in parsed
|
||||||
|
) {
|
||||||
|
jsonObjects.push(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Not a valid JSON object, ignore.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jsonObjects.length > 0) {
|
||||||
|
// When several report objects exist (de2000, de94, de), prefer *de2000 object matching -k
|
||||||
|
const de2000Obj = jsonObjects.find(o => 'avg_de2000' in o || 'peak_de2000' in o);
|
||||||
|
const targetJson = de2000Obj || jsonObjects[jsonObjects.length - 1];
|
||||||
|
|
||||||
|
avgDe = typeof targetJson.avg_de2000 === 'number' ? targetJson.avg_de2000 :
|
||||||
|
(typeof targetJson.avg_de === 'number' ? targetJson.avg_de : 0);
|
||||||
|
maxDe = typeof targetJson.peak_de2000 === 'number' ? targetJson.peak_de2000 :
|
||||||
|
(typeof targetJson.max_de === 'number' ? targetJson.max_de :
|
||||||
|
(typeof targetJson.peak_de === 'number' ? targetJson.peak_de : 0));
|
||||||
|
rmsDe = typeof targetJson.rms === 'number' ? targetJson.rms :
|
||||||
|
(typeof targetJson.rms_de === 'number' ? targetJson.rms_de : 0);
|
||||||
|
} else {
|
||||||
|
// Check for standard Argyll text summary line:
|
||||||
|
// Profile check complete, errors...: max. = %f, avg. = %f, RMS = %f
|
||||||
|
const summaryMatch = stdout.match(/Profile check complete,\s*errors[^\:]*:\s*max\.\s*=\s*([\d\.]+),\s*avg\.\s*=\s*([\d\.]+),\s*RMS\s*=\s*([\d\.]+)/i);
|
||||||
|
if (summaryMatch) {
|
||||||
|
maxDe = parseFloat(summaryMatch[1]);
|
||||||
|
avgDe = parseFloat(summaryMatch[2]);
|
||||||
|
rmsDe = parseFloat(summaryMatch[3]);
|
||||||
|
} else {
|
||||||
|
// Regex fallbacks for standard profcheck text output
|
||||||
|
const avgPatterns = [
|
||||||
|
/avg(?:\.?|erage)\s*(?:dE\s*)?[:=]\s*([\d\.]+)/i,
|
||||||
|
/average\s+(?:dE\s*)?([\d\.]+)/i,
|
||||||
|
/mean\s+(?:dE\s*)?([\d\.]+)/i,
|
||||||
|
/dE\s+average[^\d]*([\d\.]+)/i,
|
||||||
|
];
|
||||||
|
const maxPatterns = [
|
||||||
|
/max(?:\.?|imum)\s*(?:dE\s*)?[:=]\s*([\d\.]+)/i,
|
||||||
|
/peak\s*(?:dE\s*)?[:=]\s*([\d\.]+)/i,
|
||||||
|
/worst\s*(?:dE\s*)?([\d\.]+)/i,
|
||||||
|
/dE\s+max[^\d]*([\d\.]+)/i,
|
||||||
|
];
|
||||||
|
const rmsPatterns = [
|
||||||
|
/RMS(?:\.?)\s*(?:dE\s*)?[:=]\s*([\d\.]+)/i,
|
||||||
|
/rms(?:\.?)\s*(?:dE\s*)?([\d\.]+)/i,
|
||||||
|
/root\s+mean\s+sq(?:uare)?\s*(?:dE\s*)?([\d\.]+)/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
const find = (patterns) => {
|
||||||
|
for (const p of patterns) {
|
||||||
|
const match = stdout.match(p);
|
||||||
|
if (match) return match;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const avgMatch = find(avgPatterns);
|
||||||
|
const maxMatch = find(maxPatterns);
|
||||||
|
const rmsMatch = find(rmsPatterns);
|
||||||
|
|
||||||
|
if (avgMatch) avgDe = parseFloat(avgMatch[1]);
|
||||||
|
else warnings.push('Could not detect Average ΔE in profcheck output.');
|
||||||
|
|
||||||
|
if (maxMatch) maxDe = parseFloat(maxMatch[1]);
|
||||||
|
else warnings.push('Could not detect Peak ΔE in profcheck output.');
|
||||||
|
|
||||||
|
if (rmsMatch) rmsDe = parseFloat(rmsMatch[1]);
|
||||||
|
else warnings.push('Could not detect RMS ΔE in profcheck output.');
|
||||||
|
|
||||||
|
if (!avgMatch && !maxMatch && !rmsMatch) {
|
||||||
|
warnings.push('No delta-E values were found in profcheck output.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { avgDe, maxDe, rmsDe, patchCount, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks for a printer drift breach condition:
|
||||||
|
* Returns an alert string if the last >=2 consecutive records have avg_de >= 3.5
|
||||||
|
* and span distinct calendar dates (or are >= 1 hour apart).
|
||||||
|
* @param {Array<object>} records - Array of verification records sorted ascending by timestamp.
|
||||||
|
* @returns {string|null} Alert text or null if no breach.
|
||||||
|
*/
|
||||||
|
export function checkBreachAlert(records) {
|
||||||
|
if (!records || records.length < 2) return null;
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
let firstBreach = null;
|
||||||
|
let latestBreach = null;
|
||||||
|
|
||||||
|
for (let i = records.length - 1; i >= 0; i--) {
|
||||||
|
if (records[i].avg_de >= 3.5) {
|
||||||
|
count++;
|
||||||
|
if (!latestBreach) latestBreach = records[i];
|
||||||
|
firstBreach = records[i];
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count >= 2 && firstBreach && latestBreach) {
|
||||||
|
const tFirst = new Date(firstBreach.timestamp).getTime();
|
||||||
|
const tLatest = new Date(latestBreach.timestamp).getTime();
|
||||||
|
const diffHours = (tLatest - tFirst) / (1000 * 60 * 60);
|
||||||
|
const dFirst = firstBreach.timestamp.slice(0, 10);
|
||||||
|
const dLatest = latestBreach.timestamp.slice(0, 10);
|
||||||
|
|
||||||
|
if (dFirst !== dLatest || diffHours >= 1.0) {
|
||||||
|
const fmt = (ts) => {
|
||||||
|
try {
|
||||||
|
return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
|
} catch (_) {
|
||||||
|
return ts;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return `⚠️ Re-profiling Recommended — ${count} consecutive verifications out of tolerance (first: ${fmt(firstBreach.timestamp)}, latest: ${fmt(latestBreach.timestamp)})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads verification history records and updates the Stage 5 drift analytics UI.
|
||||||
|
*/
|
||||||
|
export async function loadVerificationHistory(selectedPrinter = "") {
|
||||||
|
const profileName = profileBasename || wizardState.basename || "";
|
||||||
|
const driftSection = document.getElementById("driftHistorySection");
|
||||||
|
if (!driftSection) return;
|
||||||
|
|
||||||
|
const alertCard = document.getElementById("driftAlertCard");
|
||||||
|
const alertText = document.getElementById("driftAlertText");
|
||||||
|
const emptyState = document.getElementById("driftEmptyState");
|
||||||
|
const chartWrap = document.getElementById("driftChartWrap");
|
||||||
|
const chartSvg = document.getElementById("driftTrendChart");
|
||||||
|
const tbody = document.getElementById("verificationHistoryTbody");
|
||||||
|
const btnExport = document.getElementById("btnExportHistoryCsv");
|
||||||
|
const btnClear = document.getElementById("btnClearHistory");
|
||||||
|
const printerFilterSelect = document.getElementById("driftPrinterFilter");
|
||||||
|
const filterRow = document.getElementById("driftFilterRow");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Fetch all records for current profile to populate printer options
|
||||||
|
const allProfileRecords = await invoke("get_verification_history", {
|
||||||
|
profileName: profileName || null,
|
||||||
|
printerName: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (printerFilterSelect) {
|
||||||
|
const distinctPrinters = Array.from(new Set(allProfileRecords.map(r => r.printer_name).filter(Boolean)));
|
||||||
|
printerFilterSelect.innerHTML = `<option value="">All Printers</option>`;
|
||||||
|
distinctPrinters.forEach(p => {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = p;
|
||||||
|
opt.textContent = p;
|
||||||
|
if (p === selectedPrinter) opt.selected = true;
|
||||||
|
printerFilterSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
if (filterRow) {
|
||||||
|
filterRow.classList.toggle("hidden", distinctPrinters.length <= 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fetch filtered records
|
||||||
|
const records = await invoke("get_verification_history", {
|
||||||
|
profileName: profileName || null,
|
||||||
|
printerName: selectedPrinter || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Breach Alert check (evaluated against profile records chronologically)
|
||||||
|
const breachMessage = checkBreachAlert(records);
|
||||||
|
if (alertCard && alertText) {
|
||||||
|
if (breachMessage) {
|
||||||
|
alertText.textContent = breachMessage;
|
||||||
|
alertCard.classList.remove("hidden");
|
||||||
|
} else {
|
||||||
|
alertCard.classList.add("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasRecords = records.length > 0;
|
||||||
|
if (btnExport) btnExport.disabled = !hasRecords;
|
||||||
|
if (btnClear) btnClear.disabled = allProfileRecords.length === 0;
|
||||||
|
|
||||||
|
if (!hasRecords) {
|
||||||
|
if (emptyState) emptyState.classList.remove("hidden");
|
||||||
|
if (chartWrap) chartWrap.classList.add("hidden");
|
||||||
|
if (tbody) tbody.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emptyState) emptyState.classList.add("hidden");
|
||||||
|
if (chartWrap) chartWrap.classList.remove("hidden");
|
||||||
|
|
||||||
|
// 4. Render Hand-rolled SVG Trend Chart
|
||||||
|
if (chartSvg) {
|
||||||
|
renderDriftTrendChart(chartSvg, records);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Render Run-Log Table (newest first)
|
||||||
|
if (tbody) {
|
||||||
|
tbody.innerHTML = "";
|
||||||
|
const reversed = [...records].reverse();
|
||||||
|
reversed.forEach(r => {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
|
||||||
|
const tdDate = document.createElement("td");
|
||||||
|
try {
|
||||||
|
tdDate.textContent = new Date(r.timestamp).toLocaleString();
|
||||||
|
} catch (_) {
|
||||||
|
tdDate.textContent = r.timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tdPrinter = document.createElement("td");
|
||||||
|
tdPrinter.textContent = r.printer_name || "Unknown";
|
||||||
|
|
||||||
|
const tdAvg = document.createElement("td");
|
||||||
|
tdAvg.textContent = r.avg_de.toFixed(2);
|
||||||
|
|
||||||
|
const tdMax = document.createElement("td");
|
||||||
|
tdMax.textContent = r.max_de.toFixed(2);
|
||||||
|
|
||||||
|
const tdRms = document.createElement("td");
|
||||||
|
tdRms.textContent = r.rms_de.toFixed(2);
|
||||||
|
|
||||||
|
const tdPatches = document.createElement("td");
|
||||||
|
tdPatches.textContent = r.patch_count || "-";
|
||||||
|
|
||||||
|
const tdStatus = document.createElement("td");
|
||||||
|
const badge = document.createElement("span");
|
||||||
|
const statusClass = r.status === 'excellent' ? 'badge-excellent' :
|
||||||
|
r.status === 'good' ? 'badge-good' :
|
||||||
|
r.status === 'acceptable' ? 'badge-acceptable' : 'badge-poor';
|
||||||
|
badge.className = `status-badge ${statusClass}`;
|
||||||
|
badge.textContent = r.status ? r.status.toUpperCase() : "UNKNOWN";
|
||||||
|
tdStatus.appendChild(badge);
|
||||||
|
|
||||||
|
tr.appendChild(tdDate);
|
||||||
|
tr.appendChild(tdPrinter);
|
||||||
|
tr.appendChild(tdAvg);
|
||||||
|
tr.appendChild(tdMax);
|
||||||
|
tr.appendChild(tdRms);
|
||||||
|
tr.appendChild(tdPatches);
|
||||||
|
tr.appendChild(tdStatus);
|
||||||
|
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn(`Failed to load verification history: ${e}`, 'Stage5-Profcheck');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand-rolled SVG line chart rendering verification drift trends with ICCery threshold bands.
|
||||||
|
*/
|
||||||
|
function renderDriftTrendChart(svg, records) {
|
||||||
|
// Downsample to the last 50 points if necessary
|
||||||
|
const data = records.length > 50 ? records.slice(records.length - 50) : records;
|
||||||
|
|
||||||
|
const width = 640;
|
||||||
|
const height = 220;
|
||||||
|
const padLeft = 45;
|
||||||
|
const padRight = 30;
|
||||||
|
const padTop = 20;
|
||||||
|
const padBottom = 28;
|
||||||
|
|
||||||
|
const plotW = width - padLeft - padRight;
|
||||||
|
const plotH = height - padTop - padBottom;
|
||||||
|
|
||||||
|
// Compute max Y (minimum 4.0 for all threshold bands)
|
||||||
|
let maxY = 4.0;
|
||||||
|
data.forEach(d => {
|
||||||
|
if (d.max_de > maxY) maxY = d.max_de;
|
||||||
|
if (d.avg_de > maxY) maxY = d.avg_de;
|
||||||
|
});
|
||||||
|
maxY = Math.ceil(maxY * 1.1);
|
||||||
|
|
||||||
|
const getY = (val) => padTop + plotH - (val / maxY) * plotH;
|
||||||
|
const getX = (idx) => {
|
||||||
|
if (data.length <= 1) return padLeft + plotW / 2;
|
||||||
|
return padLeft + (idx / (data.length - 1)) * plotW;
|
||||||
|
};
|
||||||
|
|
||||||
|
let elements = [];
|
||||||
|
|
||||||
|
// Threshold background bands
|
||||||
|
// Bands at: 0-1.0 (Excellent), 1.0-2.0 (Good), 2.0-3.5 (Acceptable), 3.5-maxY (Warning)
|
||||||
|
const bands = [
|
||||||
|
{ from: 0.0, to: 1.0, color: "rgba(34, 197, 94, 0.08)", label: "Excellent (< 1.0)" },
|
||||||
|
{ from: 1.0, to: 2.0, color: "rgba(59, 130, 246, 0.08)", label: "Good (< 2.0)" },
|
||||||
|
{ from: 2.0, to: 3.5, color: "rgba(245, 158, 11, 0.08)", label: "Acceptable (< 3.5)" },
|
||||||
|
{ from: 3.5, to: maxY, color: "rgba(239, 68, 68, 0.08)", label: "Warning (≥ 3.5)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
bands.forEach(b => {
|
||||||
|
const yTop = getY(Math.min(b.to, maxY));
|
||||||
|
const yBot = getY(b.from);
|
||||||
|
const bandH = Math.max(0, yBot - yTop);
|
||||||
|
elements.push(`<rect x="${padLeft}" y="${yTop}" width="${plotW}" height="${bandH}" fill="${b.color}" />`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Threshold lines
|
||||||
|
[1.0, 2.0, 3.5].forEach(thresh => {
|
||||||
|
if (thresh <= maxY) {
|
||||||
|
const y = getY(thresh);
|
||||||
|
elements.push(`<line x1="${padLeft}" y1="${y}" x2="${padLeft + plotW}" y2="${y}" stroke="rgba(255,255,255,0.15)" stroke-dasharray="3,3" />`);
|
||||||
|
elements.push(`<text x="${padLeft + plotW - 4}" y="${y - 3}" class="drift-band-label">${thresh.toFixed(1)} ΔE</text>`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verification bands caption
|
||||||
|
elements.push(`<text x="${padLeft + 6}" y="${padTop + 12}" fill="rgba(255,255,255,0.35)" font-size="9px" font-family="sans-serif">ICCery verification bands</text>`);
|
||||||
|
|
||||||
|
// Y Axis ticks
|
||||||
|
const ySteps = [0, 1, 2, 3.5];
|
||||||
|
if (maxY > 5) ySteps.push(Math.floor(maxY));
|
||||||
|
ySteps.forEach(val => {
|
||||||
|
const y = getY(val);
|
||||||
|
elements.push(`<line x1="${padLeft - 4}" y1="${y}" x2="${padLeft}" y2="${y}" stroke="rgba(255,255,255,0.3)" />`);
|
||||||
|
elements.push(`<text x="${padLeft - 8}" y="${y + 3}" text-anchor="end" class="drift-chart-text">${val.toFixed(1)}</text>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// X Axis baseline
|
||||||
|
elements.push(`<line x1="${padLeft}" y1="${padTop + plotH}" x2="${padLeft + plotW}" y2="${padTop + plotH}" stroke="rgba(255,255,255,0.3)" />`);
|
||||||
|
|
||||||
|
// Series points & paths
|
||||||
|
if (data.length === 1) {
|
||||||
|
const x = getX(0);
|
||||||
|
const yAvg = getY(data[0].avg_de);
|
||||||
|
const yMax = getY(data[0].max_de);
|
||||||
|
|
||||||
|
// Dashed horizontal line across plot for single point
|
||||||
|
elements.push(`<line x1="${padLeft}" y1="${yAvg}" x2="${padLeft + plotW}" y2="${yAvg}" stroke="#3b82f6" stroke-dasharray="4,4" stroke-opacity="0.5" />`);
|
||||||
|
elements.push(`<circle cx="${x}" cy="${yAvg}" r="5" class="drift-dot-avg"><title>Avg ΔE: ${data[0].avg_de.toFixed(2)} (${data[0].timestamp})</title></circle>`);
|
||||||
|
elements.push(`<circle cx="${x}" cy="${yMax}" r="4" class="drift-dot-max"><title>Peak ΔE: ${data[0].max_de.toFixed(2)}</title></circle>`);
|
||||||
|
} else {
|
||||||
|
// Polylines
|
||||||
|
let ptsAvg = [];
|
||||||
|
let ptsMax = [];
|
||||||
|
data.forEach((d, idx) => {
|
||||||
|
const x = getX(idx);
|
||||||
|
const yA = getY(d.avg_de);
|
||||||
|
const yM = getY(d.max_de);
|
||||||
|
ptsAvg.push(`${x.toFixed(1)},${yA.toFixed(1)}`);
|
||||||
|
ptsMax.push(`${x.toFixed(1)},${yM.toFixed(1)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.push(`<polyline points="${ptsMax.join(' ')}" class="drift-line-max" />`);
|
||||||
|
elements.push(`<polyline points="${ptsAvg.join(' ')}" class="drift-line-avg" />`);
|
||||||
|
|
||||||
|
// Draw dots
|
||||||
|
data.forEach((d, idx) => {
|
||||||
|
const x = getX(idx);
|
||||||
|
const yA = getY(d.avg_de);
|
||||||
|
const yM = getY(d.max_de);
|
||||||
|
const dateStr = d.timestamp.slice(0, 10);
|
||||||
|
elements.push(`<circle cx="${x.toFixed(1)}" cy="${yA.toFixed(1)}" r="4" class="drift-dot-avg"><title>Avg: ${d.avg_de.toFixed(2)} (${dateStr})</title></circle>`);
|
||||||
|
elements.push(`<circle cx="${x.toFixed(1)}" cy="${yM.toFixed(1)}" r="3" class="drift-dot-max"><title>Peak: ${d.max_de.toFixed(2)} (${dateStr})</title></circle>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start and End date labels on X axis
|
||||||
|
const startStr = data[0].timestamp.slice(5, 10);
|
||||||
|
const endStr = data[data.length - 1].timestamp.slice(5, 10);
|
||||||
|
elements.push(`<text x="${padLeft}" y="${height - 8}" text-anchor="start" class="drift-chart-text">${startStr}</text>`);
|
||||||
|
elements.push(`<text x="${padLeft + plotW}" y="${height - 8}" text-anchor="end" class="drift-chart-text">${endStr}</text>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
||||||
|
svg.innerHTML = elements.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initProfcheck() {
|
export function initProfcheck() {
|
||||||
@@ -25,9 +442,68 @@ export function initProfcheck() {
|
|||||||
const maxDeEl = document.getElementById("profcheckMaxDe");
|
const maxDeEl = document.getElementById("profcheckMaxDe");
|
||||||
const rmsDeEl = document.getElementById("profcheckRmsDe");
|
const rmsDeEl = document.getElementById("profcheckRmsDe");
|
||||||
const badgeEl = document.getElementById("profcheckBadge");
|
const badgeEl = document.getElementById("profcheckBadge");
|
||||||
|
const btnExport = document.getElementById("btnExportHistoryCsv");
|
||||||
|
const btnClear = document.getElementById("btnClearHistory");
|
||||||
|
const printerFilterSelect = document.getElementById("driftPrinterFilter");
|
||||||
|
|
||||||
if (!btnVerify) return;
|
if (!btnVerify) return;
|
||||||
|
|
||||||
|
// Listen for printer filter changes in drift history
|
||||||
|
if (printerFilterSelect) {
|
||||||
|
printerFilterSelect.addEventListener("change", () => {
|
||||||
|
loadVerificationHistory(printerFilterSelect.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export CSV button handler
|
||||||
|
if (btnExport) {
|
||||||
|
btnExport.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
const profileName = profileBasename || wizardState.basename || "verification";
|
||||||
|
const defaultName = `${profileName}_history.csv`;
|
||||||
|
const chosenPath = await invoke("select_csv_save_path", { defaultName });
|
||||||
|
if (chosenPath) {
|
||||||
|
const printerFilter = printerFilterSelect ? printerFilterSelect.value : "";
|
||||||
|
const count = await invoke("export_verification_history_csv", {
|
||||||
|
destPath: chosenPath,
|
||||||
|
profileName: profileName || null,
|
||||||
|
printerName: printerFilter || null,
|
||||||
|
});
|
||||||
|
wizardState.showNotice(`✓ Exported ${count} verification records to ${chosenPath}`, "success");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`CSV Export failed: ${err}`, 'Stage5-Profcheck');
|
||||||
|
wizardState.showNotice(`Failed to export CSV: ${err}`, "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear History button handler
|
||||||
|
if (btnClear) {
|
||||||
|
btnClear.addEventListener("click", async () => {
|
||||||
|
if (confirm("Are you sure you want to clear all verification history records? This cannot be undone.")) {
|
||||||
|
try {
|
||||||
|
await invoke("clear_verification_history");
|
||||||
|
await loadVerificationHistory();
|
||||||
|
wizardState.showNotice("Verification history cleared.", "info");
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Clear history failed: ${err}`, 'Stage5-Profcheck');
|
||||||
|
wizardState.showNotice(`Failed to clear history: ${err}`, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for Stage 5 navigation to load history
|
||||||
|
window.addEventListener("stage-changed", (event) => {
|
||||||
|
if (event.detail && event.detail.stage === 5) {
|
||||||
|
loadVerificationHistory();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
loadVerificationHistory();
|
||||||
|
|
||||||
btnVerify.addEventListener("click", async () => {
|
btnVerify.addEventListener("click", async () => {
|
||||||
const basename = profileBasename || wizardState.basename;
|
const basename = profileBasename || wizardState.basename;
|
||||||
const cwd = profileCwd || wizardState.cwd;
|
const cwd = profileCwd || wizardState.cwd;
|
||||||
@@ -94,7 +570,55 @@ export function initProfcheck() {
|
|||||||
|
|
||||||
if (event.payload.code === 0) {
|
if (event.payload.code === 0) {
|
||||||
logPre.textContent += "\n[SUCCESS] profcheck verification finished.\n";
|
logPre.textContent += "\n[SUCCESS] profcheck verification finished.\n";
|
||||||
parseAndRenderReport(stdoutAccumulator);
|
const report = parseProfcheckReport(stdoutAccumulator);
|
||||||
|
|
||||||
|
reportCard.classList.remove("hidden");
|
||||||
|
if (report.warnings.length > 0) {
|
||||||
|
logPre.textContent += `\n[WARN] ${report.warnings.join(' ')}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
avgDeEl.textContent = report.avgDe.toFixed(2);
|
||||||
|
maxDeEl.textContent = report.maxDe.toFixed(2);
|
||||||
|
rmsDeEl.textContent = report.rmsDe.toFixed(2);
|
||||||
|
|
||||||
|
// Quality verdict
|
||||||
|
badgeEl.className = "report-badge";
|
||||||
|
if (report.avgDe < 1.0) {
|
||||||
|
badgeEl.textContent = "EXCELLENT";
|
||||||
|
badgeEl.classList.add("badge-excellent");
|
||||||
|
} else if (report.avgDe < 2.0) {
|
||||||
|
badgeEl.textContent = "GOOD";
|
||||||
|
badgeEl.classList.add("badge-good");
|
||||||
|
} else if (report.avgDe < 3.5) {
|
||||||
|
badgeEl.textContent = "ACCEPTABLE";
|
||||||
|
badgeEl.classList.add("badge-acceptable");
|
||||||
|
} else {
|
||||||
|
badgeEl.textContent = "POOR";
|
||||||
|
badgeEl.classList.add("badge-poor");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-save record to verification history
|
||||||
|
const record = {
|
||||||
|
id: "",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
printer_name: wizardState.printerName || "Unknown",
|
||||||
|
profile_name: profileBasename || wizardState.basename || "Unknown",
|
||||||
|
avg_de: report.avgDe,
|
||||||
|
max_de: report.maxDe,
|
||||||
|
rms_de: report.rmsDe,
|
||||||
|
patch_count: report.patchCount,
|
||||||
|
status: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke("save_verification_record", { record });
|
||||||
|
} catch (saveErr) {
|
||||||
|
logger.warn(`Could not auto-save verification record: ${saveErr}`, 'Stage5-Profcheck');
|
||||||
|
logPre.textContent += `\n[WARN] Could not auto-save verification record: ${saveErr}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh verification history display
|
||||||
|
await loadVerificationHistory();
|
||||||
|
|
||||||
// Ensure gamut mesh is loaded into 3D viewer
|
// Ensure gamut mesh is loaded into 3D viewer
|
||||||
const gamFilePath = cwd ? `${cwd}${sep}${basename}.gam` : `${basename}.gam`;
|
const gamFilePath = cwd ? `${cwd}${sep}${basename}.gam` : `${basename}.gam`;
|
||||||
@@ -119,110 +643,4 @@ export function initProfcheck() {
|
|||||||
btnVerify.disabled = false;
|
btnVerify.disabled = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse profcheck output for Average, Peak, and RMS delta-E values.
|
|
||||||
* Supports both Argyll's JSON-style summary and plain-text legacy output.
|
|
||||||
* @param {string} stdout - Full profcheck stdout.
|
|
||||||
*/
|
|
||||||
function parseAndRenderReport(stdout) {
|
|
||||||
reportCard.classList.remove("hidden");
|
|
||||||
|
|
||||||
let avgDe = 0.0;
|
|
||||||
let maxDe = 0.0;
|
|
||||||
let rmsDe = 0.0;
|
|
||||||
let parserWarnings = [];
|
|
||||||
|
|
||||||
// Argyll's JSON output can appear either as a compact object on a single
|
|
||||||
// line or embedded inside larger text. Try to find and parse the LAST valid
|
|
||||||
// JSON object in the output, which is most likely the summary.
|
|
||||||
const jsonObjects = [];
|
|
||||||
const re = /\{[\s\S]*?\}/g;
|
|
||||||
let m;
|
|
||||||
while ((m = re.exec(stdout)) !== null) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(m[0]);
|
|
||||||
if (typeof parsed === 'object' && parsed !== null && ('avg_de' in parsed || 'peak_de' in parsed || 'rms_de' in parsed)) {
|
|
||||||
jsonObjects.push(parsed);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Not a valid JSON object, ignore.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (jsonObjects.length > 0) {
|
|
||||||
const json = jsonObjects[jsonObjects.length - 1];
|
|
||||||
avgDe = typeof json.avg_de === 'number' ? json.avg_de : 0;
|
|
||||||
maxDe = typeof json.max_de === 'number' ? json.max_de : (typeof json.peak_de === 'number' ? json.peak_de : 0);
|
|
||||||
rmsDe = typeof json.rms_de === 'number' ? json.rms_de : 0;
|
|
||||||
} else {
|
|
||||||
// Regex fallbacks for standard profcheck text output
|
|
||||||
const avgPatterns = [
|
|
||||||
/avg(?:\.?|erage)\s*(?:dE\s*)?[:=]\s*([\d\.]+)/i,
|
|
||||||
/average\s+(?:dE\s*)?([\d\.]+)/i,
|
|
||||||
/mean\s+(?:dE\s*)?([\d\.]+)/i,
|
|
||||||
/dE\s+average[^\d]*([\d\.]+)/i,
|
|
||||||
];
|
|
||||||
const maxPatterns = [
|
|
||||||
/max(?:\.?|imum)\s*(?:dE\s*)?[:=]\s*([\d\.]+)/i,
|
|
||||||
/peak\s*(?:dE\s*)?[:=]\s*([\d\.]+)/i,
|
|
||||||
/worst\s*(?:dE\s*)?([\d\.]+)/i,
|
|
||||||
/dE\s+max[^\d]*([\d\.]+)/i,
|
|
||||||
];
|
|
||||||
const rmsPatterns = [
|
|
||||||
/RMS\s*(?:dE\s*)?[:=]\s*([\d\.]+)/i,
|
|
||||||
/rms\s*(?:dE\s*)?([\d\.]+)/i,
|
|
||||||
/root\s+mean\s+sq(?:uare)?\s*(?:dE\s*)?([\d\.]+)/i,
|
|
||||||
];
|
|
||||||
|
|
||||||
const find = (patterns) => {
|
|
||||||
for (const p of patterns) {
|
|
||||||
const match = stdout.match(p);
|
|
||||||
if (match) return match;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const avgMatch = find(avgPatterns);
|
|
||||||
const maxMatch = find(maxPatterns);
|
|
||||||
const rmsMatch = find(rmsPatterns);
|
|
||||||
|
|
||||||
if (avgMatch) avgDe = parseFloat(avgMatch[1]);
|
|
||||||
else parserWarnings.push('Could not detect Average ΔE in profcheck output.');
|
|
||||||
|
|
||||||
if (maxMatch) maxDe = parseFloat(maxMatch[1]);
|
|
||||||
else parserWarnings.push('Could not detect Peak ΔE in profcheck output.');
|
|
||||||
|
|
||||||
if (rmsMatch) rmsDe = parseFloat(rmsMatch[1]);
|
|
||||||
else parserWarnings.push('Could not detect RMS ΔE in profcheck output.');
|
|
||||||
|
|
||||||
if (!avgMatch && !maxMatch && !rmsMatch) {
|
|
||||||
parserWarnings.push('No delta-E values were found in profcheck output.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parserWarnings.length > 0) {
|
|
||||||
logPre.textContent += `\n[WARN] ${parserWarnings.join(' ')}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
avgDeEl.textContent = avgDe.toFixed(2);
|
|
||||||
maxDeEl.textContent = maxDe.toFixed(2);
|
|
||||||
rmsDeEl.textContent = rmsDe.toFixed(2);
|
|
||||||
|
|
||||||
// Quality verdict
|
|
||||||
badgeEl.className = "report-badge";
|
|
||||||
if (avgDe < 1.0) {
|
|
||||||
badgeEl.textContent = "EXCELLENT";
|
|
||||||
badgeEl.classList.add("badge-excellent");
|
|
||||||
} else if (avgDe < 2.0) {
|
|
||||||
badgeEl.textContent = "GOOD";
|
|
||||||
badgeEl.classList.add("badge-good");
|
|
||||||
} else if (avgDe < 4.0) {
|
|
||||||
badgeEl.textContent = "ACCEPTABLE";
|
|
||||||
badgeEl.classList.add("badge-acceptable");
|
|
||||||
} else {
|
|
||||||
badgeEl.textContent = "POOR";
|
|
||||||
badgeEl.classList.add("badge-poor");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
// Unit & console tests for profcheck.js parsing and breach alert logic.
|
||||||
|
// Can be run in browser devtools console:
|
||||||
|
// import('./profcheck.test.js').then(m => m.runAll())
|
||||||
|
// Or in Node:
|
||||||
|
// node src/js/profcheck.test.js
|
||||||
|
|
||||||
|
// Node environment polyfill for browser globals
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
globalThis.window = {
|
||||||
|
__TAURI__: {
|
||||||
|
core: { invoke: () => Promise.resolve() },
|
||||||
|
event: { listen: () => Promise.resolve(() => {}) }
|
||||||
|
},
|
||||||
|
addEventListener: () => {},
|
||||||
|
dispatchEvent: () => {}
|
||||||
|
};
|
||||||
|
globalThis.document = {
|
||||||
|
getElementById: () => null,
|
||||||
|
querySelectorAll: () => []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { parseProfcheckReport, checkBreachAlert } = await import('./profcheck.js');
|
||||||
|
|
||||||
|
export function runAll() {
|
||||||
|
console.group('Profcheck Report Parser & Drift Tests');
|
||||||
|
let passed = 0;
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
function assert(actual, expected, message) {
|
||||||
|
total++;
|
||||||
|
const ok = JSON.stringify(actual) === JSON.stringify(expected);
|
||||||
|
if (ok) {
|
||||||
|
console.log('PASS:', message);
|
||||||
|
passed++;
|
||||||
|
} else {
|
||||||
|
console.error('FAIL:', message, '\nExpected:', expected, '\nGot:', actual);
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Real Argyll -u JSON payload
|
||||||
|
const realUOutput = `
|
||||||
|
profcheck: Checking profile accuracy...
|
||||||
|
No of test patches = 52
|
||||||
|
{"event": "report", "peak_de2000": 2.41, "avg_de2000": 0.85, "rms": 1.02}
|
||||||
|
Profile check complete, errors(CIEDE2000): max. = 2.41, avg. = 0.85, RMS = 1.02
|
||||||
|
`;
|
||||||
|
const res1 = parseProfcheckReport(realUOutput);
|
||||||
|
assert(res1.avgDe, 0.85, 'real -u JSON avgDe');
|
||||||
|
assert(res1.maxDe, 2.41, 'real -u JSON maxDe');
|
||||||
|
assert(res1.rmsDe, 1.02, 'real -u JSON rmsDe');
|
||||||
|
assert(res1.patchCount, 52, 'real -u patchCount');
|
||||||
|
assert(res1.warnings.length, 0, 'real -u no warnings');
|
||||||
|
|
||||||
|
// 2. Preference for *de2000 object when multiple JSON objects appear
|
||||||
|
const multiJsonOutput = `
|
||||||
|
{"event": "report", "peak_de": 3.10, "avg_de": 1.20, "rms": 1.50}
|
||||||
|
{"event": "report", "peak_de2000": 2.15, "avg_de2000": 0.72, "rms": 0.95}
|
||||||
|
`;
|
||||||
|
const res2 = parseProfcheckReport(multiJsonOutput);
|
||||||
|
assert(res2.avgDe, 0.72, 'prefers *de2000 avg');
|
||||||
|
assert(res2.maxDe, 2.15, 'prefers *de2000 peak');
|
||||||
|
assert(res2.rmsDe, 0.95, 'prefers *de2000 rms');
|
||||||
|
|
||||||
|
// 3. Standard text summary line without JSON
|
||||||
|
const textSummaryOutput = `
|
||||||
|
Header information...
|
||||||
|
No of test patches = 120
|
||||||
|
Profile check complete, errors(CIEDE2000): max. = 1.95, avg. = 0.65, RMS = 0.88
|
||||||
|
Done.
|
||||||
|
`;
|
||||||
|
const res3 = parseProfcheckReport(textSummaryOutput);
|
||||||
|
assert(res3.avgDe, 0.65, 'text summary line avgDe');
|
||||||
|
assert(res3.maxDe, 1.95, 'text summary line maxDe');
|
||||||
|
assert(res3.rmsDe, 0.88, 'text summary line rmsDe');
|
||||||
|
assert(res3.patchCount, 120, 'text summary patchCount');
|
||||||
|
|
||||||
|
// 4. Legacy regex fallbacks
|
||||||
|
const legacyOutput = `
|
||||||
|
Summary:
|
||||||
|
avg. dE = 1.15
|
||||||
|
max. dE = 3.42
|
||||||
|
rms. dE = 1.65
|
||||||
|
`;
|
||||||
|
const res4 = parseProfcheckReport(legacyOutput);
|
||||||
|
assert(res4.avgDe, 1.15, 'legacy text avgDe');
|
||||||
|
assert(res4.maxDe, 3.42, 'legacy text maxDe');
|
||||||
|
assert(res4.rmsDe, 1.65, 'legacy text rmsDe');
|
||||||
|
|
||||||
|
// 5. checkBreachAlert tests
|
||||||
|
assert(checkBreachAlert([]), null, 'empty records returns null');
|
||||||
|
assert(checkBreachAlert([{ avg_de: 4.0, timestamp: '2026-09-01T10:00:00Z' }]), null, 'single record returns null');
|
||||||
|
|
||||||
|
// Two records in same minute >= 3.5 -> no alert
|
||||||
|
const sameTimeRecords = [
|
||||||
|
{ avg_de: 3.8, timestamp: '2026-09-05T12:00:10Z' },
|
||||||
|
{ avg_de: 3.9, timestamp: '2026-09-05T12:00:45Z' },
|
||||||
|
];
|
||||||
|
assert(checkBreachAlert(sameTimeRecords), null, 'two records in same minute do not fire alert');
|
||||||
|
|
||||||
|
// Two records on distinct calendar dates >= 3.5 -> alert fires
|
||||||
|
const distinctDateRecords = [
|
||||||
|
{ avg_de: 1.0, timestamp: '2026-08-15T10:00:00Z' },
|
||||||
|
{ avg_de: 3.6, timestamp: '2026-09-01T10:00:00Z' },
|
||||||
|
{ avg_de: 3.8, timestamp: '2026-09-05T10:00:00Z' },
|
||||||
|
];
|
||||||
|
const alert = checkBreachAlert(distinctDateRecords);
|
||||||
|
assert(typeof alert === 'string' && alert.includes('Re-profiling Recommended') && alert.includes('2 consecutive'), true, 'distinct dates breach alert fires');
|
||||||
|
|
||||||
|
// Two records >= 1 hour apart on same day -> alert fires
|
||||||
|
const hourApartRecords = [
|
||||||
|
{ avg_de: 3.7, timestamp: '2026-09-05T10:00:00Z' },
|
||||||
|
{ avg_de: 3.9, timestamp: '2026-09-05T12:30:00Z' },
|
||||||
|
];
|
||||||
|
const alert2 = checkBreachAlert(hourApartRecords);
|
||||||
|
assert(typeof alert2 === 'string' && alert2.includes('Re-profiling Recommended'), true, 'records >=1 hr apart breach alert fires');
|
||||||
|
|
||||||
|
// Trailing record is good -> no alert
|
||||||
|
const recoveredRecords = [
|
||||||
|
{ avg_de: 3.7, timestamp: '2026-09-01T10:00:00Z' },
|
||||||
|
{ avg_de: 3.9, timestamp: '2026-09-02T10:00:00Z' },
|
||||||
|
{ avg_de: 0.9, timestamp: '2026-09-03T10:00:00Z' },
|
||||||
|
];
|
||||||
|
assert(checkBreachAlert(recoveredRecords), null, 'recovered profile returns null');
|
||||||
|
|
||||||
|
console.log(`Profcheck tests complete: ${passed}/${total} passed`);
|
||||||
|
console.groupEnd();
|
||||||
|
return passed === total;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-run if executed directly in Node
|
||||||
|
if (typeof process !== 'undefined' && process.argv && process.argv[1] && process.argv[1].endsWith('profcheck.test.js')) {
|
||||||
|
const ok = runAll();
|
||||||
|
if (!ok) process.exit(1);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ export const wizardState = {
|
|||||||
currentStage: 1,
|
currentStage: 1,
|
||||||
basename: "",
|
basename: "",
|
||||||
cwd: "",
|
cwd: "",
|
||||||
|
printerName: "",
|
||||||
noticeTimer: null,
|
noticeTimer: null,
|
||||||
|
|
||||||
setTarget(basename, cwd) {
|
setTarget(basename, cwd) {
|
||||||
@@ -75,6 +76,8 @@ export const wizardState = {
|
|||||||
s.classList.add('hidden');
|
s.classList.add('hidden');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent('stage-changed', { detail: { stage: stageNumber } }));
|
||||||
},
|
},
|
||||||
|
|
||||||
async navigateToStage(stageNumber) {
|
async navigateToStage(stageNumber) {
|
||||||
|
|||||||
+232
-1
@@ -1158,7 +1158,7 @@ button.danger:hover {
|
|||||||
border: 1px solid rgba(76, 175, 80, 0.3);
|
border: 1px solid rgba(76, 175, 80, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-printing {
|
.badge-printing, .badge-primary {
|
||||||
background: rgba(33, 150, 243, 0.15);
|
background: rgba(33, 150, 243, 0.15);
|
||||||
color: #64b5f6;
|
color: #64b5f6;
|
||||||
border: 1px solid rgba(33, 150, 243, 0.3);
|
border: 1px solid rgba(33, 150, 243, 0.3);
|
||||||
@@ -1671,5 +1671,236 @@ button.danger:hover {
|
|||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
Longitudinal Printer Drift Tracking & Verification History — Stage 5
|
||||||
|
═══════════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.drift-history {
|
||||||
|
margin-top: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drift-filter-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drift-filter-row select {
|
||||||
|
max-width: 240px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
height: 30px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drift-chart-wrap {
|
||||||
|
width: 100%;
|
||||||
|
height: 240px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border: 1px solid var(--border-color, #333);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
#driftTrendChart {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drift-chart-grid line {
|
||||||
|
stroke: rgba(255, 255, 255, 0.08);
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drift-chart-axis line,
|
||||||
|
.drift-chart-axis path {
|
||||||
|
stroke: rgba(255, 255, 255, 0.2);
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drift-chart-text {
|
||||||
|
fill: #888;
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drift-band-label {
|
||||||
|
fill: rgba(255, 255, 255, 0.45);
|
||||||
|
font-size: 9px;
|
||||||
|
text-anchor: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drift-line-avg {
|
||||||
|
fill: none;
|
||||||
|
stroke: #3b82f6;
|
||||||
|
stroke-width: 2.5;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drift-line-max {
|
||||||
|
fill: none;
|
||||||
|
stroke: #f59e0b;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
stroke-dasharray: 4, 3;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drift-dot-avg {
|
||||||
|
fill: #3b82f6;
|
||||||
|
stroke: #1e293b;
|
||||||
|
stroke-width: 2;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: r 0.15s ease, fill 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drift-dot-avg:hover {
|
||||||
|
r: 6;
|
||||||
|
fill: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drift-dot-max {
|
||||||
|
fill: #f59e0b;
|
||||||
|
stroke: #1e293b;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: r 0.15s ease, fill 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drift-dot-max:hover {
|
||||||
|
r: 5;
|
||||||
|
fill: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drift-table-wrap {
|
||||||
|
max-height: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--border-color, #333);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drift-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drift-table th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--bg-surface, #1e1e1e);
|
||||||
|
color: var(--text-muted, #aaa);
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid var(--border-color, #333);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drift-table td {
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drift-table tbody tr:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drift-table tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
XY Automated Scanning Table Sequence — Stage 3
|
||||||
|
═══════════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.xy-table-panel {
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
border: 1px solid var(--border-color, #333);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xy-table-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xy-table-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color, #eee);
|
||||||
|
}
|
||||||
|
|
||||||
|
.xy-steps-list {
|
||||||
|
list-style: none;
|
||||||
|
counter-reset: xy-step-counter;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xy-step {
|
||||||
|
counter-increment: xy-step-counter;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted, #888);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xy-step::before {
|
||||||
|
content: counter(xy-step-counter);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xy-step.active {
|
||||||
|
background: rgba(59, 130, 246, 0.15);
|
||||||
|
border-color: rgba(59, 130, 246, 0.5);
|
||||||
|
color: #93c5fd;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xy-step.active::before {
|
||||||
|
background: var(--accent-color, #3b82f6);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xy-step.completed {
|
||||||
|
color: #86efac;
|
||||||
|
border-color: rgba(34, 197, 94, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.xy-step.completed::before {
|
||||||
|
content: "✓";
|
||||||
|
background: rgba(34, 197, 94, 0.2);
|
||||||
|
color: #86efac;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user