From 0df1c523bdc6608d6bf083e6c24262baac57ef8f Mon Sep 17 00:00:00 2001 From: Christian Mueller Date: Mon, 6 Apr 2026 12:47:41 +0200 Subject: [PATCH] feat(middleware): add datapoint transform engine with round, scale, and map support - src/modules/datapoints/transforms.ts: applyTransform() supporting map (priority), scale, and round - tests/transforms.test.ts: 17 tests covering no-transform passthrough, round precision, scale multiply, map lookup, map priority over numeric transforms, combined transforms, and edge cases Co-Authored-By: Claude Sonnet 4.6 --- .../src/modules/datapoints/transforms.ts | 45 ++++++++++ display/middleware/tests/transforms.test.ts | 84 +++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 display/middleware/src/modules/datapoints/transforms.ts create mode 100644 display/middleware/tests/transforms.test.ts diff --git a/display/middleware/src/modules/datapoints/transforms.ts b/display/middleware/src/modules/datapoints/transforms.ts new file mode 100644 index 0000000..f6ea2ed --- /dev/null +++ b/display/middleware/src/modules/datapoints/transforms.ts @@ -0,0 +1,45 @@ +export interface TransformConfig { + round?: number; + scale?: number; + map?: Record; +} + +/** + * Apply a transformation pipeline to a raw string value. + * + * Processing order: + * 1. If `map` is defined and the value exists as a key, return the mapped value immediately. + * 2. Parse the value as a float. If it's not numeric, return as-is. + * 3. Apply `scale` (multiply). + * 4. Apply `round` (toFixed, then remove trailing zeros). + */ +export function applyTransform(value: string, config: TransformConfig | null): string { + if (!config || Object.keys(config).length === 0) { + return value; + } + + // Map takes priority — if key matches, return immediately without numeric transforms + if (config.map && Object.prototype.hasOwnProperty.call(config.map, value)) { + return config.map[value]; + } + + // Only apply numeric transforms if value is actually a number + const num = parseFloat(value); + if (isNaN(num)) { + return value; + } + + let result = num; + + // Apply scale + if (typeof config.scale === 'number') { + result = result * config.scale; + } + + // Apply round + if (typeof config.round === 'number') { + result = parseFloat(result.toFixed(config.round)); + } + + return String(result); +} diff --git a/display/middleware/tests/transforms.test.ts b/display/middleware/tests/transforms.test.ts new file mode 100644 index 0000000..2f57a8b --- /dev/null +++ b/display/middleware/tests/transforms.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'vitest'; +import { applyTransform } from '../src/modules/datapoints/transforms'; + +describe('applyTransform', () => { + describe('no transform', () => { + it('should return value unchanged when config is null', () => { + expect(applyTransform('42', null)).toBe('42'); + }); + + it('should return value unchanged when config is empty object', () => { + expect(applyTransform('hello', {})).toBe('hello'); + }); + + it('should return non-numeric string unchanged with no transform', () => { + expect(applyTransform('running', null)).toBe('running'); + }); + }); + + describe('round', () => { + it('should round to specified decimal places', () => { + expect(applyTransform('3.14159', { round: 2 })).toBe('3.14'); + }); + + it('should round to 0 decimal places', () => { + expect(applyTransform('3.7', { round: 0 })).toBe('4'); + }); + + it('should round to integer by default when round is 0', () => { + expect(applyTransform('2.5', { round: 0 })).toBe('3'); + }); + + it('should return non-numeric string unchanged when rounding', () => { + expect(applyTransform('N/A', { round: 2 })).toBe('N/A'); + }); + }); + + describe('scale', () => { + it('should multiply value by scale factor', () => { + expect(applyTransform('100', { scale: 0.1 })).toBe('10'); + }); + + it('should apply scale and round together', () => { + expect(applyTransform('100', { scale: 0.333, round: 1 })).toBe('33.3'); + }); + + it('should return non-numeric string unchanged when scaling', () => { + expect(applyTransform('unknown', { scale: 2 })).toBe('unknown'); + }); + }); + + describe('map', () => { + it('should map matching value to its mapped result', () => { + expect(applyTransform('1', { map: { '1': 'Online', '0': 'Offline' } })).toBe('Online'); + }); + + it('should map another matching value', () => { + expect(applyTransform('0', { map: { '1': 'Online', '0': 'Offline' } })).toBe('Offline'); + }); + + it('should return original value when no map key matches', () => { + expect(applyTransform('2', { map: { '1': 'Online', '0': 'Offline' } })).toBe('2'); + }); + + it('map should take priority over scale and round', () => { + expect( + applyTransform('1', { map: { '1': 'yes' }, scale: 10, round: 2 }) + ).toBe('yes'); + }); + }); + + describe('combined transforms', () => { + it('should apply scale then round', () => { + expect(applyTransform('1000', { scale: 0.001, round: 2 })).toBe('1'); + }); + + it('should handle negative scale', () => { + expect(applyTransform('10', { scale: -1 })).toBe('-10'); + }); + + it('should handle large round precision', () => { + expect(applyTransform('1.23456789', { round: 5 })).toBe('1.23457'); + }); + }); +});