diff --git a/steps/favicon.png b/steps/favicon.png new file mode 100644 index 0000000..2703d60 Binary files /dev/null and b/steps/favicon.png differ diff --git a/steps/index.html b/steps/index.html index c7a3b62..c2be164 100644 --- a/steps/index.html +++ b/steps/index.html @@ -6,6 +6,7 @@ Radar and Video Visualizer - Timestamp Synchronized + diff --git a/steps/src/db.js b/steps/src/db.js index 8f2fb78..a94e0e5 100644 --- a/steps/src/db.js +++ b/steps/src/db.js @@ -1,79 +1,127 @@ -// -------------------------- IndexedDB for Caching ----------------- // -let db; +// In src/db.js, replace the entire file content with this: -//---------------------------Initialize DB----------------------------// +let db; // Initializes the IndexedDB database. -// @param {function} callback - A function to be called once the database is initialized. +// Opens or creates the 'visualizerDB' database. export function initDB(callback) { - // Open the database with the name "visualizerDB" and version 1. const request = indexedDB.open("visualizerDB", 1); - // Event handler for when the database needs to be upgraded (e.g., first time creation or version change). request.onupgradeneeded = function (event) { const db = event.target.result; - // Create an object store named "files" if it doesn't already exist. if (!db.objectStoreNames.contains("files")) { db.createObjectStore("files"); + // Creates an object store named 'files' if it doesn't exist. } }; - // Event handler for a successful database opening. request.onsuccess = function (event) { db = event.target.result; console.log("Database initialized"); - // Call the provided callback function. + // Assigns the opened database to the 'db' variable. if (callback) callback(); }; - // Event handler for an error during database opening. request.onerror = function (event) { console.error("IndexedDB error:", event.target.errorCode); + // Even if DB fails, call the callback so the app doesn't hang + // Logs any errors during database operations. + // Calls the callback even if there's an error to prevent the app from hanging. + if (callback) callback(); }; } -//---------------------------save file------------------------------// - -// Saves a file (or any value) to the IndexedDB. -// @param {string} key - The key to store the value under. -// @param {*} value - The value to be stored. -export function saveFileToDB(key, value) { - // If the database is not initialized, return. +/** + * Saves a file and its metadata to IndexedDB for versioning and integrity checks. + * @param {string} key The key to store the file under (e.g., 'json', 'video'). + * @param {File} file The file object to be cached. + */ +// Saves a file (Blob) along with its metadata into the IndexedDB. +export function saveFileWithMetadata(key, file) { if (!db) return; - // Start a read-write transaction on the "files" object store. + const transaction = db.transaction(["files"], "readwrite"); const store = transaction.objectStore("files"); - // Put (add or update) the value with the given key. - const request = store.put(value, key); - // Event handler for a successful save operation. - request.onsuccess = () => console.log(`File '${key}' saved to DB.`); - // Event handler for an error during saving. - request.onerror = (event) => - console.error(`Error saving file '${key}':`, event.target.error); -} + + // Creates a read-write transaction and gets the 'files' object store. + // Store an object containing the blob and its metadata + const dataToStore = { + filename: file.name, + size: file.size, + type: file.type, + blob: file + // Prepares the data object to be stored, including filename, size, type, and the file itself (as a Blob). + }; -//---------------------------load file--------------------------------// + const request = store.put(dataToStore, key); -export function loadFileFromDB(key, callback) { - // If the database is not initialized, return. - if (!db) return; - // Start a read-only transaction on the "files" object store. - const transaction = db.transaction(["files"], "readonly"); - const store = transaction.objectStore("files"); - // Get the value associated with the given key. - const request = store.get(key); - // Event handler for a successful retrieval. - request.onsuccess = function () { - // If a result is found, call the callback with the result. - if (request.result) { - callback(request.result); + request.onsuccess = () => console.log(`File '${file.name}' saved to DB with metadata.`); + + // Gracefully handle errors, especially quota limits + transaction.onerror = (event) => { + if (event.target.error.name === 'QuotaExceededError') { + alert("Could not cache file: Browser storage quota exceeded. The app will still work for this session."); } else { - console.log(`File '${key}' not found in DB.`); - callback(null); + // Handles potential errors during the save operation, such as QuotaExceededError. + console.error(`Error saving file '${key}':`, event.target.error); } - }; // Event handler for an error during loading. - request.onerror = (event) => { - console.error(`Error loading file '${key}':`, event.target.error); - callback(null); }; } + +/** + * Loads a file from IndexedDB only if its filename and size match expected values. + * @param {string} key The key of the file to load. + * @param {string} expectedFilename The filename we expect to find. + * @returns {Promise} A Promise that resolves with the Blob if it's fresh, otherwise null. + */ +// Loads a file from IndexedDB, performing checks for filename and size to ensure data integrity. +export function loadFreshFileFromDB(key, expectedFilename) { + return new Promise((resolve) => { + if (!db || !expectedFilename) { + resolve(null); + return; + } + + const transaction = db.transaction(["files"], "readonly"); + // Creates a read-only transaction. + const store = transaction.objectStore("files"); + const request = store.get(key); + + request.onsuccess = function () { + const cachedData = request.result; + if (!cachedData) { + console.log(`Cache miss for key '${key}': No data found.`); + // If no data is found for the key, resolve with null. + resolve(null); + return; + } + + // 1. Versioning Check: Do the filenames match? + if (cachedData.filename !== expectedFilename) { + // Checks if the cached filename matches the expected filename. + console.warn(`Cache miss for key '${key}': Stale data found (Filename mismatch).`); + resolve(null); + return; + } + + // 2. Integrity Check: Do the sizes match? + // Checks if the cached file size matches the stored size metadata. + if (cachedData.blob.size !== cachedData.size) { + console.error(`Cache miss for key '${key}': Corrupted data found (Size mismatch).`); + resolve(null); + return; + } + + // All checks passed! + // If all checks pass, resolve with the cached Blob. + console.log(`Cache hit for '${expectedFilename}'`); + resolve(cachedData.blob); + }; + + request.onerror = (event) => { + console.error(`Error loading file '${key}' from DB:`, event.target.error); + // Logs any errors during the load operation. + resolve(null); + }; + }); +} \ No newline at end of file diff --git a/steps/src/main.js b/steps/src/main.js index 629413b..c9a5e18 100644 --- a/steps/src/main.js +++ b/steps/src/main.js @@ -77,8 +77,10 @@ import { resetVisualization, updateDebugOverlay, } from "./dom.js"; + import { initializeTheme } from "./theme.js"; -import { initDB, saveFileToDB, loadFileFromDB } from "./db.js"; + +import { initDB, saveFileWithMetadata, loadFreshFileFromDB } from "./db.js"; // Sets up the video player with the given file URL. function setupVideoPlayer(fileURL) { @@ -88,6 +90,82 @@ function setupVideoPlayer(fileURL) { videoPlayer.playbackRate = parseFloat(speedSlider.value); } +// In src/main.js, add this new function +function loadVideoWithProgress(videoObject) { + if (!videoObject) return; + + showModal("Loading video...", false, true); + updateModalProgress(0); + + // Define event handlers so we can add and remove them correctly + const onProgress = () => { + if (videoPlayer.duration > 0) { + // Find the end of the buffered content + const bufferedEnd = + videoPlayer.buffered.length > 0 ? videoPlayer.buffered.end(0) : 0; + const percent = (bufferedEnd / videoPlayer.duration) * 100; + updateModalProgress(percent); + } + }; + + const onCanPlayThrough = () => { + updateModalProgress(100); + // Give the user a moment to see 100% before closing the modal + setTimeout(() => { + document.getElementById("modal-ok-btn").click(); + }, 400); + + // Clean up the event listeners we added + videoPlayer.removeEventListener("progress", onProgress); + videoPlayer.removeEventListener("canplaythrough", onCanPlayThrough); + }; + + const onError = () => { + showModal("Error: Could not load the video file."); + // Clean up event listeners on error + videoPlayer.removeEventListener("progress", onProgress); + videoPlayer.removeEventListener("canplaythrough", onCanPlayThrough); + videoPlayer.removeEventListener("error", onError); + }; + + // This one-time event is for re-syncing data once the video's metadata is ready + + +videoPlayer.addEventListener('loadedmetadata', () => { + // This is the perfect time to re-sync data if needed + if (appState.vizData) { + console.log("DEBUG: Video metadata loaded. Re-calculating timestamps."); + appState.vizData.radarFrames.forEach((frame) => { + frame.timestampMs = appState.radarStartTimeMs + frame.timestamp - appState.videoStartDate.getTime(); + }); + resetVisualization(); + } + + // --- START: New Speed Graph Logic --- + // If we have data and the video is ready, create/update the speed graph + if (appState.vizData && videoPlayer.duration > 0) { + speedGraphPlaceholder.classList.add("hidden"); + if (!appState.speedGraphInstance) { + appState.speedGraphInstance = new p5(speedGraphSketch); + } + appState.speedGraphInstance.setData(appState.vizData, videoPlayer.duration); + } + // --- END: New Speed Graph Logic --- + +}, { once: true }); // { once: true } makes sure this runs only once per load + + // { once: true } //makes sure this runs only once per load + + // Add the listeners for progress tracking + videoPlayer.addEventListener("progress", onProgress); + videoPlayer.addEventListener("canplaythrough", onCanPlayThrough); + videoPlayer.addEventListener("error", onError); + + // Create the object URL and set the video source to trigger loading + const fileURL = URL.createObjectURL(videoObject); + setupVideoPlayer(fileURL); +} + // Event listener for loading JSON file. loadJsonBtn.addEventListener("click", () => jsonFileInput.click()); loadVideoBtn.addEventListener("click", () => videoFileInput.click()); @@ -103,110 +181,104 @@ clearCacheBtn.addEventListener("click", async () => { // In src/main.js, REPLACE the jsonFileInput event listener with this: jsonFileInput.addEventListener("change", (event) => { - const file = event.target.files[0]; - if (!file) return; - - appState.jsonFilename = file.name; - localStorage.setItem("jsonFilename", appState.jsonFilename); - calculateAndSetOffset(); - saveFileToDB("json", file); // We still cache the raw file - - // 1. Show the modal with the progress bar - showModal("Parsing large JSON file...", false, true); - updateModalProgress(0); - - // 2. Create a new Worker from our script - const worker = new Worker('./src/parser.worker.js'); - - // 3. Set up listeners for messages FROM the worker - worker.onmessage = async (e) => { - const { type, data, message, percent } = e.data; + const file = event.target.files[0]; + if (!file) return; - if (type === 'progress') { - // Update the progress bar whenever the worker reports progress - updateModalProgress(percent); - } else if (type === 'complete') { - // Worker is done! Process the data it sent back. - updateModalProgress(100); - - const result = await parseVisualizationJson( - data, // Use the data object directly from the worker - appState.radarStartTimeMs, - appState.videoStartDate - ); - - if (result.error) { - showModal(result.error); - return; - } - - appState.vizData = result.data; - appState.globalMinSnr = result.minSnr; - appState.globalMaxSnr = result.maxSnr; - snrMinInput.value = appState.globalMinSnr.toFixed(1); - snrMaxInput.value = appState.globalMaxSnr.toFixed(1); - - resetVisualization(); - canvasPlaceholder.style.display = "none"; - featureToggles.classList.remove("hidden"); - - if (!appState.p5_instance) { - appState.p5_instance = new p5(radarSketch); - } - if (appState.vizData && videoPlayer.duration) { - if (!appState.speedGraphInstance) { - appState.speedGraphInstance = new p5(speedGraphSketch); - } - appState.speedGraphInstance.setData(appState.vizData, videoPlayer.duration); - } - - // Close the modal and terminate the worker - document.getElementById("modal-ok-btn").click(); - worker.terminate(); + appState.jsonFilename = file.name; + localStorage.setItem("jsonFilename", appState.jsonFilename); + calculateAndSetOffset(); + saveFileWithMetadata("json", file); // We still cache the raw file + + // 1. Show the modal with the progress bar + showModal("Parsing large JSON file...", false, true); + updateModalProgress(0); + + // 2. Create a new Worker from our script + const worker = new Worker("./src/parser.worker.js"); + + // 3. Set up listeners for messages FROM the worker + worker.onmessage = async (e) => { + const { type, data, message, percent } = e.data; + + if (type === "progress") { + // Update the progress bar whenever the worker reports progress + updateModalProgress(percent); + } else if (type === "complete") { + // Worker is done! Process the data it sent back. + updateModalProgress(100); + + const result = await parseVisualizationJson( + data, // Use the data object directly from the worker + appState.radarStartTimeMs, + appState.videoStartDate + ); - } else if (type === 'error') { - // The worker ran into an error - showModal(message); - worker.terminate(); + if (result.error) { + showModal(result.error); + return; + } + // --- START: New Cleanup Logic --- + // If p5.js instances already exist, remove them completely + if (appState.p5_instance) { + appState.p5_instance.remove(); + appState.p5_instance = null; + } + if (appState.speedGraphInstance) { + appState.speedGraphInstance.remove(); + appState.speedGraphInstance = null; + // Also reset the placeholder text + speedGraphPlaceholder.classList.remove("hidden"); + } + // --- END: New Cleanup Logic --- + appState.vizData = result.data; + appState.globalMinSnr = result.minSnr; + appState.globalMaxSnr = result.maxSnr; + snrMinInput.value = appState.globalMinSnr.toFixed(1); + snrMaxInput.value = appState.globalMaxSnr.toFixed(1); + + resetVisualization(); + canvasPlaceholder.style.display = "none"; + featureToggles.classList.remove("hidden"); + + if (!appState.p5_instance) { + appState.p5_instance = new p5(radarSketch); + } + if (appState.vizData && videoPlayer.duration) { + if (!appState.speedGraphInstance) { + appState.speedGraphInstance = new p5(speedGraphSketch); } - }; + appState.speedGraphInstance.setData( + appState.vizData, + videoPlayer.duration + ); + } - // 4. Send the file TO the worker to start the job - worker.postMessage({ file: file }); + // Close the modal and terminate the worker + document.getElementById("modal-ok-btn").click(); + worker.terminate(); + } else if (type === "error") { + // The worker ran into an error + showModal(message); + worker.terminate(); + } + }; + + // 4. Send the file TO the worker to start the job + worker.postMessage({ file: file }); }); // Event listener for video file input change. +// In src/main.js, REPLACE the videoFileInput event listener with this: videoFileInput.addEventListener("change", (event) => { const file = event.target.files[0]; if (!file) return; + appState.videoFilename = file.name; localStorage.setItem("videoFilename", appState.videoFilename); - saveFileToDB("video", file); + saveFileWithMetadata("video", file); calculateAndSetOffset(); - - if (appState.vizData) { - console.log("DEBUG: Video loaded after JSON. Re-calculating timestamps."); - appState.vizData.radarFrames.forEach((frame) => { - frame.timestampMs = - appState.radarStartTimeMs + - frame.timestamp - - appState.videoStartDate.getTime(); - }); - resetVisualization(); - } - - const fileURL = URL.createObjectURL(file); - setupVideoPlayer(fileURL); - - videoPlayer.onloadedmetadata = () => { - if (appState.speedGraphInstance) { - appState.speedGraphInstance.setData( - appState.vizData, - videoPlayer.duration - ); - } - }; + loadVideoWithProgress(file); }); // Event listener for offset input change. @@ -429,97 +501,99 @@ function calculateAndSetOffset() { // Application Initialization // In src/main.js, REPLACE the entire 'DOMContentLoaded' listener with this: +// In src/main.js, replace the existing DOMContentLoaded listener with this entire block: + +// In src/main.js, replace the existing DOMContentLoaded listener with this entire block: document.addEventListener("DOMContentLoaded", () => { initializeTheme(); console.log("DEBUG: DOMContentLoaded fired. Starting session load."); - initDB(() => { + initDB(async () => { // Make the callback async to use await console.log("DEBUG: Database initialized."); const savedOffset = localStorage.getItem("visualizerOffset"); if (savedOffset !== null) { offsetInput.value = savedOffset; } + + // Get the filenames we EXPECT to load from localStorage appState.videoFilename = localStorage.getItem("videoFilename"); appState.jsonFilename = localStorage.getItem("jsonFilename"); calculateAndSetOffset(); - const videoPromise = new Promise((resolve) => loadFileFromDB("video", resolve)); - const jsonPromise = new Promise((resolve) => loadFileFromDB("json", resolve)); - - Promise.all([videoPromise, jsonPromise]) - .then(([videoBlob, jsonBlob]) => { - console.log("DEBUG: All data fetched from IndexedDB."); - - // This function will be called with the fully parsed JSON data when ready. - const finalizeSetup = async (parsedJson) => { - if (parsedJson) { - const result = await parseVisualizationJson( - parsedJson, - appState.radarStartTimeMs, - appState.videoStartDate - ); - - if (!result.error) { - appState.vizData = result.data; - appState.globalMinSnr = result.minSnr; - appState.globalMaxSnr = result.maxSnr; - snrMinInput.value = result.minSnr.toFixed(1); - snrMaxInput.value = result.maxSnr.toFixed(1); - } else { - showModal(result.error); - } - } - - // Setup video player - if (videoBlob) { - const fileURL = URL.createObjectURL(videoBlob); - setupVideoPlayer(fileURL); - } - - // Final UI updates - if (appState.vizData) { - resetVisualization(); - canvasPlaceholder.style.display = "none"; - featureToggles.classList.remove("hidden"); - if (!appState.p5_instance) { - appState.p5_instance = new p5(radarSketch); - } - } - }; - - if (jsonBlob) { - // --- CACHED JSON FOUND: USE WORKER --- - showModal("Loading data from cache...", false, true); - updateModalProgress(0); - - const worker = new Worker('./src/parser.worker.js'); - - worker.onmessage = async (e) => { - const { type, data, message, percent } = e.data; - if (type === 'progress') { - updateModalProgress(percent); - } else if (type === 'complete') { - updateModalProgress(100); - await finalizeSetup(data); - document.getElementById("modal-ok-btn").click(); - worker.terminate(); - } else if (type === 'error') { - showModal(message); - worker.terminate(); - } - }; - - worker.postMessage({ file: jsonBlob }); + // Asynchronously load files, performing freshness and integrity checks + const videoBlob = await loadFreshFileFromDB("video", appState.videoFilename); + const jsonBlob = await loadFreshFileFromDB("json", appState.jsonFilename); + + console.log("DEBUG: Freshness checks complete. Proceeding with valid data."); + + // This function processes the parsed JSON and sets up the main visualization state + const finalizeSetup = async (parsedJson) => { + if (parsedJson) { + const result = await parseVisualizationJson( + parsedJson, + appState.radarStartTimeMs, + appState.videoStartDate + ); + if (!result.error) { + appState.vizData = result.data; + appState.globalMinSnr = result.minSnr; + appState.globalMaxSnr = result.maxSnr; + snrMinInput.value = result.minSnr.toFixed(1); + snrMaxInput.value = result.maxSnr.toFixed(1); } else { - // --- NO CACHED JSON --- - finalizeSetup(null); + showModal(result.error); } - }) - .catch((error) => { - console.error("DEBUG: Error during Promise.all data loading:", error); - }); + } + + // Final UI updates for the radar canvas + if (appState.vizData) { + resetVisualization(); + canvasPlaceholder.style.display = "none"; + featureToggles.classList.remove("hidden"); + if (!appState.p5_instance) { + appState.p5_instance = new p5(radarSketch); + } + } + }; + + // --- Main Loading Logic --- + if (jsonBlob) { + // CASE 1: Cached JSON exists. Parse it first with a progress bar. + showModal("Loading data from cache...", false, true); + updateModalProgress(0); + + const worker = new Worker('./src/parser.worker.js'); + + worker.onmessage = async (e) => { + const { type, data, message, percent } = e.data; + + if (type === 'progress') { + updateModalProgress(percent); + } else if (type === 'complete') { + updateModalProgress(100); + await finalizeSetup(data); // Process the parsed JSON + + // Hide the JSON loading modal before starting the video load + document.getElementById("modal-ok-btn").click(); + worker.terminate(); + + // Now that JSON is ready, load the video (which will show its own modal) + loadVideoWithProgress(videoBlob); + } else if (type === 'error') { + showModal(message); + worker.terminate(); + } + }; + + worker.postMessage({ file: jsonBlob }); + + } else { + // CASE 2: No cached JSON. Finalize setup with null data and just load the video if it exists. + await finalizeSetup(null); + loadVideoWithProgress(videoBlob); + } }); }); diff --git a/steps/tests/fileParsers.test.js b/steps/tests/fileParsers.test.js new file mode 100644 index 0000000..2cf9d0d --- /dev/null +++ b/steps/tests/fileParsers.test.js @@ -0,0 +1,63 @@ +// tests/fileParsers.test.js + +import { parseVisualizationJson } from '../src/fileParsers.js'; + +const resultsEl = document.getElementById('results'); + +// A simple function to run an async test and report the result +async function testAsync(description, testFunction) { + try { + await testFunction(); + console.log(`✅ PASS: ${description}`); + resultsEl.innerHTML += `

