From 6a4f8184f2e5bce265b15189ce7436b91fc17985 Mon Sep 17 00:00:00 2001 From: Tomek Zebrowski Date: Tue, 21 Apr 2026 20:31:42 +0200 Subject: [PATCH 1/4] fix: resolve OBD signal merging --- src/dataprocessor.js | 98 +++++------------------------ src/signalregistry.js | 117 ++++++++++------------------------- tests/dataprocessor.test.js | 4 +- tests/signalregistry.test.js | 8 +-- 4 files changed, 51 insertions(+), 176 deletions(-) diff --git a/src/dataprocessor.js b/src/dataprocessor.js index e89f6e5..17c1325 100644 --- a/src/dataprocessor.js +++ b/src/dataprocessor.js @@ -6,32 +6,18 @@ import { projectManager } from './projectmanager.js'; import { dbManager } from './dbmanager.js'; import { signalRegistry } from './signalregistry.js'; -/** - * DataProcessor Module - * Handles telemetry data parsing, chronological sorting, and state synchronization. - */ class DataProcessor { SCHEMA_REGISTRY = { JSON: { signal: 's', timestamp: 't', value: 'v' }, - CSV: { - signal: 'SensorName', - timestamp: 'Time_ms', - value: 'Reading', - }, + CSV: { signal: 'SensorName', timestamp: 'Time_ms', value: 'Reading' }, }; - SCHEMA = { - timeKey: 'x', - valueKey: 'y', - }; + SCHEMA = { timeKey: 'x', valueKey: 'y' }; constructor() { this.handleLocalFile = this.handleLocalFile.bind(this); } - /** - * Initializes anomaly detection templates. - */ async loadConfiguration(providedTemplates = templates) { try { if (!providedTemplates) { @@ -43,9 +29,7 @@ class DataProcessor { console.error('Config Loader:', error); try { Config.ANOMALY_TEMPLATES = {}; - } catch (e) { - /* ignore */ - } + } catch (e) {} } } @@ -62,7 +46,6 @@ class DataProcessor { files.forEach(async (file) => { try { const fileText = await this.#readFileContent(file); - let rawData; if (file.name.includes('.csv')) { const parsedCSV = this.#parseCSV(fileText); @@ -72,10 +55,8 @@ class DataProcessor { rawData = this.#normalizeWideCSV(parsedCSV); } } else { - // Pass the raw JSON straight to process; it will detect columnar internally rawData = JSON.parse(fileText); } - await this.#process(rawData, file.name); } catch (err) { const msg = `Error parsing ${file.name}: ${err.message}`; @@ -94,7 +75,6 @@ class DataProcessor { const decompressedStream = file.stream().pipeThrough(ds); return await new Response(decompressedStream).text(); } - return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = (e) => resolve(e.target.result); @@ -103,8 +83,6 @@ class DataProcessor { }); } - // --- Data Transformation & State Sync --- - async process(data, fileName) { const result = await this.#process(data, fileName); this.#finalizeBatchLoad(); @@ -115,7 +93,6 @@ class DataProcessor { try { let telemetryData = data; - // Auto-detect and unpack the highly compressed columnar format if (this.#isColumnarJSON(telemetryData)) { telemetryData = this.#normalizeColumnarJSON(telemetryData); } @@ -138,11 +115,9 @@ class DataProcessor { } const schema = this.#detectSchema(telemetryPoints[0]); - const processedPoints = telemetryPoints.flatMap((item) => this.#applyMappingAndCleaning(item, schema) ); - const result = this.#transformRawData(processedPoints, fileName); result.metadata = fileMetadata; @@ -159,14 +134,10 @@ class DataProcessor { ); result.dbId = existingFile.id; } else { - const dbId = await dbManager.saveTelemetry(result); - result.dbId = dbId; + result.dbId = await dbManager.saveTelemetry(result); } - const isAlreadyInSession = AppState.files.some( - (f) => f.dbId === result.dbId - ); - if (!isAlreadyInSession) { + if (!AppState.files.some((f) => f.dbId === result.dbId)) { AppState.files.push(result); } @@ -176,7 +147,6 @@ class DataProcessor { size: result.size, metadata: result.metadata, }); - return result; } catch (error) { console.error('Error occured during file processing', error); @@ -195,43 +165,27 @@ class DataProcessor { #normalizeColumnarJSON(data) { const normalized = []; - - if (data.metadata) { - normalized.push({ metadata: data.metadata }); - } + if (data.metadata) normalized.push({ metadata: data.metadata }); const dictionary = data.signal_dictionary || {}; const series = data.series || {}; - - // Pre-compute canonical names from the dictionary to avoid lookups in the loop const mappedDictionary = {}; - for (const [id, rawLocalizedName] of Object.entries(dictionary)) { - const nameFromId = signalRegistry.getCanonicalByPid(id); + for (const [id, rawLocalizedName] of Object.entries(dictionary)) { mappedDictionary[id] = - nameFromId || - signalRegistry.getCanonicalKey(rawLocalizedName) || - rawLocalizedName; + signalRegistry.getCanonicalByPid(id) || rawLocalizedName || `PID ${id}`; } - // Iterate through the series for (const [signalId, vectors] of Object.entries(series)) { - const signalName = mappedDictionary[signalId] || signalId; - + const signalName = mappedDictionary[signalId]; const times = vectors.t || []; const values = vectors.v || []; - const length = Math.min(times.length, values.length); for (let i = 0; i < length; i++) { - normalized.push({ - s: signalName, - t: times[i], - v: values[i], - }); + normalized.push({ s: signalName, t: times[i], v: values[i] }); } } - return normalized; } @@ -240,7 +194,6 @@ class DataProcessor { const keys = Object.keys(rows[0]); const hasTimeColumn = keys.includes('Time'); const firstTimeValue = rows[0]['Time']; - return ( hasTimeColumn && typeof firstTimeValue === 'string' && @@ -251,7 +204,6 @@ class DataProcessor { #normalizeAlfaOBD(rows) { const normalized = []; if (!rows || rows.length === 0) return normalized; - const keys = Object.keys(rows[0]); const timeKey = 'Time'; const signalKeys = keys.filter((k) => k !== timeKey); @@ -259,14 +211,12 @@ class DataProcessor { rows.forEach((row) => { const rawTime = row[timeKey]; if (!rawTime) return; - const parts = rawTime.split(':'); if (parts.length !== 3) return; const hours = parseInt(parts[0], 10); const minutes = parseInt(parts[1], 10); const seconds = parseFloat(parts[2]); - if (isNaN(hours) || isNaN(minutes) || isNaN(seconds)) return; const timestampMs = (hours * 3600 + minutes * 60 + seconds) * 1000; @@ -282,7 +232,6 @@ class DataProcessor { } }); }); - return normalized; } @@ -301,24 +250,17 @@ class DataProcessor { if (isNaN(timestamp)) return []; let prefix = ''; - if (typeof baseSignal === 'string') { + if (typeof baseSignal === 'string') prefix = baseSignal.replace(/\n/g, ' ').trim(); - } if (typeof rawValue === 'object' && rawValue !== null) { const derivedPoints = []; - for (const [key, val] of Object.entries(rawValue)) { const numVal = Number(val); if (isNaN(numVal)) continue; - const formattedKey = key.charAt(0).toUpperCase() + key.slice(1); - const finalSignal = prefix - ? `${prefix}-${formattedKey}` - : formattedKey; - derivedPoints.push({ - signal: finalSignal, + signal: prefix ? `${prefix}-${formattedKey}` : formattedKey, timestamp: timestamp, value: numVal, }); @@ -345,9 +287,7 @@ class DataProcessor { #parseCSV(csvText) { const lines = csvText.split('\n').filter((line) => line.trim()); if (lines.length === 0) return []; - const headers = lines[0].split(',').map((h) => h.trim()); - return lines.slice(1).map((line) => { const values = line.split(','); return headers.reduce((obj, header, i) => { @@ -359,15 +299,12 @@ class DataProcessor { #normalizeWideCSV(rows) { if (!rows || rows.length === 0) return rows; - const keys = Object.keys(rows[0]); - if ( keys.includes('SensorName') && (keys.includes('Time_ms') || keys.includes('time')) - ) { + ) return rows; - } const timeKey = keys.find((k) => k.toLowerCase().includes('time')); if (!timeKey) return rows; @@ -380,7 +317,6 @@ class DataProcessor { if (isNaN(timeVal)) return; const timestampMs = timeKey.includes('(s)') ? timeVal * 1000 : timeVal; - signalKeys.forEach((sigKey) => { const val = row[sigKey]; if (val !== '' && val !== null && val !== undefined) { @@ -392,7 +328,6 @@ class DataProcessor { } }); }); - return normalized; } @@ -406,12 +341,7 @@ class DataProcessor { sorted.forEach((p) => { if (!signals[p.signal]) signals[p.signal] = []; - - signals[p.signal].push({ - [timeKey]: p.timestamp, - [valueKey]: p.value, - }); - + signals[p.signal].push({ [timeKey]: p.timestamp, [valueKey]: p.value }); if (p.timestamp < minT) minT = p.timestamp; if (p.timestamp > maxT) maxT = p.timestamp; }); diff --git a/src/signalregistry.js b/src/signalregistry.js index acfb540..3024704 100644 --- a/src/signalregistry.js +++ b/src/signalregistry.js @@ -3,11 +3,9 @@ import signalConfig from './signals.json'; class SignalRegistry { constructor() { this.mappings = {}; - this.metadata = {}; // Stores units, min, max, etc. - this.pidMap = {}; // Maps PIDs directly to canonical keys + this.metadata = {}; + this.pidMap = {}; this.defaultSignals = []; - - // Synchronous setup of local defaults and aliases this._initLocal(); } @@ -17,7 +15,6 @@ class SignalRegistry { if (entry.default) { this.defaultSignals.push(entry.name); } - // Initialize base metadata for local signals this.metadata[entry.name] = { units: '', min: null, @@ -27,32 +24,27 @@ class SignalRegistry { }); } - /** - * Fetches metadata from multiple ObdMetrics definitions and merges them. - */ - async init(urls = []) { + async init( + urls = [ + 'https://raw.githubusercontent.com/tzebrowski/ObdMetrics/v11.x/src/main/resources/giulia_2.0_gme.json', + 'https://raw.githubusercontent.com/tzebrowski/ObdMetrics/v11.x/src/main/resources/alfa.json', + ] + ) { try { - // Ensure we are working with an array const urlList = Array.isArray(urls) ? urls : [urls]; - - // Fetch all URLs in parallel const fetchPromises = urlList.map(async (url) => { try { const response = await fetch(url); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); - const data = await response.json(); this.#mergeMetadata(data); - - // Log just the filename for cleaner console output const fileName = url.substring(url.lastIndexOf('/') + 1); console.log(`SignalRegistry: Loaded metadata from ${fileName}`); } catch (err) { console.error(`SignalRegistry: Failed to load from ${url}`, err); } }); - await Promise.all(fetchPromises); console.log( `SignalRegistry: All remote metadata loaded successfully. Total PIDs mapped: ${Object.keys(this.pidMap).length}` @@ -65,39 +57,24 @@ class SignalRegistry { } } - /** - * Retrieve metadata for charting (units, min, max limits). - */ getSignalMetadata(canonicalKey) { return this.metadata[canonicalKey] || null; } - /** - * Retrieve the canonical name based strictly on the PID/ID. - */ getCanonicalByPid(pid) { if (!pid) return null; return this.pidMap[String(pid).toLowerCase()] || null; } - /** - * Returns the list of canonical signal names that should be shown by default. - */ getDefaultSignals() { return this.defaultSignals; } - /** - * Finds the actual signal name from a list of available signals - * that matches the given canonical key. - */ findSignal(canonicalKey, availableSignals) { if (!availableSignals || availableSignals.length === 0) return null; - if (availableSignals.includes(canonicalKey)) return canonicalKey; const aliases = this.mappings[canonicalKey] || []; - for (const alias of aliases) { const match = availableSignals.find( (s) => s.toLowerCase() === alias.toLowerCase() @@ -109,45 +86,28 @@ class SignalRegistry { try { const escapedAlias = alias.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const regex = new RegExp(`\\b${escapedAlias}\\b`, 'i'); - const match = availableSignals.find((s) => regex.test(s)); if (match) return match; } catch (e) { console.warn(`SignalRegistry: Invalid regex for alias "${alias}"`, e); } } - return null; } - /** - * Checks if a raw signal name maps to a default canonical signal. - */ isDefaultSignal(rawSignalName) { const canonical = this.getCanonicalKey(rawSignalName); return this.defaultSignals.includes(canonical); } - /** - * Reverse lookup: Finds the canonical key for a given raw signal name - */ getCanonicalKey(rawSignalName) { + if (!rawSignalName) return rawSignalName; + const lowerRaw = String(rawSignalName).toLowerCase(); + for (const [key, aliases] of Object.entries(this.mappings)) { - if (key === rawSignalName) return key; - - if ( - aliases.some((alias) => { - if (rawSignalName.toLowerCase() === alias.toLowerCase()) return true; - try { - const escaped = alias.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - return new RegExp(`\\b${escaped}\\b`, 'i').test(rawSignalName); - } catch { - return false; - } - }) - ) { + if (key.toLowerCase() === lowerRaw) return key; + if (aliases.some((alias) => String(alias).toLowerCase() === lowerRaw)) return key; - } } return rawSignalName; } @@ -156,67 +116,58 @@ class SignalRegistry { let loadedPids = 0; let metricsArray = []; - // ObdMetrics JSON files group PIDs into distinct categories (livedata, metadata, capabilities, etc.) - // We need to iterate over all these groups and flatten them into one big array. if (Array.isArray(data)) { metricsArray = data; } else if (data && typeof data === 'object') { Object.values(data).forEach((value) => { - if (Array.isArray(value)) { - // Merge every array we find (livedata, metadata, etc.) together - metricsArray = metricsArray.concat(value); - } + if (Array.isArray(value)) metricsArray = metricsArray.concat(value); }); } - if (metricsArray.length === 0) { - console.error( - 'SignalRegistry: Failed to parse remote metrics. No arrays found in JSON:', - data - ); - return; - } + if (metricsArray.length === 0) return; metricsArray.forEach((metric) => { if (!metric || typeof metric !== 'object') return; - const rawDesc = metric.description || ''; if (!rawDesc) return; - const cleanName = rawDesc.split('\n')[0].trim(); - const canonicalKey = this.getCanonicalKey(cleanName) || cleanName; + const groupName = rawDesc.split('\n')[0].trim(); + const fullName = rawDesc.replace(/\n/g, ' ').trim(); + const canonicalKey = this.getCanonicalKey(groupName) || groupName; - this.metadata[canonicalKey] = { - ...this.metadata[canonicalKey], + const metricMetadata = { units: metric.units || '', min: metric.min !== undefined ? parseFloat(metric.min) : null, max: metric.max !== undefined ? parseFloat(metric.max) : null, }; - let mappedSomething = false; + this.metadata[canonicalKey] = { + ...this.metadata[canonicalKey], + ...metricMetadata, + }; + this.metadata[fullName] = { + ...this.metadata[fullName], + ...metricMetadata, + }; - // ObdMetrics definitions use both an internal "id" (e.g., "7040") and an OBD "pid" (e.g., "1001"). - // We map BOTH so that the log file can match against either identifier perfectly. + let mappedSomething = false; [metric.id, metric.pid, metric.command].forEach((identifier) => { if (identifier) { const cleanId = String(identifier) .replace(/^(pid_|0x)/i, '') .toLowerCase(); - this.pidMap[cleanId] = canonicalKey; + this.pidMap[cleanId] = fullName; mappedSomething = true; } }); if (mappedSomething) loadedPids++; - if (!this.mappings[canonicalKey]) { - this.mappings[canonicalKey] = [cleanName]; - } - - const rawAlias = rawDesc.replace(/\n/g, ' ').trim(); - if (!this.mappings[canonicalKey].includes(rawAlias)) { - this.mappings[canonicalKey].push(rawAlias); - } + if (!this.mappings[canonicalKey]) + this.mappings[canonicalKey] = [groupName]; + if (!this.mappings[canonicalKey].includes(fullName)) + this.mappings[canonicalKey].push(fullName); + if (!this.mappings[fullName]) this.mappings[fullName] = [fullName]; }); console.log( diff --git a/tests/dataprocessor.test.js b/tests/dataprocessor.test.js index 12b1398..042902d 100644 --- a/tests/dataprocessor.test.js +++ b/tests/dataprocessor.test.js @@ -868,10 +868,10 @@ describe('DataProcessor: Columnar JSON Support', () => { expect(file.metadata['trip.duration']).toBe('3600'); // Available signals check (should map IDs to human-readable names) - expect(file.availableSignals).toContain('Boost'); + expect(file.availableSignals).toContain('Boost Pressure'); // Series data check (un-pivoted successfully) - const boostData = file.signals['Boost']; + const boostData = file.signals['Boost Pressure']; expect(boostData).toHaveLength(2); expect(boostData[0].x).toBe(1000); // timestamp expect(boostData[0].y).toBe(14.1); // value diff --git a/tests/signalregistry.test.js b/tests/signalregistry.test.js index a6f8b15..9d16a2a 100644 --- a/tests/signalregistry.test.js +++ b/tests/signalregistry.test.js @@ -74,9 +74,7 @@ describe('SignalRegistry', () => { test('returns canonical key via alias match (Word Boundary)', () => { // 'Gas Pedal Position' has alias 'TPS' // Should match "TPS Sensor" - expect(signalRegistry.getCanonicalKey('TPS Sensor')).toBe( - 'Gas Pedal Position' - ); + expect(signalRegistry.getCanonicalKey('TPS Sensor')).toBe('TPS Sensor'); }); test('does NOT return canonical key for partial word match', () => { @@ -86,10 +84,6 @@ describe('SignalRegistry', () => { expect(signalRegistry.getCanonicalKey('Calculated')).toBe('Calculated'); }); - test('returns canonical key via case-insensitive alias match', () => { - expect(signalRegistry.getCanonicalKey('Engine Torque Nm')).toBe('Torque'); - }); - test('returns the raw signal name if no mapping is found (Fallback)', () => { expect(signalRegistry.getCanonicalKey('Unknown Signal 123')).toBe( 'Unknown Signal 123' From 99f716e4f268a1c7335fd7f94b9894496718dc4d Mon Sep 17 00:00:00 2001 From: Tomek Zebrowski Date: Tue, 21 Apr 2026 21:58:36 +0200 Subject: [PATCH 2/4] feat: add definition caching Implement 7-day `localStorage` caching in `SignalRegistry.init()` to prevent fetching OBD dictionaries on every page load. --- src/signalregistry.js | 41 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/src/signalregistry.js b/src/signalregistry.js index 3024704..0b79b02 100644 --- a/src/signalregistry.js +++ b/src/signalregistry.js @@ -32,19 +32,58 @@ class SignalRegistry { ) { try { const urlList = Array.isArray(urls) ? urls : [urls]; + const CACHE_TTL = 7 * 24 * 60 * 60 * 1000; + const fetchPromises = urlList.map(async (url) => { + const cacheKey = `obd_dict_${url}`; + const cachedData = localStorage.getItem(cacheKey); + + if (cachedData) { + try { + const parsedCache = JSON.parse(cachedData); + if (Date.now() - parsedCache.timestamp < CACHE_TTL) { + this.#mergeMetadata(parsedCache.data); + const fileName = url.substring(url.lastIndexOf('/') + 1); + console.log( + `SignalRegistry: Loaded metadata from cache (${fileName})` + ); + return; + } + } catch (e) { + localStorage.removeItem(cacheKey); + } + } + try { const response = await fetch(url); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); const data = await response.json(); + + try { + localStorage.setItem( + cacheKey, + JSON.stringify({ + timestamp: Date.now(), + data: data, + }) + ); + } catch (cacheErr) { + console.warn( + 'SignalRegistry: LocalStorage cache full or unavailable.' + ); + } + this.#mergeMetadata(data); const fileName = url.substring(url.lastIndexOf('/') + 1); - console.log(`SignalRegistry: Loaded metadata from ${fileName}`); + console.log( + `SignalRegistry: Loaded metadata from network (${fileName})` + ); } catch (err) { console.error(`SignalRegistry: Failed to load from ${url}`, err); } }); + await Promise.all(fetchPromises); console.log( `SignalRegistry: All remote metadata loaded successfully. Total PIDs mapped: ${Object.keys(this.pidMap).length}` From 13aaa3cf648c791a597f1b259477bf41de9828df Mon Sep 17 00:00:00 2001 From: Tomek Zebrowski Date: Wed, 22 Apr 2026 09:37:57 +0200 Subject: [PATCH 3/4] feat: add tests for signal registry --- tests/signalregistry.test.js | 179 ++++++++++++++++++++++++++--------- 1 file changed, 132 insertions(+), 47 deletions(-) diff --git a/tests/signalregistry.test.js b/tests/signalregistry.test.js index 9d16a2a..be92295 100644 --- a/tests/signalregistry.test.js +++ b/tests/signalregistry.test.js @@ -1,7 +1,21 @@ -import { jest, describe, test, expect } from '@jest/globals'; +import { + jest, + describe, + test, + expect, + beforeEach, + afterEach, +} from '@jest/globals'; import { signalRegistry } from '../src/signalregistry.js'; describe('SignalRegistry', () => { + beforeEach(() => { + // Reset any local mappings that might carry over between tests + signalRegistry.pidMap = {}; + signalRegistry.metadata = {}; + signalRegistry.mappings = {}; + }); + describe('findSignal()', () => { test('returns null if availableSignals is empty or null', () => { expect(signalRegistry.findSignal('Engine Speed', [])).toBeNull(); @@ -10,83 +24,154 @@ describe('SignalRegistry', () => { test('returns the canonical key if it exists directly in availableSignals', () => { const signals = ['Voltage', 'Engine Speed', 'Temp']; - // Should find "Engine Speed" directly expect(signalRegistry.findSignal('Engine Speed', signals)).toBe( 'Engine Speed' ); }); - test('finds signal via exact alias match (Case Insensitive)', () => { - // Mapping: 'Engine Speed' includes 'RPM' - const signals = ['Voltage', 'rpm', 'Temp']; - expect(signalRegistry.findSignal('Engine Speed', signals)).toBe('rpm'); - }); - test('finds signal via partial match with word boundaries', () => { - // Mapping: 'Latitude' includes 'Lat' - // Should match "GPS Lat" because "Lat" is a distinct word + signalRegistry.mappings['Latitude'] = ['Lat']; const signals = ['Time', 'GPS Lat', 'Altitude']; expect(signalRegistry.findSignal('Latitude', signals)).toBe('GPS Lat'); }); test('ignores partial matches inside other words (Word Boundary Check)', () => { - // Mapping: 'Latitude' includes 'lat' - // Should NOT match "Calculated" even though it contains "lat" + signalRegistry.mappings['Latitude'] = ['lat']; const signals = ['Calculated Load', 'Plate Position']; expect(signalRegistry.findSignal('Latitude', signals)).toBeNull(); }); + }); - test('finds signal when alias is surrounded by symbols', () => { - // Mapping: 'Latitude' includes 'lat' - // Should match "GPS-Lat" or "(Lat)" - const signals = ['GPS-Lat', 'Other']; - expect(signalRegistry.findSignal('Latitude', signals)).toBe('GPS-Lat'); + describe('getCanonicalKey() [Strict Anti-Squashing]', () => { + test('returns the key itself if the input matches a canonical key', () => { + signalRegistry.mappings['Engine Speed'] = []; + expect(signalRegistry.getCanonicalKey('Engine Speed')).toBe( + 'Engine Speed' + ); }); - test('prioritizes exact alias matches over partial matches', () => { - const signals = ['Engine RPM', 'RPM']; - expect(signalRegistry.findSignal('Engine Speed', signals)).toBe('RPM'); + test('returns canonical key via EXACT alias match (Case Insensitive)', () => { + signalRegistry.mappings['Engine Speed'] = ['RPM']; + expect(signalRegistry.getCanonicalKey('rpm')).toBe('Engine Speed'); }); - test('returns first matching alias if multiple exist', () => { - const signals = ['Velocity', 'Speed']; - expect(signalRegistry.findSignal('Vehicle Speed', signals)).toBe('Speed'); - }); + test('STRICT MATCHING: does NOT squash distinct target names into generic ones', () => { + // If the registry knows "Boost", it should NOT capture "Boost Target" via partial match + signalRegistry.mappings['Boost'] = ['Boost Pressure']; - test('returns null if no matching signal is found', () => { - const signals = ['Voltage', 'Temp', 'Pressure']; - expect(signalRegistry.findSignal('Engine Speed', signals)).toBeNull(); + expect(signalRegistry.getCanonicalKey('Boost Target')).toBe( + 'Boost Target' + ); + expect(signalRegistry.getCanonicalKey('Boost Measured')).toBe( + 'Boost Measured' + ); }); - test('handles unknown canonical keys gracefully (returns null)', () => { - const signals = ['RPM', 'Speed']; - expect(signalRegistry.findSignal('NonExistentKey', signals)).toBeNull(); + test('returns the raw signal name if no exact mapping is found (Fallback)', () => { + expect(signalRegistry.getCanonicalKey('Unknown Signal 123')).toBe( + 'Unknown Signal 123' + ); }); }); - describe('getCanonicalKey()', () => { - test('returns the key itself if the input matches a canonical key', () => { - expect(signalRegistry.getCanonicalKey('Engine Speed')).toBe( - 'Engine Speed' - ); + describe('init() - Fetching, Parsing, and Caching', () => { + let mockStorage = {}; + let originalFetch; + + beforeEach(() => { + originalFetch = global.fetch; + global.fetch = jest.fn(); + + mockStorage = {}; + + // Properly mock localStorage for Jest/JSDOM using defineProperty + const localStorageMock = { + getItem: jest.fn((key) => mockStorage[key] || null), + setItem: jest.fn((key, val) => { + mockStorage[key] = val; + }), + removeItem: jest.fn((key) => { + delete mockStorage[key]; + }), + clear: jest.fn(() => { + mockStorage = {}; + }), + }; + + Object.defineProperty(window, 'localStorage', { + value: localStorageMock, + writable: true, + }); }); - test('returns canonical key via alias match (Word Boundary)', () => { - // 'Gas Pedal Position' has alias 'TPS' - // Should match "TPS Sensor" - expect(signalRegistry.getCanonicalKey('TPS Sensor')).toBe('TPS Sensor'); + afterEach(() => { + global.fetch = originalFetch; + jest.clearAllMocks(); }); - test('does NOT return canonical key for partial word match', () => { - // 'Latitude' alias 'lat' should NOT match "Calculated" - // If logic was loose, "Calculated" would map to "Latitude". - // Correct behavior: returns "Calculated" (raw) - expect(signalRegistry.getCanonicalKey('Calculated')).toBe('Calculated'); + test('fetches from network, parses multiline descriptions correctly, and saves to cache', async () => { + const mockNetworkData = [ + { id: '1001', description: 'Boost\nTarget', units: 'bar' }, + { id: '1002', description: 'Boost\nMeasured', units: 'bar' }, + ]; + + global.fetch.mockResolvedValue({ + ok: true, + json: async () => mockNetworkData, + }); + + await signalRegistry.init(['mock_url.json']); + + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(window.localStorage.setItem).toHaveBeenCalledWith( + 'obd_dict_mock_url.json', + expect.stringContaining('Boost\\nTarget') // JSON.stringify escapes the \n + ); + + // Verify that the newline was replaced by a space, keeping them distinct + expect(signalRegistry.getCanonicalByPid('1001')).toBe('Boost Target'); + expect(signalRegistry.getCanonicalByPid('1002')).toBe('Boost Measured'); + expect(signalRegistry.getSignalMetadata('Boost Target').units).toBe( + 'bar' + ); }); - test('returns the raw signal name if no mapping is found (Fallback)', () => { - expect(signalRegistry.getCanonicalKey('Unknown Signal 123')).toBe( - 'Unknown Signal 123' + test('loads from localStorage cache if valid and within TTL', async () => { + const validCache = { + timestamp: Date.now(), + data: [{ id: '2050', description: 'Cached Engine Speed' }], + }; + mockStorage['obd_dict_cached_url.json'] = JSON.stringify(validCache); + + await signalRegistry.init(['cached_url.json']); + + // Network should NOT be hit + expect(global.fetch).not.toHaveBeenCalled(); + + // Data should be populated from cache + expect(signalRegistry.getCanonicalByPid('2050')).toBe( + 'Cached Engine Speed' + ); + }); + + test('ignores cache and fetches network if cache is older than 7 days', async () => { + const expiredCache = { + timestamp: Date.now() - 8 * 24 * 60 * 60 * 1000, // 8 days old + data: [{ id: '3000', description: 'Old Data' }], + }; + mockStorage['obd_dict_expired_url.json'] = JSON.stringify(expiredCache); + + global.fetch.mockResolvedValue({ + ok: true, + json: async () => [{ id: '3000', description: 'Fresh Network Data' }], + }); + + await signalRegistry.init(['expired_url.json']); + + // Network MUST be hit because cache expired + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(signalRegistry.getCanonicalByPid('3000')).toBe( + 'Fresh Network Data' ); }); }); From 22a01bb768c5021f404a92cb3ea25a3ce187dd8c Mon Sep 17 00:00:00 2001 From: Tomek Zebrowski Date: Wed, 22 Apr 2026 13:55:09 +0200 Subject: [PATCH 4/4] refactoring: implementation cleanup --- src/signalregistry.js | 166 +++++++++++++++++++++++++----------------- 1 file changed, 99 insertions(+), 67 deletions(-) diff --git a/src/signalregistry.js b/src/signalregistry.js index 0b79b02..ea7e4d5 100644 --- a/src/signalregistry.js +++ b/src/signalregistry.js @@ -1,90 +1,32 @@ import signalConfig from './signals.json'; +const CACHE_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days + class SignalRegistry { constructor() { this.mappings = {}; this.metadata = {}; this.pidMap = {}; this.defaultSignals = []; - this._initLocal(); - } - - _initLocal() { - signalConfig.forEach((entry) => { - this.mappings[entry.name] = entry.aliases || []; - if (entry.default) { - this.defaultSignals.push(entry.name); - } - this.metadata[entry.name] = { - units: '', - min: null, - max: null, - pid: null, - }; - }); + this.#initLocal(); } + /** + * Orchestrates the fetching and merging of all remote dictionaries. + */ async init( urls = [ + 'https://raw.githubusercontent.com/tzebrowski/ObdMetrics/v11.x/src/main/resources/mode01.json', 'https://raw.githubusercontent.com/tzebrowski/ObdMetrics/v11.x/src/main/resources/giulia_2.0_gme.json', 'https://raw.githubusercontent.com/tzebrowski/ObdMetrics/v11.x/src/main/resources/alfa.json', + 'https://raw.githubusercontent.com/tzebrowski/ObdMetrics/v11.x/src/main/resources/rfhub_module.json', ] ) { try { const urlList = Array.isArray(urls) ? urls : [urls]; - const CACHE_TTL = 7 * 24 * 60 * 60 * 1000; - - const fetchPromises = urlList.map(async (url) => { - const cacheKey = `obd_dict_${url}`; - const cachedData = localStorage.getItem(cacheKey); - - if (cachedData) { - try { - const parsedCache = JSON.parse(cachedData); - if (Date.now() - parsedCache.timestamp < CACHE_TTL) { - this.#mergeMetadata(parsedCache.data); - const fileName = url.substring(url.lastIndexOf('/') + 1); - console.log( - `SignalRegistry: Loaded metadata from cache (${fileName})` - ); - return; - } - } catch (e) { - localStorage.removeItem(cacheKey); - } - } - try { - const response = await fetch(url); - if (!response.ok) - throw new Error(`HTTP error! status: ${response.status}`); - const data = await response.json(); - - try { - localStorage.setItem( - cacheKey, - JSON.stringify({ - timestamp: Date.now(), - data: data, - }) - ); - } catch (cacheErr) { - console.warn( - 'SignalRegistry: LocalStorage cache full or unavailable.' - ); - } - - this.#mergeMetadata(data); - const fileName = url.substring(url.lastIndexOf('/') + 1); - console.log( - `SignalRegistry: Loaded metadata from network (${fileName})` - ); - } catch (err) { - console.error(`SignalRegistry: Failed to load from ${url}`, err); - } - }); + await Promise.all(urlList.map((url) => this.#loadDictionary(url))); - await Promise.all(fetchPromises); console.log( `SignalRegistry: All remote metadata loaded successfully. Total PIDs mapped: ${Object.keys(this.pidMap).length}` ); @@ -96,6 +38,96 @@ class SignalRegistry { } } + #initLocal() { + signalConfig.forEach((entry) => { + this.mappings[entry.name] = entry.aliases || []; + if (entry.default) { + this.defaultSignals.push(entry.name); + } + this.metadata[entry.name] = { + units: '', + min: null, + max: null, + pid: null, + }; + }); + } + + /** + * Coordinates loading a single dictionary (Cache vs Network). + */ + async #loadDictionary(url) { + const fileName = url.substring(url.lastIndexOf('/') + 1); + let data = this.#getFromCache(url); + + if (data) { + console.log(`SignalRegistry: Loaded metadata from cache (${fileName})`); + } else { + data = await this.#fetchAndCache(url); + if (data) { + console.log( + `SignalRegistry: Loaded metadata from network (${fileName})` + ); + } + } + + if (data) { + this.#mergeMetadata(data); + } + } + + /** + * Handles local storage retrieval and TTL validation. + */ + #getFromCache(url) { + const cacheKey = `obd_dict_${url}`; + const cachedData = localStorage.getItem(cacheKey); + + if (!cachedData) return null; + + try { + const parsedCache = JSON.parse(cachedData); + if (Date.now() - parsedCache.timestamp < CACHE_TTL) { + return parsedCache.data; + } + // Cache expired + localStorage.removeItem(cacheKey); + } catch (e) { + // Cache corrupted + localStorage.removeItem(cacheKey); + } + + return null; + } + + /** + * Handles network fetching and local storage writing. + */ + async #fetchAndCache(url) { + try { + const response = await fetch(url); + if (!response.ok) + throw new Error(`HTTP error! status: ${response.status}`); + + const data = await response.json(); + const cacheKey = `obd_dict_${url}`; + + try { + localStorage.setItem( + cacheKey, + JSON.stringify({ timestamp: Date.now(), data }) + ); + } catch (cacheErr) { + console.warn('SignalRegistry: LocalStorage cache full or unavailable.'); + } + + return data; + } catch (err) { + console.error(`SignalRegistry: Failed to load from ${url}`, err); + return null; + } + } + getSignalMetadata(canonicalKey) { return this.metadata[canonicalKey] || null; }