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");
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>
<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 -->
<section id="stage-1" class="stage active">
<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;
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');
+7 -20
View File
@@ -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);
}
+1 -14
View File
@@ -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);
}
+1 -14
View File
@@ -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);
}
+116 -14
View File
@@ -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;
}
}
};
+2 -16
View File
@@ -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);
}
+23
View File
@@ -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;