PASS: ${description}

`; + } catch (error) { + console.error(`❌ FAIL: ${description}`, error); + resultsEl.innerHTML += `

FAIL: ${description}

${error}

`; + } +} + + +// --- Test Cases for parseVisualizationJson --- + +const mockRawData = { + radarFrames: [ + { timestamp: 50, pointCloud: [{ snr: 10 }, { snr: 15 }] }, + { timestamp: 100, pointCloud: [{ snr: 5 }, { snr: 20 }] } + ] +}; + +const radarStartTimeMs = 1725440400000; // Sept 4, 2025 12:30:00 PM GMT +const videoStartDate = new Date(radarStartTimeMs - 1000); // Video starts 1 second earlier + +testAsync("fileParsers.js: should correctly calculate timestampMs for each frame", async () => { + const result = await parseVisualizationJson(mockRawData, radarStartTimeMs, videoStartDate); + const expectedTimestamp1 = radarStartTimeMs + 50 - videoStartDate.getTime(); // 1000 + 50 = 1050 + const expectedTimestamp2 = radarStartTimeMs + 100 - videoStartDate.getTime(); // 1000 + 100 = 1100 + + if (result.data.radarFrames[0].timestampMs !== expectedTimestamp1) { + throw new Error(`Expected first frame timestamp to be ${expectedTimestamp1} but got ${result.data.radarFrames[0].timestampMs}`); + } + if (result.data.radarFrames[1].timestampMs !== expectedTimestamp2) { + throw new Error(`Expected second frame timestamp to be ${expectedTimestamp2} but got ${result.data.radarFrames[1].timestampMs}`); + } +}); + +testAsync("fileParsers.js: should correctly calculate global min and max SNR", async () => { + const result = await parseVisualizationJson(mockRawData, radarStartTimeMs, videoStartDate); + + if (result.minSnr !== 5) { + throw new Error(`Expected minSnr to be 5 but got ${result.minSnr}`); + } + if (result.maxSnr !== 20) { + throw new Error(`Expected maxSnr to be 20 but got ${result.maxSnr}`); + } +}); + +testAsync("fileParsers.js: should return an error if radarFrames are missing", async () => { + const badData = { tracks: [] }; // Missing radarFrames + const result = await parseVisualizationJson(badData, radarStartTimeMs, videoStartDate); + + if (!result.error) { + throw new Error("Expected an error for missing radarFrames, but none was returned."); + } +}); \ No newline at end of file diff --git a/steps/tests/test-runner.html b/steps/tests/test-runner.html new file mode 100644 index 0000000..b56e993 --- /dev/null +++ b/steps/tests/test-runner.html @@ -0,0 +1,22 @@ + + + + + Visualizer Unit Tests + + + +

Visualizer Unit Tests

+

Check the browser's console for detailed results.

+
+ + + + + + \ No newline at end of file diff --git a/steps/tests/utils.test.js b/steps/tests/utils.test.js new file mode 100644 index 0000000..a84dada --- /dev/null +++ b/steps/tests/utils.test.js @@ -0,0 +1,94 @@ +// tests/utils.test.js + +import { + extractTimestampInfo, + parseTimestamp, + findRadarFrameIndexForTime +} from '../src/utils.js'; + +const resultsEl = document.getElementById('results'); + +// A simple function to run a test and report the result +function test(description, testFunction) { + try { + testFunction(); + console.log(`✅ PASS: ${description}`); + resultsEl.innerHTML += `

