diff --git a/AGENTS.md b/AGENTS.md index 974bda0..4cd8f00 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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) - Canon color bypass: `CNIJIntent2=4` or `CNIJIntent=4` - 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--`. + - `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 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. diff --git a/ROADMAP.md b/ROADMAP.md index 1254f4a..c176060 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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`) - [ ] **macOS Platform Bundle**: Build and sign universal macOS `.dmg` bundles with notarization. - [ ] **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 @@ -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] **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) -- [ ] **Batch Verification & Drift Tracking (#95)**: Track printer drift over time by comparing periodic verification measurements against a baseline profile. -- [ ] **Multi-Language Localization (#96)**: Full UI internationalization (English, German, French, Japanese). +### Milestone 12 — Future Workflow & Advanced Analytics +- [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. +- [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). diff --git a/package.json b/package.json index 7f3e7e2..4906920 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "iccery", "private": true, - "version": "0.8.1", + "version": "0.8.2", "type": "module", "scripts": { "fetch-argyll": "node scripts/fetch-argyll.mjs", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index fc6ef20..9a39611 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1423,7 +1423,7 @@ dependencies = [ [[package]] name = "iccery" -version = "0.8.1" +version = "0.8.2" dependencies = [ "base64 0.22.1", "image", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 166f429..64209d7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "iccery" -version = "0.8.1" +version = "0.8.2" description = "Modern Printer Profiling UI frontend for ArgyllCMS" authors = ["Gordon"] edition = "2021" diff --git a/src-tauri/argyll/mocks/chartread.mock b/src-tauri/argyll/mocks/chartread.mock index b05aada..4d03792 100644 --- a/src-tauri/argyll/mocks/chartread.mock +++ b/src-tauri/argyll/mocks/chartread.mock @@ -2,6 +2,51 @@ # Mock script for chartread -u # 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." # We don't really wait for input, just wait 1 second diff --git a/src-tauri/argyll/mocks/profcheck.mock b/src-tauri/argyll/mocks/profcheck.mock index 78da1f3..c424509 100644 --- a/src-tauri/argyll/mocks/profcheck.mock +++ b/src-tauri/argyll/mocks/profcheck.mock @@ -1,14 +1,12 @@ #!/bin/bash # Mock script for profcheck -# Simulates profcheck verification output +# Simulates real ArgyllCMS profcheck -v -k -s -u output echo "profcheck: Checking profile accuracy..." +echo "No of test patches = 52" sleep 1 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 -echo "Summary:" -echo " avg. dE = 0.85" -echo " max. dE = 2.41" -echo " rms. dE = 1.02" +echo "Profile check complete, errors(CIEDE2000): max. = 2.41, avg. = 0.85, RMS = 1.02" exit 0 diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 47bedb7..6ce29df 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -425,6 +425,34 @@ pub async fn select_target_file( rx.await.map_err(|e| format!("Dialog channel error: {}", e)) } +#[tauri::command] +pub async fn select_csv_save_path( + app: AppHandle, + default_name: Option, +) -> Result, 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] pub async fn select_directory( app: AppHandle, @@ -1084,6 +1112,7 @@ pub fn build_profcheck_args(config: &ProfcheckConfig) -> Vec { "-v".to_string(), "-k".to_string(), "-s".to_string(), + "-u".to_string(), config.ti3_path.clone(), config.icc_path.clone(), ] @@ -1868,7 +1897,7 @@ mod tests { cwd: "/home/user".to_string(), }; 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] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ed3ce75..9a1e621 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ mod commands; mod events; mod print; mod process_manager; +mod quality_store; mod settings; #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -74,6 +75,11 @@ pub fn run() { commands::get_printer_capabilities, commands::show_printer_properties, 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::save_settings, settings::get_all_presets, diff --git a/src-tauri/src/quality_store.rs b/src-tauri/src/quality_store.rs new file mode 100644 index 0000000..301cba6 --- /dev/null +++ b/src-tauri/src/quality_store.rs @@ -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--" + 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, +} + +impl Default for VerificationHistoryStore { + fn default() -> Self { + Self { + version: STORE_VERSION, + records: Vec::new(), + } + } +} + +/// Generates a unique record identifier: "vr--" +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 { + 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::(&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 = 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 { + 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 { + 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 { + 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, + printer_name: Option, +) -> Result, 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, + printer_name: Option, +) -> Result { + 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"); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 7494f5e..a1843df 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ICCery", - "version": "0.8.1", + "version": "0.8.2", "identifier": "com.gronod.iccery", "build": { "frontendDist": "../src" diff --git a/src/index.html b/src/index.html index 68bf9e2..e79a503 100644 --- a/src/index.html +++ b/src/index.html @@ -559,6 +559,25 @@ + + + + +
State: IDLE
@@ -804,6 +823,44 @@
+ +
+ Verification History & Drift Tracking + + +
+ +
+

No verification history yet for this profile.

+
+ + + + + + + + + + + + + +
DatePrinterAvg ΔE₀₀Peak ΔE₀₀RMS ΔE₀₀PatchesStatus
+
+
+ + +
+
+
@@ -950,7 +1007,7 @@

About ICCery

-

Version: v0.8.1Build Date: September 2026

+

Version: v0.8.2Build Date: September 2026

Copyright © 2026 Gordon Bolton. All rights reserved.

Licences & EULA

diff --git a/src/js/chartread.js b/src/js/chartread.js index 3b8158c..20e6589 100644 --- a/src/js/chartread.js +++ b/src/js/chartread.js @@ -45,7 +45,7 @@ export function populateStage3TargetContext(metadata) { } // State machine states -const STATE = { +export const STATE = { IDLE: "IDLE", CALIBRATING: "CALIBRATING", AWAITING_STRIP: "AWAITING_STRIP", @@ -53,13 +53,242 @@ const STATE = { ALL_STRIPS_READ: "ALL_STRIPS_READ", WARNING: "WARNING", PROMPT_CONTINUE: "PROMPT_CONTINUE", + TABLE_PLACE_SHEET: "TABLE_PLACE_SHEET", + TABLE_ALIGN: "TABLE_ALIGN", ERROR: "ERROR", 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 currentProcessId = ""; let measurementInProgress = false; +let xyTableDetected = false; let currentPassIndex = 0; const recordedPasses = []; @@ -83,6 +312,82 @@ export function initChartread() { const passCounterBadge = document.getElementById("passCounterBadge"); const btnMeasureAnotherSheet = document.getElementById("btnMeasureAnotherSheet"); 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) { measurementInProgress = busy; @@ -182,7 +487,13 @@ export function initChartread() { const opt = document.createElement("option"); // 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.textContent = `${inst.type || inst.name}${inst.port ? ` (Port ${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})` : ""}`; + } instrumentSelect.appendChild(opt); }); 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) { currentState = newState; if (stateLabel) stateLabel.textContent = newState; @@ -287,6 +613,22 @@ export function initChartread() { } if (btnCancel) btnCancel.classList.remove("hidden"); 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: if (btnRetry) { btnRetry.disabled = false; @@ -339,6 +681,15 @@ export function initChartread() { setState(STATE.CALIBRATING); 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 config = { @@ -365,64 +716,66 @@ export function initChartread() { logPre.textContent += line + "\n"; logPre.scrollTop = logPre.scrollHeight; - // Parse prompts for state transitions - const lineLower = line.toLowerCase(); + const classified = classifyChartreadLine(line, currentState); - 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") - ) { - 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") - ) { - const previousPrompt = promptText ? promptText.textContent.trim() : ""; - 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())) { - setPrompt(`${previousPrompt}\n${line.trim()}`); - } else { - setPrompt(line.trim()); + if (classified.matched) { + if ( + classified.state === STATE.TABLE_PLACE_SHEET || + classified.state === STATE.TABLE_ALIGN || + (classified.meta && (classified.meta.isRemoveSheetNotice || classified.meta.sheetOk)) + ) { + 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 lineLower = line.toLowerCase(); + const isContinuationPrompt = lineLower.includes("hit return to use it anyway") || lineLower.includes("use it anyway"); + if (currentState === STATE.WARNING && isContinuationPrompt && previousPrompt && !previousPrompt.includes(line.trim())) { + setPrompt(`${previousPrompt}\n${line.trim()}`); + } else { + 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(); if (event.payload.code === 0) { + if (xyTableDetected) { + updateXyTableSequence("done"); + } try { const passIndex = currentPassIndex + 1; const filename = await invoke("snapshot_ti3", { @@ -615,8 +971,12 @@ export function initChartread() { try { btnAccept.disabled = true; await invoke("send_stdin", { id: currentProcessId, input: "\n" }); - setState(STATE.READING); - setPrompt("Accepted. Processing..."); + if (currentState === STATE.TABLE_PLACE_SHEET || currentState === STATE.TABLE_ALIGN) { + setPrompt("Continuing XY table sequence..."); + } else { + setState(STATE.READING); + setPrompt("Accepted. Processing..."); + } } catch (e) { console.error("send_stdin error:", e); btnAccept.disabled = false; @@ -682,14 +1042,27 @@ export function initChartread() { if (btnCancel) { btnCancel.addEventListener("click", async () => { 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 }); stopSwatchListener(); setState(recordedPasses.length > 0 ? STATE.FINISHED : STATE.IDLE); setPrompt("Measurement cancelled."); + if (xyTableDetected) { + updateXyTableSequence("standby"); + } } catch (e) { console.error("kill_process error:", e); setPrompt(`Cancel failed: ${e}`); } finally { + btnCancel.disabled = false; setMeasurementBusy(false); stopSwatchListener(); } diff --git a/src/js/chartread.test.js b/src/js/chartread.test.js new file mode 100644 index 0000000..6443fec --- /dev/null +++ b/src/js/chartread.test.js @@ -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(); +} diff --git a/src/js/printtarg.js b/src/js/printtarg.js index 149f020..27006ed 100644 --- a/src/js/printtarg.js +++ b/src/js/printtarg.js @@ -574,6 +574,7 @@ export function initPrinttarg() { showNotification("error", "Please select a destination printer first."); return; } + wizardState.printerName = printerName; const options = getSelectedPrintOptions(); const origBtnContent = triggeringButton ? triggeringButton.innerHTML : ""; @@ -619,6 +620,7 @@ export function initPrinttarg() { showNotification("error", "Please select a destination printer first."); return; } + wizardState.printerName = printerName; const options = getSelectedPrintOptions(); const cwd = stage1Cwd; diff --git a/src/js/profcheck.js b/src/js/profcheck.js index f88c223..e81169b 100644 --- a/src/js/profcheck.js +++ b/src/js/profcheck.js @@ -14,6 +14,423 @@ export function setStage4Result(basename, cwd) { profileBasename = basename || wizardState.basename; profileCwd = cwd || wizardState.cwd; 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} 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 = ``; + 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(``); + }); + + // Threshold lines + [1.0, 2.0, 3.5].forEach(thresh => { + if (thresh <= maxY) { + const y = getY(thresh); + elements.push(``); + elements.push(`${thresh.toFixed(1)} ΔE`); + } + }); + + // Verification bands caption + elements.push(`ICCery verification bands`); + + // 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(``); + elements.push(`${val.toFixed(1)}`); + }); + + // X Axis baseline + elements.push(``); + + // 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(``); + elements.push(`Avg ΔE: ${data[0].avg_de.toFixed(2)} (${data[0].timestamp})`); + elements.push(`Peak ΔE: ${data[0].max_de.toFixed(2)}`); + } 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(``); + elements.push(``); + + // 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(`Avg: ${d.avg_de.toFixed(2)} (${dateStr})`); + elements.push(`Peak: ${d.max_de.toFixed(2)} (${dateStr})`); + }); + + // 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(`${startStr}`); + elements.push(`${endStr}`); + } + + svg.setAttribute("viewBox", `0 0 ${width} ${height}`); + svg.innerHTML = elements.join("\n"); } export function initProfcheck() { @@ -25,9 +442,68 @@ export function initProfcheck() { const maxDeEl = document.getElementById("profcheckMaxDe"); const rmsDeEl = document.getElementById("profcheckRmsDe"); const badgeEl = document.getElementById("profcheckBadge"); + const btnExport = document.getElementById("btnExportHistoryCsv"); + const btnClear = document.getElementById("btnClearHistory"); + const printerFilterSelect = document.getElementById("driftPrinterFilter"); 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 () => { const basename = profileBasename || wizardState.basename; const cwd = profileCwd || wizardState.cwd; @@ -94,7 +570,55 @@ export function initProfcheck() { if (event.payload.code === 0) { 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 const gamFilePath = cwd ? `${cwd}${sep}${basename}.gam` : `${basename}.gam`; @@ -119,110 +643,4 @@ export function initProfcheck() { 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"); - } - } } diff --git a/src/js/profcheck.test.js b/src/js/profcheck.test.js new file mode 100644 index 0000000..c802047 --- /dev/null +++ b/src/js/profcheck.test.js @@ -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); +} diff --git a/src/js/state.js b/src/js/state.js index e0857a4..9f05a55 100644 --- a/src/js/state.js +++ b/src/js/state.js @@ -4,6 +4,7 @@ export const wizardState = { currentStage: 1, basename: "", cwd: "", + printerName: "", noticeTimer: null, setTarget(basename, cwd) { @@ -75,6 +76,8 @@ export const wizardState = { s.classList.add('hidden'); } }); + + window.dispatchEvent(new CustomEvent('stage-changed', { detail: { stage: stageNumber } })); }, async navigateToStage(stageNumber) { diff --git a/src/styles/main.css b/src/styles/main.css index 3ef18d8..5af936b 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -1158,7 +1158,7 @@ button.danger:hover { border: 1px solid rgba(76, 175, 80, 0.3); } -.badge-printing { +.badge-printing, .badge-primary { background: rgba(33, 150, 243, 0.15); color: #64b5f6; border: 1px solid rgba(33, 150, 243, 0.3); @@ -1671,5 +1671,236 @@ button.danger:hover { 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; +}