Browse Source

feat: Implement robust caching, video progress, and major bug fixes.

This commit introduces a suite of major improvements focused on application robustness, user experience, and bug fixes. The changes overhaul the caching system, enhance the file loading experience, and resolve critical state management issues.

###  New Features & Enhancements

1.  **Robust Caching System**:
    - The IndexedDB caching logic now stores file metadata (filename, size) alongside the file blob.
    - Implemented a **versioning check** by comparing filenames to prevent loading stale, outdated cache.
    - Added an **integrity check** by comparing file sizes to detect and discard corrupted or incomplete cached data.
    - Implemented graceful error handling for browser `QuotaExceededError`.

2.  **Progress Bar for All Loading Operations**:
    - The smooth, worker-based progress bar now appears when loading JSON data from the **IndexedDB cache**, providing a consistent experience with fresh file loads.
    - A new progress bar has been implemented for **video file loading**. It tracks the browser's buffering progress and appears for both fresh file selections and cached reloads.

3.  **UI Polish**:
    - A **favicon** has been added to the application tab for a more professional look.

### 🐛 Bug Fixes

1.  **Corrected Worker Parsing Logic**:
    - Fixed a critical bug in the JSON parsing web worker (`parser.worker.js`) where its logic failed to handle nested objects (like `pointCloud` arrays). The worker now uses a robust algorithm to correctly build the entire JSON tree, ensuring data is always parsed accurately.

2.  **Fixed JSON Reloading**:
    - Resolved an issue where loading a new JSON file over an existing one would fail. The application now properly removes old p5.js visualization instances before creating new ones, ensuring a clean state for the new data.

3.  **Fixed Speed Graph on Cached Load**:
    - Corrected a bug where the speed graph would not appear when the application started up from a cached session. The initialization logic now correctly creates and updates the speed graph after the cached video's metadata is loaded.
refactor/modularize
RUSHIL AMBARISH KADU 9 months ago
parent
commit
18922ec559
  1. BIN
      steps/favicon.png
  2. 1
      steps/index.html
  3. 140
      steps/src/db.js
  4. 404
      steps/src/main.js
  5. 63
      steps/tests/fileParsers.test.js
  6. 22
      steps/tests/test-runner.html
  7. 94
      steps/tests/utils.test.js

BIN
steps/favicon.png

After

Width: 32  |  Height: 32  |  Size: 1.4 KiB

1
steps/index.html

@ -6,6 +6,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Radar and Video Visualizer - Timestamp Synchronized</title>
<link rel="icon" type="image/png" sizes="32x32" href="favicon.png" />
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/oboe@2.1.5/dist/oboe-browser.min.js"></script>

140
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<Blob|null>} 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);
};
});
}

404
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);
}
});
});

63
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 += `<p class="pass"><b>PASS:</b> ${description}</p>`;
} catch (error) {
console.error(`❌ FAIL: ${description}`, error);
resultsEl.innerHTML += `<p class="fail"><b>FAIL:</b> ${description}<br><pre>${error}</pre></p>`;
}
}
// --- 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.");
}
});

22
steps/tests/test-runner.html

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Visualizer Unit Tests</title>
<style>
body { font-family: sans-serif; padding: 20px; }
.pass { color: green; }
.fail { color: red; }
pre { background-color: #f4f4f4; padding: 10px; border-radius: 5px; }
</style>
</head>
<body>
<h1>Visualizer Unit Tests</h1>
<p>Check the browser's console for detailed results.</p>
<div id="results"></div>
<script type="module" src="utils.test.js"></script>
<script type="module" src="fileParsers.test.js"></script> </body>
</body>
</html>

94
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 += `<p class="pass"><b>PASS:</b> ${description}</p>`;
} catch (error) {
console.error(`❌ FAIL: ${description}`, error);
resultsEl.innerHTML += `<p class="fail"><b>FAIL:</b> ${description}<br><pre>${error}</pre></p>`;
}
}
// --- 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}`);
}
});
Loading…
Cancel
Save