PASS: ${description}

`; + } catch (error) { + console.error(`❌ FAIL: ${description}`, error); + resultsEl.innerHTML += `

FAIL: ${description}

${error}

`; + } +} + +// --- Test Cases for Timestamp Functions --- + +test("utils.js: should extract timestamp info from a new JSON filename format", () => { + const info = extractTimestampInfo('fHist_04092025_123000.123.json'); + if (!info || info.format !== 'json' || info.timestampStr !== '04092025_123000.123') { + throw new Error(`Expected 'json' format and correct timestamp, but got ${JSON.stringify(info)}`); + } +}); + +test("utils.js: should extract timestamp info from a video filename", () => { + const info = extractTimestampInfo('WIN_20250904_12_30_00_Pro.mp4'); + if (!info || info.format !== 'video' || info.timestampStr !== '20250904_123000') { + throw new Error(`Expected 'video' format and correct timestamp, but got ${JSON.stringify(info)}`); + } +}); + +test("utils.js: should correctly parse a video timestamp string into a Date object", () => { + const timestampStr = '20250904_123000'; // 4th Sept 2025, 12:30:00 + const date = parseTimestamp(timestampStr, 'video'); + const expectedDate = new Date(Date.UTC(2025, 8, 4, 12, 30, 0)); // Month is 0-indexed (8 = September) + + if (date.getTime() !== expectedDate.getTime()) { + throw new Error(`Date mismatch. Expected ${expectedDate.toISOString()} but got ${date.toISOString()}`); + } +}); + + +// --- Test Cases for findRadarFrameIndexForTime --- + +const mockVizData = { + radarFrames: [ + { timestampMs: 100 }, // index 0 + { timestampMs: 200 }, // index 1 + { timestampMs: 300 }, // index 2 + { timestampMs: 400 }, // index 3 + ] +}; + +test("utils.js: should find the correct frame for a time that is between two frames", () => { + const index = findRadarFrameIndexForTime(250, mockVizData); // Should find the frame at 200ms + if (index !== 1) { + throw new Error(`Expected index 1 but got ${index}`); + } +}); + +test("utils.js: should find the correct frame for a time that exactly matches a frame", () => { + const index = findRadarFrameIndexForTime(300, mockVizData); + if (index !== 2) { + throw new Error(`Expected index 2 but got ${index}`); + } +}); + +test("utils.js: should return the last frame for a time after the end of the data", () => { + const index = findRadarFrameIndexForTime(500, mockVizData); + if (index !== 3) { + throw new Error(`Expected index 3 but got ${index}`); + } +}); + +test("utils.js: should return the first frame for a time before the start of the data", () => { + const index = findRadarFrameIndexForTime(50, mockVizData); + if (index !== 0) { + throw new Error(`Expected index 0 but got ${index}`); + } +}); + +test("utils.js: should return -1 if radarFrames array is empty", () => { + const index = findRadarFrameIndexForTime(100, { radarFrames: [] }); + if (index !== -1) { + throw new Error(`Expected index -1 for empty data but got ${index}`); + } +}); \ No newline at end of file