From a323e3f1bc4c1f0150b81809ecea1ea4c7488ef8 Mon Sep 17 00:00:00 2001 From: ccoventry <17183194+ccoventry@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:08:44 -0600 Subject: [PATCH] Start Capture: grey Start out and name each check before the batch (#953) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The click ran OBS, disk, demo, map, path, game and Steam checks with Start still live and nothing on the footer. capture_precheck.js greys Start out and slides the footer bar from the click, puts each check on the status line ("Checking the picked demos are where they were (3 of 12)…"), and on a stop brings Start back with the reason. refreshLaunchGuard leaves Start greyed out while the checks run. changed_demos emits changed_demos_progress (33 ms throttle, always the last demo) for the count. Co-Authored-By: Claude Opus 5.5 --- studio/src-tauri/src/capture_manager.rs | 79 +++++++++++++++++--- studio/src-tauri/src/lib.rs | 25 ++++++- studio/src/capture_pane.js | 59 ++++++++++++--- studio/src/capture_precheck.js | 77 ++++++++++++++++++++ studio/src/capture_precheck.test.js | 88 +++++++++++++++++++++++ studio/src/ipc_bridge.js | 21 ++++-- studio/src/project_demos.js | 7 +- studio/src/queue_split.js | 7 +- studio/src/strings.js | 16 +++++ studio/tests/e2e/capture-precheck.html | 52 ++++++++++++++ studio/tests/e2e/capture-precheck.spec.js | 67 +++++++++++++++++ 11 files changed, 464 insertions(+), 34 deletions(-) create mode 100644 studio/src/capture_precheck.js create mode 100644 studio/src/capture_precheck.test.js create mode 100644 studio/tests/e2e/capture-precheck.html create mode 100644 studio/tests/e2e/capture-precheck.spec.js diff --git a/studio/src-tauri/src/capture_manager.rs b/studio/src-tauri/src/capture_manager.rs index 91986eff8..a0b4b22ca 100644 --- a/studio/src-tauri/src/capture_manager.rs +++ b/studio/src-tauri/src/capture_manager.rs @@ -1895,13 +1895,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 @@ -3712,11 +3738,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 9db262cb8..ed419d52e 100644 --- a/studio/src-tauri/src/lib.rs +++ b/studio/src-tauri/src/lib.rs @@ -154,11 +154,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 a18edcd3d..ba14682a4 100644 --- a/studio/src/ipc_bridge.js +++ b/studio/src/ipc_bridge.js @@ -78,12 +78,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 966c46ee3..c38ddc0d3 100644 --- a/studio/src/queue_split.js +++ b/studio/src/queue_split.js @@ -91,10 +91,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 57f101faf..f148b3c44 100644 --- a/studio/src/strings.js +++ b/studio/src/strings.js @@ -2167,6 +2167,22 @@ export const STRINGS = { SHOW_FILE: 'Show', }, + // ── 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(); +});