diff --git a/studio/src-tauri/src/capture_manager.rs b/studio/src-tauri/src/capture_manager.rs index cbc93c572..3b308e178 100644 --- a/studio/src-tauri/src/capture_manager.rs +++ b/studio/src-tauri/src/capture_manager.rs @@ -2062,13 +2062,39 @@ fn demo_file_key(path: &Path) -> Option { /// scanned: its key (size + first 64 KB) differs from the saved one. Their /// highlights are frame numbers in the old file, so they don't line up (#21). /// Missing files and demos with no saved key are left out. -pub fn changed_demos(demos: &[KnownDemo]) -> Vec { - demos - .iter() - .filter(|d| !d.file_key.is_empty() && Path::new(&d.path).is_file()) - .filter(|d| demo_file_key(Path::new(&d.path)).is_some_and(|key| key != d.file_key)) - .map(|d| d.path.clone()) - .collect() +/// +/// `checked(done, total)` runs after each demo, so Start Capture's line can +/// count them (#953). Every call; the caller throttles. +pub fn changed_demos_with_progress( + demos: &[KnownDemo], + mut checked: impl FnMut(usize, usize), +) -> Vec { + let mut changed = Vec::new(); + for (i, d) in demos.iter().enumerate() { + if !d.file_key.is_empty() + && Path::new(&d.path).is_file() + && demo_file_key(Path::new(&d.path)).is_some_and(|key| key != d.file_key) + { + changed.push(d.path.clone()); + } + checked(i + 1, demos.len()); + } + changed +} + +/// Whether a `changed_demos_progress` event goes out: the last demo always +/// (so the count ends on "12 of 12"), the others at most one per ~33 ms. +pub(crate) fn changed_progress_due( + last_ms: &std::sync::atomic::AtomicU32, + now_ms: u32, + done: usize, + total: usize, +) -> bool { + if done == total { + last_ms.store(now_ms, std::sync::atomic::Ordering::Relaxed); + return true; + } + progress_emit_due(last_ms, now_ms) } /// A project demo that is no longer at its saved path, and the file that @@ -4009,11 +4035,48 @@ mod tests { ]; std::fs::write(&replaced, b"HLDEMO a different, shorter one").unwrap(); assert_eq!( - changed_demos(&demos), + changed_demos_with_progress(&demos, |_, _| {}), vec![replaced.to_string_lossy().into_owned()] ); } + #[test] + fn the_changed_demo_check_counts_every_demo_it_reads() { + let scratch = Scratch::new("changed_count"); + let same = scratch.path().join("same.dem"); + std::fs::write(&same, b"HLDEMO the scanned one").unwrap(); + let demos = vec![ + KnownDemo { + path: same.to_string_lossy().into_owned(), + file_key: demo_file_key(&same).unwrap(), + }, + KnownDemo { + path: scratch + .path() + .join("gone.dem") + .to_string_lossy() + .into_owned(), + file_key: "1-00".into(), + }, + ]; + let mut seen = Vec::new(); + let changed = changed_demos_with_progress(&demos, |done, total| seen.push((done, total))); + assert!(changed.is_empty()); + assert_eq!(seen, vec![(1, 2), (2, 2)]); + } + + #[test] + fn the_changed_demo_count_is_throttled_but_always_ends_on_the_last() { + use std::sync::atomic::AtomicU32; + let last = AtomicU32::new(u32::MAX); + assert!(changed_progress_due(&last, 0, 1, 10)); + assert!(!changed_progress_due(&last, 10, 2, 10)); + assert!(!changed_progress_due(&last, 32, 3, 10)); + assert!(changed_progress_due(&last, 33, 4, 10)); + // The last demo goes out however soon after the one before. + assert!(changed_progress_due(&last, 34, 10, 10)); + } + #[test] fn a_missing_demo_with_no_key_or_no_match_is_reported_without_a_candidate() { let scratch = Scratch::new("locate_none"); diff --git a/studio/src-tauri/src/lib.rs b/studio/src-tauri/src/lib.rs index 49380af08..423dd99a8 100644 --- a/studio/src-tauri/src/lib.rs +++ b/studio/src-tauri/src/lib.rs @@ -155,11 +155,30 @@ async fn locate_missing_demos( .await } -/// Which demos are no longer the file they were scanned from (#21). +/// Which demos are no longer the file they were scanned from (#21). Emits +/// `changed_demos_progress` ({ done, total }, at most every ~33 ms and on +/// the last demo) for Start Capture's line (#953). #[tauri::command] -async fn changed_demos(demos: Vec) -> Result, String> { +async fn changed_demos( + app_handle: tauri::AppHandle, + demos: Vec, +) -> Result, String> { messages::flatten_spawn_blocking(tokio::task::spawn_blocking(move || { - Ok(capture_manager::changed_demos(&demos)) + use tauri::Emitter; + let started = std::time::Instant::now(); + let last_emit = std::sync::atomic::AtomicU32::new(u32::MAX); + Ok(capture_manager::changed_demos_with_progress( + &demos, + |done, total| { + let now_ms = started.elapsed().as_millis().min(u32::MAX as u128 - 1) as u32; + if capture_manager::changed_progress_due(&last_emit, now_ms, done, total) { + let _ = app_handle.emit( + "changed_demos_progress", + serde_json::json!({ "done": done, "total": total }), + ); + } + }, + )) })) .await } diff --git a/studio/src/capture_pane.js b/studio/src/capture_pane.js index 16f6a959b..26f482070 100644 --- a/studio/src/capture_pane.js +++ b/studio/src/capture_pane.js @@ -27,6 +27,7 @@ import { profileState } from './command_profiles.js'; import { initCaptureSummary, renderCaptureSummary } from './capture_summary_ui.js'; import { computeRequiredCaptureBytes, AGR_BYTES_PER_FRAME } from './capture_estimate.js'; import { setStatusLine, uiStatusText } from './status_line.js'; +import { createCapturePrecheck } from './capture_precheck.js'; import { timeLeftSuffix } from './capture_time_left.js'; import { refreshAfterTyping } from './input_refresh.js'; @@ -39,6 +40,9 @@ let unlistenPatchingFinished = null; // Tracks whether a batch is actively running so refreshLaunchGuard() never // re-enables Start Capture out from under the capture_status "running" lock. let capturingInFlight = false; +// Start Capture's checks before the batch (#953): while they run, +// refreshLaunchGuard() leaves Start greyed out too. +let precheck = null; // The batch's time left (#643): the last estimate, the status line it goes // after, and the timer that counts it down between clip boundaries. let timeLeft = null; @@ -494,7 +498,7 @@ export async function refreshLaunchGuard(state) { const bannedCommandsPresent = bannedCount > 0; const blocked = bannedCommandsPresent || pathsMissing || noHighlightsSelected || noDrivesConfigured || noUsableSpace || insufficientSpace; - if (!capturingInFlight) { + if (!capturingInFlight && !precheck?.isChecking()) { startBtn.disabled = blocked; } // Every change that can block Start comes through here, so the strip @@ -884,6 +888,7 @@ export function initCaptureUI(getState, onSettingsChange, onStatusChange, getTak }; currentGetState = getState; + precheck = createCapturePrecheck({ startBtn, statusEl, progressContainer, refreshStart: () => refreshLaunchGuard() }); initCaptureSummary(currentCaptureSetup); currentOnSettingsChange = onSettingsChange || null; currentOnStatusChange = onStatusChange || null; @@ -1381,7 +1386,26 @@ export function initCaptureUI(getState, onSettingsChange, onStatusChange, getTak if (startBtn) { startBtn.addEventListener('click', async () => { + if (precheck.isChecking()) return; + precheck.begin(); + try { + await runChecksAndStart(); + } finally { + // A check that threw, or returned without saying why. + precheck.stop(); + } + }); + + // Start Capture Batch's checks, in order, each on the footer line (#953). + // A check that stops the batch calls precheck.stop() with the reason its + // toast gives; one that passes everything hands over to the batch. + const runChecksAndStart = async () => { const state = getState ? getState() : { scanPaths: [], targetDrives: [], currentScannedDemos: [] }; + // Shows `text` as a toast and on the line, and brings Start back. + const stopWith = (text) => { + showToast(text, 'error'); + precheck.stop(text); + }; // OBS mode: verify live, right before committing to a batch, rather // than trusting whatever a check found minutes (or a whole session) @@ -1392,9 +1416,10 @@ export function initCaptureUI(getState, onSettingsChange, onStatusChange, getTak // it (much like HLAE doesn't need to already be open either) and wait // for it to come up before giving up. if (document.querySelector('#config-capture-mode')?.value === 'obs') { + precheck.step('obs'); await runObsConnectionTest(); if (!isObsConnected() && !(await launchObsAndRetry())) { - showToast(STRINGS.CAPTURE.OBS_NOT_CONNECTED_WARNING, 'error'); + stopWith(STRINGS.CAPTURE.OBS_NOT_CONNECTED_WARNING); return; } } @@ -1404,17 +1429,18 @@ export function initCaptureUI(getState, onSettingsChange, onStatusChange, getTak // never let a capture start without sufficient disk space (this is // also what closes the old zero-drive bypass: `availableBytes === 0` // now blocks unconditionally instead of skipping the check). + precheck.step('disk'); const guard = await refreshLaunchGuard(state); if (guard && guard.blocked) { // Ahead of the disk branches, and well ahead of the running-game check // below: "you haven't picked anything yet" is the answer the user needs, // and it should not arrive behind a modal about closing their game. if (guard.noHighlightsSelected) { - showToast(STRINGS.CAPTURE.NO_HIGHLIGHTS_SELECTED_WARNING, 'error'); + stopWith(STRINGS.CAPTURE.NO_HIGHLIGHTS_SELECTED_WARNING); } else if (guard.availableBytes === 0) { - showToast(STRINGS.CAPTURE.NO_CAPTURE_OUTPUT_DIR_WITH_SPACE, 'error'); + stopWith(STRINGS.CAPTURE.NO_CAPTURE_OUTPUT_DIR_WITH_SPACE); } else { - showToast(STRINGS.CAPTURE.insufficientDiskSpaceToast((guard.requiredBytes / 1e9).toFixed(2), (guard.availableBytes / 1e9).toFixed(2)), 'error'); + stopWith(STRINGS.CAPTURE.insufficientDiskSpaceToast((guard.requiredBytes / 1e9).toFixed(2), (guard.availableBytes / 1e9).toFixed(2))); } return; } @@ -1424,14 +1450,18 @@ export function initCaptureUI(getState, onSettingsChange, onStatusChange, getTak // still missing after that stops the batch, since its highlights // cannot be captured. A picked demo with more than one map stops it // too (#217); main.js has said why then ('handled'). - const demosReady = pickedDemosPresent ? await pickedDemosPresent() : true; + precheck.step('demos'); + const demosReady = pickedDemosPresent + ? await pickedDemosPresent({ onProgress: (p) => precheck.step('demos', p?.done, p?.total) }) + : true; if (demosReady !== true) { - if (demosReady === false) showToast(STRINGS.CAPTURE.DEMOS_MISSING_NOT_STARTED, 'error'); + if (demosReady === false) stopWith(STRINGS.CAPTURE.DEMOS_MISSING_NOT_STARTED); return; } // A map a session crashed on before, for a reason the demo file can't // show (#207): ask before patching. + precheck.step('maps'); const pickedPaths = (state.currentScannedDemos || []) .filter((d) => (d.streaks || []).some((s) => s.selected === true)) .map((d) => d.path); @@ -1444,6 +1474,7 @@ export function initCaptureUI(getState, onSettingsChange, onStatusChange, getTak // One with no camera data captures a frozen view (#920). if (!(await confirmNoCameraData(picked))) return; + precheck.step('paths'); const activePayload = buildCapturePayload(state); if (!activePayload) return; // buildCapturePayload already toasted the reason @@ -1451,7 +1482,7 @@ export function initCaptureUI(getState, onSettingsChange, onStatusChange, getTak await validatePaths(activePayload.hlae_path, activePayload.game_path); } catch (err) { console.error("Executable path validation failed:", err); - showToast(String(err), 'error'); + stopWith(String(err)); return; } @@ -1470,8 +1501,9 @@ export function initCaptureUI(getState, onSettingsChange, onStatusChange, getTak resolution_width: activePayload.resolution_width, resolution_height: activePayload.resolution_height, }; + precheck.step('game'); if (!(await closeGameWithOtherSettings(launchRequest, { button: startBtn }))) { - setStatusLine(statusEl, STRINGS.RUNNING_GAME.BATCH_NOT_STARTED_STATUS); + precheck.stop(STRINGS.RUNNING_GAME.BATCH_NOT_STARTED_STATUS); return; } let engineAlreadyRunning = false; @@ -1483,6 +1515,7 @@ export function initCaptureUI(getState, onSettingsChange, onStatusChange, getTak } const runBatch = () => { + precheck.handOver(); showToast(STRINGS.CAPTURE.INITIALIZING_CAPTURE_BATCH, "info"); startBtn.disabled = true; if (cancelBtn) cancelBtn.disabled = false; @@ -1490,17 +1523,21 @@ export function initCaptureUI(getState, onSettingsChange, onStatusChange, getTak }; if (engineAlreadyRunning) { + // Its window asks what to do and starts the batch itself; Start comes + // back behind it in case the answer is no. + precheck.stop(STRINGS.CAPTURE_PRECHECK.GAME_OPEN); requestProcessGuardedLaunch(runBatch, { forBatch: true }); return; } // Before any patching: without Steam the game can't start. + precheck.step('steam'); if (!(await ensureSteamReady())) { - setStatusLine(statusEl, STRINGS.STEAM.BATCH_NOT_STARTED_STATUS); + precheck.stop(STRINGS.STEAM.BATCH_NOT_STARTED_STATUS); return; } runBatch(); - }); + }; } // Split out of the click handler so the process-conflict modal can park it diff --git a/studio/src/capture_precheck.js b/studio/src/capture_precheck.js new file mode 100644 index 000000000..cf4391c90 --- /dev/null +++ b/studio/src/capture_precheck.js @@ -0,0 +1,77 @@ +// capture_precheck.js — what the Capture footer shows while Start Capture +// Batch checks everything before the batch starts (#953): OBS, disk space, +// the picked demos, the maps, the paths, a running game and Steam. Start is +// greyed out and the bar slides from the click, so the click never looks +// lost; a check that stops the batch brings Start back and says why. +// capture_pane.js calls it at each step. +// +// The sliding bar is the page's own `.progress-bar-container.indeterminate`; +// it can move to progress_view.js once #951 lands. +import { STRINGS } from './strings.js'; +import { setStatusLine } from './status_line.js'; + +/// The line for `step`; `done`/`total` count the picked demos read so far. +export function precheckLine(step, done = 0, total = 0) { + const S = STRINGS.CAPTURE_PRECHECK; + switch (step) { + case 'obs': return S.OBS; + case 'disk': return S.DISK; + case 'demos': return total > 0 ? S.demosCounted(Math.min(done, total), total) : S.DEMOS; + case 'maps': return S.MAPS; + case 'paths': return S.PATHS; + case 'game': return S.GAME; + case 'steam': return S.STEAM; + default: return S.STARTING; + } +} + +/** + * The footer while the checks run. `refreshStart()` puts Start back the way + * the disk-space guard wants it (capture_pane.js' refreshLaunchGuard). + */ +export function createCapturePrecheck({ startBtn, statusEl, progressContainer, refreshStart }) { + let checking = false; + let shownBefore = ''; + + function slide(on) { + if (!progressContainer) return; + progressContainer.classList.toggle('indeterminate', on); + } + + return { + isChecking: () => checking, + + /// The click: Start greys out and the first line shows at once. + begin() { + checking = true; + if (startBtn) startBtn.disabled = true; + if (progressContainer) { + shownBefore = progressContainer.style.display; + progressContainer.style.display = 'block'; + } + slide(true); + setStatusLine(statusEl, precheckLine('start')); + }, + + step(name, done, total) { + if (checking) setStatusLine(statusEl, precheckLine(name, done, total)); + }, + + /// A check stopped the batch: `why` is what its toast said (or nothing, + /// when the user said no to a question, or the check set its own line). + stop(why = STRINGS.CAPTURE_PRECHECK.NOT_STARTED) { + if (!checking) return; + checking = false; + slide(false); + if (progressContainer) progressContainer.style.display = shownBefore; + if (why) setStatusLine(statusEl, why); + refreshStart?.(); + }, + + /// Every check passed: the batch's own lines take over from here. + handOver() { + checking = false; + slide(false); + }, + }; +} diff --git a/studio/src/capture_precheck.test.js b/studio/src/capture_precheck.test.js new file mode 100644 index 000000000..ed4061157 --- /dev/null +++ b/studio/src/capture_precheck.test.js @@ -0,0 +1,88 @@ +import { describe, it, expect, vi } from 'vitest'; +import { precheckLine, createCapturePrecheck } from './capture_precheck.js'; + +// Just enough of an element for the factory: no DOM in these tests. +function fakeEl(display = 'none') { + const classes = new Set(); + return { + disabled: false, + textContent: '', + title: '', + style: { display }, + classList: { + toggle: (name, on) => (on ? classes.add(name) : classes.delete(name)), + contains: (name) => classes.has(name), + }, + }; +} + +describe('capture_precheck (#953)', () => { + it('names each check in plain words', () => { + expect(precheckLine('start')).toBe('Getting ready to capture…'); + expect(precheckLine('obs')).toBe('Checking OBS…'); + expect(precheckLine('disk')).toBe('Checking the disk space…'); + expect(precheckLine('demos')).toBe('Checking the picked demos are where they were…'); + expect(precheckLine('demos', 3, 12)).toBe('Checking the picked demos are where they were (3 of 12)…'); + expect(precheckLine('demos', 15, 12)).toBe('Checking the picked demos are where they were (12 of 12)…'); + expect(precheckLine('maps')).toBe('Checking the maps and demos for known problems…'); + expect(precheckLine('paths')).toBe('Checking the game and HLAE paths…'); + expect(precheckLine('game')).toBe('Checking whether the game is already open…'); + expect(precheckLine('steam')).toBe('Checking Steam…'); + }); + + it('greys Start out and slides the bar from the click, and a stop puts both back', () => { + const startBtn = fakeEl(); + const statusEl = fakeEl(); + const progressContainer = fakeEl('none'); + const refreshStart = vi.fn(); + const p = createCapturePrecheck({ startBtn, statusEl, progressContainer, refreshStart }); + + p.begin(); + expect(p.isChecking()).toBe(true); + expect(startBtn.disabled).toBe(true); + expect(progressContainer.style.display).toBe('block'); + expect(progressContainer.classList.contains('indeterminate')).toBe(true); + expect(statusEl.textContent).toBe('Getting ready to capture…'); + + p.step('demos', 2, 5); + expect(statusEl.textContent).toBe('Checking the picked demos are where they were (2 of 5)…'); + + p.stop('Not enough disk space.'); + expect(p.isChecking()).toBe(false); + expect(statusEl.textContent).toBe('Not enough disk space.'); + expect(progressContainer.style.display).toBe('none'); + expect(progressContainer.classList.contains('indeterminate')).toBe(false); + expect(refreshStart).toHaveBeenCalledTimes(1); + + // A second stop (the click handler's finally) changes nothing. + p.stop(); + expect(statusEl.textContent).toBe('Not enough disk space.'); + expect(refreshStart).toHaveBeenCalledTimes(1); + // Nor does a late step. + p.step('steam'); + expect(statusEl.textContent).toBe('Not enough disk space.'); + }); + + it('a stop with no reason says it was not started', () => { + const statusEl = fakeEl(); + const p = createCapturePrecheck({ startBtn: fakeEl(), statusEl, progressContainer: fakeEl() }); + p.begin(); + p.stop(); + expect(statusEl.textContent).toBe('Not started.'); + }); + + it('hands over to the batch with the bar still shown and Start still greyed out', () => { + const startBtn = fakeEl(); + const progressContainer = fakeEl('none'); + const refreshStart = vi.fn(); + const p = createCapturePrecheck({ startBtn, statusEl: fakeEl(), progressContainer, refreshStart }); + p.begin(); + p.handOver(); + expect(p.isChecking()).toBe(false); + expect(startBtn.disabled).toBe(true); + expect(progressContainer.style.display).toBe('block'); + expect(progressContainer.classList.contains('indeterminate')).toBe(false); + p.stop(); + expect(refreshStart).not.toHaveBeenCalled(); + }); +}); diff --git a/studio/src/ipc_bridge.js b/studio/src/ipc_bridge.js index a6c2a000d..b9f7cc4b2 100644 --- a/studio/src/ipc_bridge.js +++ b/studio/src/ipc_bridge.js @@ -92,12 +92,21 @@ export async function estimateRescan(paths) { // `[{ path, candidate }]`; quiet on failure, since the project itself loaded. // Paths of the demos whose file no longer matches its saved key (#21). // Empty when the check itself fails, so a broken check never blocks a batch. -export async function changedDemos(demos) { - return invoke("changed_demos", { demos }) - .catch((err) => { - console.error("IPC Execution Error (changed_demos):", err); - return []; - }); +// `onProgress({ done, total })` gets `changed_demos_progress` while it reads +// them (#953). +export async function changedDemos(demos, onProgress = null) { + const unlisten = onProgress + ? await listen('changed_demos_progress', (event) => onProgress(event.payload)).catch(() => null) + : null; + try { + return await invoke("changed_demos", { demos }) + .catch((err) => { + console.error("IPC Execution Error (changed_demos):", err); + return []; + }); + } finally { + unlisten?.(); + } } export async function locateMissingDemos(demos, searchDirs) { diff --git a/studio/src/project_demos.js b/studio/src/project_demos.js index 3e1715807..e4b31448d 100644 --- a/studio/src/project_demos.js +++ b/studio/src/project_demos.js @@ -123,8 +123,9 @@ export function createProjectDemos({ getDemos, getTakeIndex, getScanPaths, refre // Start Capture Batch's check: a demo can go missing after the queue was // loaded. True when every demo with a highlight picked is where the queue - // says, after any the user chose to relocate. - async function pickedDemosPresent() { + // says, after any the user chose to relocate. `onProgress({ done, total })` + // counts the changed-demo check, for Start Capture's line (#953). + async function pickedDemosPresent({ onProgress = null } = {}) { const picked = getDemos().filter((d) => (d.streaks || []).some((s) => s.selected === true)); if (picked.length === 0) return true; if ((await checkMissingDemos(null, [], picked)) !== 0) return false; @@ -133,7 +134,7 @@ export function createProjectDemos({ getDemos, getTakeIndex, getScanPaths, refre // anyway") is at the right path but isn't the demo that was scanned: // every highlight would record the wrong moment, and any past its end // would leave the game stopped at the end of the demo. - const changed = await changedDemos(picked.filter((d) => d.file_key).map((d) => ({ path: d.path, file_key: d.file_key }))); + const changed = await changedDemos(picked.filter((d) => d.file_key).map((d) => ({ path: d.path, file_key: d.file_key })), onProgress); if (changed.length === 0) return true; const rescan = await themedConfirm(STRINGS.MAIN.CHANGED_DEMOS_MESSAGE, { title: STRINGS.MAIN.CHANGED_DEMOS_TITLE, diff --git a/studio/src/queue_split.js b/studio/src/queue_split.js index c72780cd1..a40c6d215 100644 --- a/studio/src/queue_split.js +++ b/studio/src/queue_split.js @@ -102,10 +102,11 @@ export function createQueueSplit({ getDemos, removeDemo, scan, getTakeIndex, ref // Start Capture Batch's demo checks: picks in a demo with more than one map // first, then missing or changed demos (#21). Resolves true to go ahead, // false when a demo is missing (capture_pane.js says so), or 'handled' when - // this already told the user why not. - async function captureDemosReady() { + // this already told the user why not. `options.onProgress` counts the + // changed-demo check (#953). + async function captureDemosReady(options = {}) { const multi = pickedMultiMapDemos(getDemos()); - if (multi.length === 0) return pickedDemosPresent(); + if (multi.length === 0) return pickedDemosPresent(options); const split = await themedConfirm(STRINGS.MAIN.MULTI_MAP_PICKED_MESSAGE, { title: STRINGS.MAIN.MULTI_MAP_PICKED_TITLE, confirmLabel: STRINGS.MAIN.MULTI_MAP_PICKED_SPLIT, diff --git a/studio/src/strings.js b/studio/src/strings.js index 51088b388..f28aefb5c 100644 --- a/studio/src/strings.js +++ b/studio/src/strings.js @@ -2198,6 +2198,22 @@ export const STRINGS = { FAILED: 'Stopped with an error (see the message).', }, + // ── capture_precheck.js: the Capture footer's line while Start Capture + // Batch checks everything before the batch starts (#953) + CAPTURE_PRECHECK: { + STARTING: 'Getting ready to capture…', + OBS: 'Checking OBS…', + DISK: 'Checking the disk space…', + DEMOS: 'Checking the picked demos are where they were…', + demosCounted: (done, total) => `Checking the picked demos are where they were (${done} of ${total})…`, + MAPS: 'Checking the maps and demos for known problems…', + PATHS: 'Checking the game and HLAE paths…', + GAME: 'Checking whether the game is already open…', + STEAM: 'Checking Steam…', + NOT_STARTED: 'Not started.', + GAME_OPEN: 'Not started yet: the game is already open.', + }, + // ── command_suggest.js: the Commands tab's type-ahead (#215) ──────────── COMMAND_SUGGEST: { OWNED_BY_STUDIO: "DoD Studio sets this itself, so it's refused here.", diff --git a/studio/tests/e2e/capture-precheck.html b/studio/tests/e2e/capture-precheck.html new file mode 100644 index 000000000..e948272a1 --- /dev/null +++ b/studio/tests/e2e/capture-precheck.html @@ -0,0 +1,52 @@ + + + + + + capture precheck test harness + + + + + +
+
+ + +

Status: Waiting...

+ + + + + diff --git a/studio/tests/e2e/capture-precheck.spec.js b/studio/tests/e2e/capture-precheck.spec.js new file mode 100644 index 000000000..987cce6e8 --- /dev/null +++ b/studio/tests/e2e/capture-precheck.spec.js @@ -0,0 +1,67 @@ +// Start Capture Batch's checks before the batch (#953): Start greys out and +// the footer line names each check from the click, and a check that stops +// the batch brings Start back with the reason on the line. +import { test, expect } from '@playwright/test'; + +async function loadHarness(page) { + await page.addInitScript(() => { + window.__mockInvokeHandlers = { + // window.__holdDisk holds the disk check after the first pass. + calculate_export_pool_space: () => (window.__holdDisk ? new Promise(() => {}) : 500e9), + diagnose_capture_output_paths: () => [], + }; + }); + await page.goto('/tests/e2e/capture-precheck.html'); + await page.waitForFunction(() => window.__harnessReady === true); + // The disk-space guard's first pass lets Start go. + await expect(page.locator('#start-capture-btn')).toBeEnabled(); +} + +test('the first line shows at once, before any check has answered', async ({ page }) => { + await loadHarness(page); + await page.evaluate(() => { window.__holdDisk = true; }); + await page.click('#start-capture-btn'); + await expect(page.locator('#start-capture-btn')).toBeDisabled(); + await expect(page.locator('#batch-status')).toHaveText('Checking the disk space…'); + await expect(page.locator('#capture-progress-container')).toHaveClass(/indeterminate/); +}); + +test('Start greys out and the line names each check while they run', async ({ page }) => { + await loadHarness(page); + await page.click('#start-capture-btn'); + await expect(page.locator('#start-capture-btn')).toBeDisabled(); + await expect(page.locator('#capture-progress-container')).toBeVisible(); + await expect(page.locator('#capture-progress-container')).toHaveClass(/indeterminate/); + // Held at the demo check until the test settles it. + await page.waitForFunction(() => window.__demosCalls === 1); + await expect(page.locator('#batch-status')).toHaveText('Checking the picked demos are where they were…'); + await page.evaluate(() => window.__demosProgress({ done: 3, total: 12 })); + await expect(page.locator('#batch-status')).toHaveText('Checking the picked demos are where they were (3 of 12)…'); + // A second click while it checks does nothing. + await page.locator('#start-capture-btn').click({ force: true }); + expect(await page.evaluate(() => window.__demosCalls)).toBe(1); + // Typing in a field that refreshes the guard leaves Start greyed out. + await page.evaluate(() => import('/src/capture_pane.js').then((m) => m.refreshLaunchGuard())); + await expect(page.locator('#start-capture-btn')).toBeDisabled(); +}); + +test('a demo that is missing stops it: Start comes back and the line says why', async ({ page }) => { + await loadHarness(page); + await page.click('#start-capture-btn'); + await page.waitForFunction(() => window.__demosCalls === 1); + await page.evaluate(() => window.__demosDone(false)); + await expect(page.locator('#batch-status')).toContainText('Capture not started: a demo with picked highlights is missing.'); + await expect(page.locator('#start-capture-btn')).toBeEnabled(); + await expect(page.locator('#capture-progress-container')).not.toHaveClass(/indeterminate/); + await expect(page.locator('#capture-progress-container')).toBeHidden(); +}); + +test('a question answered no says it was not started', async ({ page }) => { + await loadHarness(page); + await page.click('#start-capture-btn'); + await page.waitForFunction(() => window.__demosCalls === 1); + // 'handled': the demo check asked something and the answer was no. + await page.evaluate(() => window.__demosDone('handled')); + await expect(page.locator('#batch-status')).toHaveText('Not started.'); + await expect(page.locator('#start-capture-btn')).toBeEnabled(); +});