diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 3e74972..7f68fcf 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1405,4 +1405,95 @@ mod tests { let candidates_exe = get_binary_candidates("targen.exe"); assert_eq!(candidates_exe, vec!["targen.exe"]); } + + #[test] + fn test_verify_stage_artefacts_empty_input() { + let status = verify_stage_artefacts("".to_string(), "".to_string()); + assert!(!status.stage1_complete); + assert!(!status.stage2_complete); + assert!(!status.stage3_complete); + assert!(!status.stage4_complete); + assert!(status.profile_path.is_none()); + + let status2 = verify_stage_artefacts(" ".to_string(), " ".to_string()); + assert!(!status2.stage1_complete); + assert!(!status2.stage2_complete); + assert!(!status2.stage3_complete); + assert!(!status2.stage4_complete); + assert!(status2.profile_path.is_none()); + } + + #[test] + fn test_verify_stage_artefacts_file_progression() { + let temp_dir = std::env::temp_dir().join(format!("iccery_test_gating_{}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos())); + std::fs::create_dir_all(&temp_dir).unwrap(); + let cwd = temp_dir.to_string_lossy().to_string(); + let basename = "test_target".to_string(); + + // 0. No files exist + let status0 = verify_stage_artefacts(cwd.clone(), basename.clone()); + assert!(!status0.stage1_complete); + assert!(!status0.stage2_complete); + assert!(!status0.stage3_complete); + assert!(!status0.stage4_complete); + assert!(status0.profile_path.is_none()); + + // 1. .ti1 created + std::fs::write(temp_dir.join("test_target.ti1"), b"ti1 content").unwrap(); + let status1 = verify_stage_artefacts(cwd.clone(), basename.clone()); + assert!(status1.stage1_complete); + assert!(!status1.stage2_complete); + assert!(!status1.stage3_complete); + assert!(!status1.stage4_complete); + assert!(status1.profile_path.is_none()); + + // 2. .ti2 created + std::fs::write(temp_dir.join("test_target.ti2"), b"ti2 content").unwrap(); + let status2 = verify_stage_artefacts(cwd.clone(), basename.clone()); + assert!(status2.stage1_complete); + assert!(status2.stage2_complete); + assert!(!status2.stage3_complete); + assert!(!status2.stage4_complete); + + // 3. .ti3 created + std::fs::write(temp_dir.join("test_target.ti3"), b"ti3 content").unwrap(); + let status3 = verify_stage_artefacts(cwd.clone(), basename.clone()); + assert!(status3.stage1_complete); + assert!(status3.stage2_complete); + assert!(status3.stage3_complete); + assert!(!status3.stage4_complete); + + // 4. Profile created (.icc / .icm) + let (prof_ext, _) = resolve_profile_extension(&cwd, &basename); + std::fs::write(temp_dir.join(format!("test_target.{}", prof_ext)), b"icc profile content").unwrap(); + let status4 = verify_stage_artefacts(cwd.clone(), basename.clone()); + assert!(status4.stage1_complete); + assert!(status4.stage2_complete); + assert!(status4.stage3_complete); + assert!(status4.stage4_complete); + assert!(status4.profile_path.is_some()); + + // Clean up + let _ = std::fs::remove_dir_all(&temp_dir); + } + + #[test] + fn test_verify_stage_artefacts_missing_intermediate() { + let temp_dir = std::env::temp_dir().join(format!("iccery_test_missing_ti2_{}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos())); + std::fs::create_dir_all(&temp_dir).unwrap(); + let cwd = temp_dir.to_string_lossy().to_string(); + let basename = "gap_target".to_string(); + + // Create .ti1 and .ti3, but NOT .ti2 + std::fs::write(temp_dir.join("gap_target.ti1"), b"ti1").unwrap(); + std::fs::write(temp_dir.join("gap_target.ti3"), b"ti3").unwrap(); + + let status = verify_stage_artefacts(cwd.clone(), basename.clone()); + assert!(status.stage1_complete); + assert!(!status.stage2_complete, ".ti2 is missing"); + assert!(status.stage3_complete, ".ti3 exists"); + assert!(!status.stage4_complete); + + let _ = std::fs::remove_dir_all(&temp_dir); + } } diff --git a/src/index.html b/src/index.html index c7adcdd..0e09510 100644 --- a/src/index.html +++ b/src/index.html @@ -56,6 +56,13 @@
+ + +
diff --git a/src/js/app.js b/src/js/app.js index 8e4d672..8edc069 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -11,37 +11,32 @@ import { wizardState } from './state.js'; const { invoke } = window.__TAURI__.core; document.addEventListener('DOMContentLoaded', () => { - // Wizard navigation + // Wizard stepper navigation with re-validation const steps = document.querySelectorAll('.step'); - const stages = document.querySelectorAll('.stage'); + steps.forEach(step => { + step.addEventListener('click', async () => { + const targetStep = parseInt(step.getAttribute('data-step'), 10); + if (isNaN(targetStep)) return; + await wizardState.navigateToStage(targetStep); + }); + }); + + // Global wizard notification close button + const wizardNotificationClose = document.getElementById('wizardNotificationClose'); + if (wizardNotificationClose) { + wizardNotificationClose.addEventListener('click', () => { + wizardState.hideNotice(); + }); + } + + // Re-validate gating on window focus (e.g. when returning after modifying files in Explorer/Finder) + window.addEventListener('focus', () => { + wizardState.updateGating(); + }); // Initialize gating on load wizardState.updateGating(); - steps.forEach(step => { - step.addEventListener('click', () => { - if (step.classList.contains('disabled')) { - return; - } - - const targetStep = step.getAttribute('data-step'); - - // Update UI - steps.forEach(s => s.classList.remove('active')); - stages.forEach(s => { - s.classList.remove('active'); - s.classList.add('hidden'); - }); - - step.classList.add('active'); - const targetStage = document.getElementById(`stage-${targetStep}`); - if (targetStage) { - targetStage.classList.remove('hidden'); - targetStage.classList.add('active'); - } - }); - }); - // About modal const aboutDialog = document.getElementById('aboutDialog'); const openAboutBtn = document.getElementById('openAboutBtn'); diff --git a/src/js/chartread.js b/src/js/chartread.js index 2a22c66..636a896 100644 --- a/src/js/chartread.js +++ b/src/js/chartread.js @@ -476,7 +476,8 @@ export function initChartread() { basename: basename, }); logPre.textContent += `\n[SUCCESS] Promoted ${recordedPasses[0].filename} → ${basename}.ti3 (single pass, average not invoked).\n`; - acceptStage3(basename, cwd); + setStage3Result(basename, cwd); + advanceToStage4(); } catch (e) { btnFinishAndAverage.disabled = false; setPrompt(`Failed to restore ${basename}.ti3: ${e}`); @@ -503,7 +504,8 @@ export function initChartread() { if (event.payload.code === 0) { setPrompt(`✅ Successfully averaged ${recordedPasses.length} measurement passes into ${basename}.ti3!`); logPre.textContent += `\n[SUCCESS] average wrote ${basename}.ti3\n`; - acceptStage3(basename, cwd); + setStage3Result(basename, cwd); + advanceToStage4(); } else { setPrompt(`⚠️ Argyll average exited with code ${event.payload.code}. Promoting pass 1 as fallback.`); logPre.textContent += `\n[ERROR] average exited ${event.payload.code}. Promoting ${recordedPasses[0].filename}.\n`; @@ -513,7 +515,8 @@ export function initChartread() { source: recordedPasses[0].filename, basename: basename, }); - acceptStage3(basename, cwd); + setStage3Result(basename, cwd); + advanceToStage4(); } catch (e) { btnFinishAndAverage.disabled = false; setPrompt(`Average failed and fallback promote failed: ${e}`); @@ -609,21 +612,5 @@ export function initChartread() { } function advanceToStage4() { - const steps = document.querySelectorAll('.step'); - const stages = document.querySelectorAll('.stage'); - - steps.forEach(s => s.classList.remove('active')); - if (steps[3]) { - steps[3].classList.add('active'); - steps[3].classList.remove('disabled'); - } - - stages.forEach(s => { - s.classList.remove('active'); - s.classList.add('hidden'); - }); - if (stages[3]) { - stages[3].classList.remove('hidden'); - stages[3].classList.add('active'); - } + wizardState.navigateToStage(4); } diff --git a/src/js/colprof.js b/src/js/colprof.js index 4cf66be..e8d0b6e 100644 --- a/src/js/colprof.js +++ b/src/js/colprof.js @@ -164,18 +164,5 @@ async function triggerGamutExtraction(basename, cwd, profilePath) { } function advanceToStage5() { - const steps = document.querySelectorAll('.step'); - const stages = document.querySelectorAll('.stage'); - - steps.forEach(s => s.classList.remove('active')); - if (steps[4]) steps[4].classList.add('active'); - - stages.forEach(s => { - s.classList.remove('active'); - s.classList.add('hidden'); - }); - if (stages[4]) { - stages[4].classList.remove('hidden'); - stages[4].classList.add('active'); - } + wizardState.navigateToStage(5); } diff --git a/src/js/printtarg.js b/src/js/printtarg.js index 6cf1911..9a0408f 100644 --- a/src/js/printtarg.js +++ b/src/js/printtarg.js @@ -652,18 +652,5 @@ export function initPrinttarg() { } function advanceToStage3() { - const steps = document.querySelectorAll('.step'); - const stages = document.querySelectorAll('.stage'); - - steps.forEach(s => s.classList.remove('active')); - if (steps[2]) steps[2].classList.add('active'); - - stages.forEach(s => { - s.classList.remove('active'); - s.classList.add('hidden'); - }); - if (stages[2]) { - stages[2].classList.remove('hidden'); - stages[2].classList.add('active'); - } + wizardState.navigateToStage(3); } diff --git a/src/js/state.js b/src/js/state.js index 5ef8618..fd383a1 100644 --- a/src/js/state.js +++ b/src/js/state.js @@ -4,6 +4,7 @@ export const wizardState = { currentStage: 1, basename: "", cwd: "", + noticeTimer: null, setTarget(basename, cwd) { if (basename) this.basename = basename; @@ -11,8 +12,48 @@ export const wizardState = { return this.updateGating(); }, - navigateToStage(stageNumber) { - this.currentStage = stageNumber; + showNotice(message, type = "warning", durationMs = 5000) { + const banner = document.getElementById("wizardNotification"); + const textEl = document.getElementById("wizardNotificationText"); + const iconEl = document.getElementById("wizardNotificationIcon"); + + if (!banner || !textEl) return; + + if (this.noticeTimer) { + clearTimeout(this.noticeTimer); + this.noticeTimer = null; + } + + banner.className = `notification-banner ${type}`; + banner.classList.remove("hidden"); + textEl.textContent = message; + + if (iconEl) { + if (type === "success") iconEl.textContent = "✓"; + else if (type === "error") iconEl.textContent = "✕"; + else if (type === "info") iconEl.textContent = "ℹ️"; + else iconEl.textContent = "⚠️"; + } + + if (durationMs > 0) { + this.noticeTimer = setTimeout(() => { + this.hideNotice(); + }, durationMs); + } + }, + + hideNotice() { + const banner = document.getElementById("wizardNotification"); + if (banner) { + banner.classList.add("hidden"); + } + if (this.noticeTimer) { + clearTimeout(this.noticeTimer); + this.noticeTimer = null; + } + }, + + applyStageDOM(stageNumber) { const steps = document.querySelectorAll('.step'); const stages = document.querySelectorAll('.stage'); @@ -36,9 +77,30 @@ export const wizardState = { }); }, + async navigateToStage(stageNumber) { + const targetNum = parseInt(stageNumber, 10); + if (isNaN(targetNum) || targetNum < 1 || targetNum > 5) return false; + + if (targetNum === 1) { + this.currentStage = 1; + this.applyStageDOM(1); + return true; + } + + const gating = await this.updateGating(); + if (gating && targetNum <= gating.maxValidStage) { + this.currentStage = targetNum; + this.applyStageDOM(targetNum); + return true; + } else { + this.showNotice(`Stage ${targetNum} is locked because required prerequisite files are missing on disk.`, "warning"); + return false; + } + }, + async updateGating() { const steps = document.querySelectorAll('.step'); - if (!steps || steps.length === 0) return; + if (!steps || steps.length === 0) return null; if (!this.basename || !this.cwd) { steps.forEach((step, idx) => { @@ -48,7 +110,20 @@ export const wizardState = { step.classList.add('disabled'); } }); - return; + + if (this.currentStage > 1) { + this.currentStage = 1; + this.applyStageDOM(1); + } + + return { + stage1_complete: false, + stage2_complete: false, + stage3_complete: false, + stage4_complete: false, + maxValidStage: 1, + unlocked: [true, false, false, false, false], + }; } try { @@ -57,19 +132,32 @@ export const wizardState = { basename: this.basename, }); - // Step 1: always accessible - // Step 2: unlocked if .ti1 exists - // Step 3: unlocked if .ti2 exists - // Step 4: unlocked if .ti3 exists - // Step 5: unlocked if profile exists + // Strict sequential gating: + // Stage 1: always accessible + // Stage 2: unlocked if .ti1 exists (stage1_complete) + // Stage 3: unlocked if .ti1 + .ti2 exist + // Stage 4: unlocked if .ti1 + .ti2 + .ti3 exist + // Stage 5: unlocked if .ti1 + .ti2 + .ti3 + profile exist + const stage1Valid = true; + const stage2Valid = !!status.stage1_complete; + const stage3Valid = stage2Valid && !!status.stage2_complete; + const stage4Valid = stage3Valid && !!status.stage3_complete; + const stage5Valid = stage4Valid && !!status.stage4_complete; + const unlocked = [ - true, - status.stage1_complete, - status.stage2_complete, - status.stage3_complete, - status.stage4_complete, + stage1Valid, + stage2Valid, + stage3Valid, + stage4Valid, + stage5Valid, ]; + let maxValidStage = 1; + if (stage2Valid) maxValidStage = 2; + if (stage3Valid) maxValidStage = 3; + if (stage4Valid) maxValidStage = 4; + if (stage5Valid) maxValidStage = 5; + steps.forEach((step, idx) => { if (unlocked[idx]) { step.classList.remove('disabled'); @@ -77,8 +165,22 @@ export const wizardState = { step.classList.add('disabled'); } }); + + // If current stage is no longer valid, automatically navigate back to maxValidStage + if (this.currentStage > maxValidStage) { + this.currentStage = maxValidStage; + this.applyStageDOM(maxValidStage); + this.showNotice(`Target files changed on disk — returned to Stage ${maxValidStage}.`, "warning"); + } + + return { + ...status, + maxValidStage, + unlocked, + }; } catch (e) { console.warn("Could not verify stage artefacts:", e); + return null; } } }; diff --git a/src/js/targen.js b/src/js/targen.js index 375cda5..1ff196e 100644 --- a/src/js/targen.js +++ b/src/js/targen.js @@ -189,6 +189,7 @@ export function initTargen() { selectedPathDisplay.textContent = `Directory: ${currentWorkingDir}`; } updateGenerateButton(); + wizardState.setTarget(basename, currentWorkingDir); } } catch (err) { console.error("Failed to open file dialog:", err); @@ -358,20 +359,5 @@ export function initTargen() { } function advanceToStage2() { - const steps = document.querySelectorAll('.step'); - const stages = document.querySelectorAll('.stage'); - - // Update stepper - steps.forEach((s) => s.classList.remove('active')); - if (steps[1]) steps[1].classList.add('active'); - - // Update sections - stages.forEach((s) => { - s.classList.remove('active'); - s.classList.add('hidden'); - }); - if (stages[1]) { - stages[1].classList.remove('hidden'); - stages[1].classList.add('active'); - } + wizardState.navigateToStage(2); } diff --git a/src/styles/main.css b/src/styles/main.css index fef156d..83b28b5 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -1099,12 +1099,35 @@ button.danger:hover { color: #a5d6a7; } +.notification-banner.warning { + background: rgba(255, 152, 0, 0.12); + border: 1px solid rgba(255, 152, 0, 0.3); + color: #ffcc80; +} + .notification-banner.error { background: rgba(244, 67, 54, 0.12); border: 1px solid rgba(244, 67, 54, 0.3); color: #ef9a9a; } +.notification-close { + background: none; + border: none; + color: inherit; + font-size: 1rem; + line-height: 1; + cursor: pointer; + padding: 0 4px; + margin-left: auto; + opacity: 0.7; + transition: opacity 0.2s ease; +} + +.notification-close:hover { + opacity: 1; +} + .print-actions-row { display: flex; gap: 12px;