feat(wizard): re-validate disk artefacts on stage entry, app start and window focus (fixes #151) #156

Merged
gronod merged 1 commits from feat/revalidate-artefacts-151 into development 2026-08-30 19:31:34 +01:00
9 changed files with 269 additions and 104 deletions
+91
View File
@@ -1405,4 +1405,95 @@ mod tests {
let candidates_exe = get_binary_candidates("targen.exe"); let candidates_exe = get_binary_candidates("targen.exe");
assert_eq!(candidates_exe, vec!["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);
}
} }
+7
View File
@@ -56,6 +56,13 @@
</aside> </aside>
<main class="content"> <main class="content">
<!-- Global Wizard Notification Banner -->
<div id="wizardNotification" class="notification-banner warning hidden" role="alert">
<span class="notification-icon" id="wizardNotificationIcon">⚠️</span>
<div class="notification-text" id="wizardNotificationText"></div>
<button type="button" id="wizardNotificationClose" class="notification-close" title="Dismiss notice"></button>
</div>
<!-- Stage 1: targen --> <!-- Stage 1: targen -->
<section id="stage-1" class="stage active"> <section id="stage-1" class="stage active">
<div style="display: flex; justify-content: space-between; align-items: center;"> <div style="display: flex; justify-content: space-between; align-items: center;">
+21 -26
View File
@@ -11,37 +11,32 @@ import { wizardState } from './state.js';
const { invoke } = window.__TAURI__.core; const { invoke } = window.__TAURI__.core;
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
// Wizard navigation // Wizard stepper navigation with re-validation
const steps = document.querySelectorAll('.step'); 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 // Initialize gating on load
wizardState.updateGating(); 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 // About modal
const aboutDialog = document.getElementById('aboutDialog'); const aboutDialog = document.getElementById('aboutDialog');
const openAboutBtn = document.getElementById('openAboutBtn'); const openAboutBtn = document.getElementById('openAboutBtn');
+7 -20
View File
@@ -476,7 +476,8 @@ export function initChartread() {
basename: basename, basename: basename,
}); });
logPre.textContent += `\n[SUCCESS] Promoted ${recordedPasses[0].filename}${basename}.ti3 (single pass, average not invoked).\n`; 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) { } catch (e) {
btnFinishAndAverage.disabled = false; btnFinishAndAverage.disabled = false;
setPrompt(`Failed to restore ${basename}.ti3: ${e}`); setPrompt(`Failed to restore ${basename}.ti3: ${e}`);
@@ -503,7 +504,8 @@ export function initChartread() {
if (event.payload.code === 0) { if (event.payload.code === 0) {
setPrompt(`✅ Successfully averaged ${recordedPasses.length} measurement passes into ${basename}.ti3!`); setPrompt(`✅ Successfully averaged ${recordedPasses.length} measurement passes into ${basename}.ti3!`);
logPre.textContent += `\n[SUCCESS] average wrote ${basename}.ti3\n`; logPre.textContent += `\n[SUCCESS] average wrote ${basename}.ti3\n`;
acceptStage3(basename, cwd); setStage3Result(basename, cwd);
advanceToStage4();
} else { } else {
setPrompt(`⚠️ Argyll average exited with code ${event.payload.code}. Promoting pass 1 as fallback.`); 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`; 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, source: recordedPasses[0].filename,
basename: basename, basename: basename,
}); });
acceptStage3(basename, cwd); setStage3Result(basename, cwd);
advanceToStage4();
} catch (e) { } catch (e) {
btnFinishAndAverage.disabled = false; btnFinishAndAverage.disabled = false;
setPrompt(`Average failed and fallback promote failed: ${e}`); setPrompt(`Average failed and fallback promote failed: ${e}`);
@@ -609,21 +612,5 @@ export function initChartread() {
} }
function advanceToStage4() { function advanceToStage4() {
const steps = document.querySelectorAll('.step'); wizardState.navigateToStage(4);
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');
}
} }
+1 -14
View File
@@ -164,18 +164,5 @@ async function triggerGamutExtraction(basename, cwd, profilePath) {
} }
function advanceToStage5() { function advanceToStage5() {
const steps = document.querySelectorAll('.step'); wizardState.navigateToStage(5);
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');
}
} }
+1 -14
View File
@@ -652,18 +652,5 @@ export function initPrinttarg() {
} }
function advanceToStage3() { function advanceToStage3() {
const steps = document.querySelectorAll('.step'); wizardState.navigateToStage(3);
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');
}
} }
+116 -14
View File
@@ -4,6 +4,7 @@ export const wizardState = {
currentStage: 1, currentStage: 1,
basename: "", basename: "",
cwd: "", cwd: "",
noticeTimer: null,
setTarget(basename, cwd) { setTarget(basename, cwd) {
if (basename) this.basename = basename; if (basename) this.basename = basename;
@@ -11,8 +12,48 @@ export const wizardState = {
return this.updateGating(); return this.updateGating();
}, },
navigateToStage(stageNumber) { showNotice(message, type = "warning", durationMs = 5000) {
this.currentStage = stageNumber; 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 steps = document.querySelectorAll('.step');
const stages = document.querySelectorAll('.stage'); 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() { async updateGating() {
const steps = document.querySelectorAll('.step'); const steps = document.querySelectorAll('.step');
if (!steps || steps.length === 0) return; if (!steps || steps.length === 0) return null;
if (!this.basename || !this.cwd) { if (!this.basename || !this.cwd) {
steps.forEach((step, idx) => { steps.forEach((step, idx) => {
@@ -48,7 +110,20 @@ export const wizardState = {
step.classList.add('disabled'); 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 { try {
@@ -57,19 +132,32 @@ export const wizardState = {
basename: this.basename, basename: this.basename,
}); });
// Step 1: always accessible // Strict sequential gating:
// Step 2: unlocked if .ti1 exists // Stage 1: always accessible
// Step 3: unlocked if .ti2 exists // Stage 2: unlocked if .ti1 exists (stage1_complete)
// Step 4: unlocked if .ti3 exists // Stage 3: unlocked if .ti1 + .ti2 exist
// Step 5: unlocked if profile exists // 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 = [ const unlocked = [
true, stage1Valid,
status.stage1_complete, stage2Valid,
status.stage2_complete, stage3Valid,
status.stage3_complete, stage4Valid,
status.stage4_complete, stage5Valid,
]; ];
let maxValidStage = 1;
if (stage2Valid) maxValidStage = 2;
if (stage3Valid) maxValidStage = 3;
if (stage4Valid) maxValidStage = 4;
if (stage5Valid) maxValidStage = 5;
steps.forEach((step, idx) => { steps.forEach((step, idx) => {
if (unlocked[idx]) { if (unlocked[idx]) {
step.classList.remove('disabled'); step.classList.remove('disabled');
@@ -77,8 +165,22 @@ export const wizardState = {
step.classList.add('disabled'); 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) { } catch (e) {
console.warn("Could not verify stage artefacts:", e); console.warn("Could not verify stage artefacts:", e);
return null;
} }
} }
}; };
+2 -16
View File
@@ -189,6 +189,7 @@ export function initTargen() {
selectedPathDisplay.textContent = `Directory: ${currentWorkingDir}`; selectedPathDisplay.textContent = `Directory: ${currentWorkingDir}`;
} }
updateGenerateButton(); updateGenerateButton();
wizardState.setTarget(basename, currentWorkingDir);
} }
} catch (err) { } catch (err) {
console.error("Failed to open file dialog:", err); console.error("Failed to open file dialog:", err);
@@ -358,20 +359,5 @@ export function initTargen() {
} }
function advanceToStage2() { function advanceToStage2() {
const steps = document.querySelectorAll('.step'); wizardState.navigateToStage(2);
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');
}
} }
+23
View File
@@ -1099,12 +1099,35 @@ button.danger:hover {
color: #a5d6a7; 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 { .notification-banner.error {
background: rgba(244, 67, 54, 0.12); background: rgba(244, 67, 54, 0.12);
border: 1px solid rgba(244, 67, 54, 0.3); border: 1px solid rgba(244, 67, 54, 0.3);
color: #ef9a9a; 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 { .print-actions-row {
display: flex; display: flex;
gap: 12px; gap: 12px;