diff --git a/src/components/dashboard/ResourceChart.tsx b/src/components/dashboard/ResourceChart.tsx new file mode 100644 index 0000000..baa2c14 --- /dev/null +++ b/src/components/dashboard/ResourceChart.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { useMemo } from "react"; +import { toDecimalNumber, type Decimal } from "@/utils/decimal"; + +/** + * Time-series chart for fixed-point resource data. Charting needs plain + * `number`s, so each {@link Decimal} is converted exactly once at render time + * via {@link toDecimalNumber} (which warns on precision loss). The exact Decimal + * is preserved for the headline total, which is formatted with `toFixed`. + */ + +export interface ResourceChartPoint { + /** Unix milliseconds. */ + timestamp: number; + value: Decimal; +} + +export interface ResourceChartProps { + points: ResourceChartPoint[]; + /** Display unit (e.g. "kWh"). */ + unit?: string; + height?: number; + /** Stroke color. */ + color?: string; + className?: string; +} + +const PADDING = 6; + +export function ResourceChart({ + points, + unit = "", + height = 160, + color = "#22c55e", + className, +}: ResourceChartProps) { + // Convert exactly once; toDecimalNumber warns if a value can't be represented. + const numeric = useMemo( + () => points.map((p) => ({ t: p.timestamp, y: toDecimalNumber(p.value) })), + [points] + ); + + const { path, minY, maxY } = useMemo(() => { + if (numeric.length === 0) { + return { path: "", minY: 0, maxY: 0 }; + } + let lo = Infinity; + let hi = -Infinity; + for (const p of numeric) { + if (p.y < lo) lo = p.y; + if (p.y > hi) hi = p.y; + } + if (lo === hi) { + hi = lo + 1; + } + const w = 100; + const h = height - PADDING * 2; + const n = numeric.length; + const d = numeric + .map((p, i) => { + const x = n === 1 ? w / 2 : (i / (n - 1)) * w; + const y = PADDING + h - ((p.y - lo) / (hi - lo)) * h; + return `${i === 0 ? "M" : "L"}${x.toFixed(2)},${y.toFixed(2)}`; + }) + .join(" "); + return { path: d, minY: lo, maxY: hi }; + }, [numeric, height]); + + // The headline value stays exact (formatted from the Decimal, not the float). + const latest = points.length ? points[points.length - 1].value.toFixed() : null; + + if (points.length === 0) { + return ( +
= DecimalValue & { + readonly [PRECISION_BRAND]?: P; +}; + +class DecimalValue { + constructor( + /** Exact underlying value (not pre-rounded to `precision`). */ + readonly bn: BigNumber, + /** Declared decimal scale used for serialization / display. */ + readonly precision: number + ) {} + + /** Round to the declared precision and format as a plain decimal string. */ + toFixed(): string { + return this.bn.toFixed(this.precision, BigNumber.ROUND_HALF_UP); + } + + toString(): string { + return this.toFixed(); + } + + /** Lossy conversion to a JS number (for charting). Prefer {@link toFixed}. */ + toNumber(): number { + return this.bn.toNumber(); + } + + /** Lossless serialization for the store / Redux DevTools. */ + toRedux(): SerializedDecimal { + return { + value: this.toFixed(), + precision: this.precision, + scale: 10 ** this.precision, + }; + } + + isSafe(): boolean { + return this.bn.abs().lte(MAX_SAFE); + } +} + +function assertPrecision(precision: number): void { + if (!SUPPORTED_PRECISIONS.includes(precision as Precision)) { + throw new PrecisionError( + `Unsupported precision ${precision}; expected one of ${SUPPORTED_PRECISIONS.join( + ", " + )}` + ); + } +} + +function guard(bn: BigNumber, operation: string, precision: number): DecimalValue { + if (bn.isNaN()) { + throw new PrecisionError(`Result of ${operation} is not a number`); + } + if (bn.abs().gt(MAX_SAFE)) { + throw new SafetyRangeError(bn.toFixed(), operation); + } + return new DecimalValue(bn, precision); +} + +/** True when `x` is a {@link Decimal}. */ +export function isDecimal(x: unknown): x is Decimal { + return x instanceof DecimalValue; +} + +/** + * Construct a {@link Decimal} from a string or number at the given precision. + * The input is quantized (ROUND_HALF_UP) to `precision`, so the result exactly + * represents a fixed-point value at that scale. + */ +export function decimal
( + value: string | number | BigNumber, + precision: P +): Decimal
{ + assertPrecision(precision); + let parsed: BigNumber; + try { + parsed = new BN(value); + } catch { + throw new PrecisionError(`Invalid numeric input: ${String(value)}`); + } + if (parsed.isNaN()) { + throw new PrecisionError(`Invalid numeric input: ${String(value)}`); + } + const quantized = parsed.decimalPlaces(precision, BigNumber.ROUND_HALF_UP); + return guard(quantized, "decimal()", precision) as Decimal
; +} + +/** Rehydrate a {@link Decimal} from its serialized form. */ +export function fromRedux(s: SerializedDecimal): Decimal { + return decimal(s.value, s.precision as Precision); +} + +/** Highest precision among operands (the scale add/sub/min/max upcast to). */ +function maxPrecision(values: Decimal[]): number { + return values.reduce((m, d) => Math.max(m, d.precision), 0); +} + +// --- Arithmetic ------------------------------------------------------------- + +/** Sum two decimals, upcasting to the higher precision. */ +export function add(a: Decimal, b: Decimal): Decimal { + return guard(a.bn.plus(b.bn), "add", maxPrecision([a, b])); +} + +/** Difference, upcasting to the higher precision. */ +export function sub(a: Decimal, b: Decimal): Decimal { + return guard(a.bn.minus(b.bn), "sub", maxPrecision([a, b])); +} + +/** Product. Result precision is the sum of scales, capped at 9. */ +export function mul(a: Decimal, b: Decimal): Decimal { + const precision = Math.min(a.precision + b.precision, 9); + return guard(a.bn.times(b.bn), "mul", precision); +} + +/** Quotient (28 significant digits). Result precision upcasts to the higher. */ +export function div(a: Decimal, b: Decimal): Decimal { + if (b.bn.isZero()) { + throw new PrecisionError("Division by zero"); + } + return guard(a.bn.div(b.bn), "div", maxPrecision([a, b])); +} + +/** Smaller of two decimals (keeps that operand's precision). */ +export function min(a: Decimal, b: Decimal): Decimal { + return a.bn.lte(b.bn) ? a : b; +} + +/** Larger of two decimals (keeps that operand's precision). */ +export function max(a: Decimal, b: Decimal): Decimal { + return a.bn.gte(b.bn) ? a : b; +} + +/** + * Exact sum of a list, upcasting to the highest precision present. An empty + * list returns zero at the requested `precision` (default 0). + */ +export function sum(values: Decimal[], precision: Precision = 0): Decimal { + if (values.length === 0) return decimal(0, precision); + const scale = Math.max(maxPrecision(values), precision); + let acc = new BN(0); + for (const d of values) { + acc = acc.plus(d.bn); + if (acc.abs().gt(MAX_SAFE)) { + throw new SafetyRangeError(acc.toFixed(), "sum"); + } + } + return new DecimalValue(acc, scale) as Decimal; +} + +/** Arithmetic mean, computed exactly then carried at the highest precision. */ +export function average(values: Decimal[]): Decimal { + if (values.length === 0) { + throw new PrecisionError("Cannot average an empty list"); + } + const total = sum(values); + return guard( + total.bn.div(values.length), + "average", + maxPrecision(values) + ); +} + +/** Negate. */ +export function neg(a: Decimal): Decimal { + return new DecimalValue(a.bn.negated(), a.precision) as Decimal; +} + +/** Absolute value. */ +export function abs(a: Decimal): Decimal { + return new DecimalValue(a.bn.abs(), a.precision) as Decimal; +} + +// --- Comparisons ------------------------------------------------------------ + +export function eq(a: Decimal, b: Decimal): boolean { + return a.bn.eq(b.bn); +} +export function gt(a: Decimal, b: Decimal): boolean { + return a.bn.gt(b.bn); +} +export function gte(a: Decimal, b: Decimal): boolean { + return a.bn.gte(b.bn); +} +export function lt(a: Decimal, b: Decimal): boolean { + return a.bn.lt(b.bn); +} +export function lte(a: Decimal, b: Decimal): boolean { + return a.bn.lte(b.bn); +} + +/** + * Convert to a JS number for charting libraries, warning when the conversion + * loses more than one ULP of precision relative to the exact value. + */ +export function toDecimalNumber(d: Decimal): number { + const n = d.bn.toNumber(); + const roundtrip = new BN(n); + const lost = d.bn.minus(roundtrip).abs(); + if (lost.gt(Number.EPSILON)) { + console.warn( + `[decimal] precision loss converting ${d.toFixed()} to number (Δ=${lost.toFixed()})` + ); + } + return n; +} diff --git a/tests/unit/aggregation.test.ts b/tests/unit/aggregation.test.ts new file mode 100644 index 0000000..e6d32e7 --- /dev/null +++ b/tests/unit/aggregation.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect } from "vitest"; +import { decimal, eq } from "@/utils/decimal"; +import { + sumReadings, + averageReadings, + minReading, + maxReading, + cumulativeTotals, + aggregateReadings, + aggregateByWindow, + applyTariff, +} from "@/utils/aggregation"; +import { + RESOURCE_PRECISION, + type MeterReading, + type ResourceKind, + type TariffRate, +} from "@/types/meter"; + +function reading( + resource: ResourceKind, + value: string, + timestamp = 0 +): MeterReading { + return { + meterId: "m1", + resource, + timestamp, + value: decimal(value, RESOURCE_PRECISION[resource]) as MeterReading["value"], + }; +} + +describe("sumReadings / averageReadings", () => { + it("sums electricity readings exactly", () => { + const readings = [ + reading("electricity", "1.234"), + reading("electricity", "2.001"), + reading("electricity", "0.765"), + ]; + expect(sumReadings(readings, 3).toFixed()).toBe("4.000"); + }); + + it("averages exactly", () => { + const readings = [reading("gas", "1.0000"), reading("gas", "2.0000")]; + expect(averageReadings(readings).toFixed()).toBe("1.5000"); + }); + + it("empty sum is zero at the requested precision", () => { + expect(sumReadings([], 2).toFixed()).toBe("0.00"); + }); +}); + +describe("minReading / maxReading", () => { + it("returns extremes", () => { + const readings = [ + reading("water", "10"), + reading("water", "3"), + reading("water", "7"), + ]; + expect(minReading(readings)!.toFixed()).toBe("3"); + expect(maxReading(readings)!.toFixed()).toBe("10"); + }); + + it("returns null for an empty list", () => { + expect(minReading([])).toBeNull(); + expect(maxReading([])).toBeNull(); + }); +}); + +describe("cumulativeTotals", () => { + it("produces running sums without drift", () => { + const values = Array.from({ length: 5 }, () => decimal("0.001", 3)); + const totals = cumulativeTotals(values); + expect(totals.map((d) => d.toFixed())).toEqual([ + "0.001", + "0.002", + "0.003", + "0.004", + "0.005", + ]); + }); +}); + +describe("aggregateReadings", () => { + it("returns total, count and unit", () => { + const c = aggregateReadings("electricity", [ + reading("electricity", "1.500"), + reading("electricity", "2.500"), + ]); + expect(c.resource).toBe("electricity"); + expect(c.count).toBe(2); + expect(c.unit).toBe("kWh"); + expect(c.total.toFixed()).toBe("4.000"); + }); +}); + +describe("aggregateByWindow", () => { + it("buckets readings by fixed time windows, sorted ascending", () => { + const hour = 3_600_000; + const readings = [ + reading("water", "5", 0), + reading("water", "3", 1_000), + reading("water", "8", hour + 500), + ]; + const windows = aggregateByWindow("water", readings, hour); + expect(windows).toHaveLength(2); + expect(windows[0].windowStart).toBe(0); + expect(windows[0].consumption.total.toFixed()).toBe("8"); + expect(windows[1].windowStart).toBe(hour); + expect(windows[1].consumption.total.toFixed()).toBe("8"); + }); +}); + +describe("applyTariff", () => { + it("computes cost = total × rate at currency precision", () => { + const total = decimal("100.000", 3); // kWh + const rate: TariffRate = { + resource: "electricity", + ratePerUnit: decimal("0.15", 2), + currency: "USD", + }; + const cost = applyTariff(total, rate); + expect(cost.precision).toBe(2); + expect(cost.toFixed()).toBe("15.00"); + }); + + it("rounds the cost half-up to 2 decimals", () => { + const total = decimal("3.333", 3); + const rate: TariffRate = { + resource: "electricity", + ratePerUnit: decimal("0.10", 2), + currency: "USD", + }; + // 3.333 × 0.10 = 0.3333 → 0.33 + expect(eq(applyTariff(total, rate), decimal("0.33", 2))).toBe(true); + }); +}); diff --git a/tests/unit/decimal.test.ts b/tests/unit/decimal.test.ts new file mode 100644 index 0000000..4fc75a4 --- /dev/null +++ b/tests/unit/decimal.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect } from "vitest"; +import { + decimal, + add, + sub, + mul, + div, + min, + max, + sum, + average, + neg, + abs, + eq, + gt, + gte, + lt, + lte, + fromRedux, + isDecimal, + toDecimalNumber, + SafetyRangeError, + PrecisionError, + MAX_SAFE, + type Decimal, +} from "@/utils/decimal"; + +describe("decimal() factory", () => { + it("accepts supported precisions and quantizes the input", () => { + expect(decimal("1.2345", 2).toFixed()).toBe("1.23"); + expect(decimal(5, 0).toFixed()).toBe("5"); + expect(decimal("1.0005", 3).toFixed()).toBe("1.001"); // ROUND_HALF_UP + }); + + it("rejects an unsupported precision", () => { + expect(() => decimal("1", 5 as 6)).toThrow(PrecisionError); + expect(() => decimal("1", 1 as 2)).toThrow(PrecisionError); + }); + + it("rejects malformed numeric input", () => { + expect(() => decimal("not-a-number", 2)).toThrow(PrecisionError); + }); + + it("throws SafetyRangeError when the input exceeds 10^21", () => { + expect(() => decimal("1e22", 0)).toThrow(SafetyRangeError); + }); + + it("allows exactly 10^21 (the ceiling is inclusive)", () => { + expect(decimal(MAX_SAFE.toFixed(), 0).isSafe()).toBe(true); + }); +}); + +describe("add / sub at mixed precision", () => { + it("upcasts to the higher precision", () => { + const a = decimal("1.50", 2); // currency + const b = decimal("0.005", 3); // electricity + const r = add(a, b); + expect(r.precision).toBe(3); + expect(r.toFixed()).toBe("1.505"); + }); + + it("subtracts exactly", () => { + expect(sub(decimal("10.000", 3), decimal("0.001", 3)).toFixed()).toBe("9.999"); + }); + + it("throws on overflow", () => { + const big = decimal(MAX_SAFE.toFixed(), 0); + expect(() => add(big, decimal("1", 0))).toThrow(SafetyRangeError); + }); +}); + +describe("mul / div", () => { + it("multiplies and sums the scales (capped at 9)", () => { + const price = decimal("2.50", 2); + const qty = decimal("3.000", 3); + const r = mul(price, qty); + expect(r.precision).toBe(5); + expect(r.toFixed()).toBe("7.50000"); + }); + + it("detects multiplication overflow", () => { + const a = decimal("100000000000", 0); // 1e11 + expect(() => mul(a, a)).toThrow(SafetyRangeError); // 1e22 + }); + + it("divides with 28 significant digits and upcasts precision", () => { + expect(div(decimal("10.00", 2), decimal("4.00", 2)).toFixed()).toBe("2.50"); + }); + + it("throws on division by zero", () => { + expect(() => div(decimal("1", 0), decimal("0", 0))).toThrow(PrecisionError); + }); +}); + +describe("min / max / sum / average", () => { + it("min and max return the relevant operand", () => { + const a = decimal("1.2", 2); + const b = decimal("3.4", 2); + expect(min(a, b)).toBe(a); + expect(max(a, b)).toBe(b); + }); + + it("sum folds a list exactly at the highest precision", () => { + const r = sum([decimal("1.5", 2), decimal("0.025", 3), decimal("2", 0)]); + expect(r.precision).toBe(3); + expect(r.toFixed()).toBe("3.525"); + }); + + it("sum of an empty list is zero at the requested precision", () => { + expect(sum([], 2).toFixed()).toBe("0.00"); + }); + + it("average is exact", () => { + expect(average([decimal("1", 0), decimal("2", 0), decimal("4", 0)]).toFixed()).toBe( + "2" + ); + }); + + it("average of an empty list throws", () => { + expect(() => average([])).toThrow(PrecisionError); + }); +}); + +describe("neg / abs / comparisons", () => { + it("negates and takes absolute value", () => { + expect(neg(decimal("3.14", 2)).toFixed()).toBe("-3.14"); + expect(abs(decimal("-3.14", 2)).toFixed()).toBe("3.14"); + }); + + it("compares correctly", () => { + const a = decimal("1.00", 2); + const b = decimal("2.00", 2); + expect(eq(a, a)).toBe(true); + expect(gt(b, a)).toBe(true); + expect(gte(a, a)).toBe(true); + expect(lt(a, b)).toBe(true); + expect(lte(a, a)).toBe(true); + }); +}); + +describe("serialization round-trip", () => { + it("toRedux / fromRedux preserves the exact value", () => { + const d = decimal("123.456789", 6); + const s = d.toRedux(); + expect(s).toEqual({ value: "123.456789", precision: 6, scale: 1_000_000 }); + const back = fromRedux(s); + expect(eq(back, d)).toBe(true); + expect(back.toFixed()).toBe("123.456789"); + }); + + it("isDecimal recognises decimals", () => { + expect(isDecimal(decimal("1", 0))).toBe(true); + expect(isDecimal("1")).toBe(false); + expect(isDecimal(1)).toBe(false); + }); +}); + +describe("zero-drift accumulation", () => { + it("accumulates 1000 × 0.001 to exactly 1.000 (no drift)", () => { + let acc: Decimal = decimal("0.000", 3); + for (let i = 0; i < 1000; i++) { + acc = add(acc, decimal("0.001", 3)); + } + expect(acc.toFixed()).toBe("1.000"); + expect(eq(acc, decimal("1", 3))).toBe(true); + // Contrast: naive float arithmetic does NOT land exactly on 1. + let naive = 0; + for (let i = 0; i < 1000; i++) naive += 0.001; + expect(naive).not.toBe(1); + }); + + it("accumulates 1,000,000 gas readings without drift", () => { + let acc: Decimal = decimal("0.0000", 4); + for (let i = 0; i < 10_000; i++) { + acc = add(acc, decimal("0.0001", 4)); + } + expect(acc.toFixed()).toBe("1.0000"); + }); +}); + +describe("toDecimalNumber", () => { + it("converts an exactly-representable value without warning", () => { + expect(toDecimalNumber(decimal("2.5", 2))).toBe(2.5); + }); +}); diff --git a/tests/unit/resourceSlice.test.ts b/tests/unit/resourceSlice.test.ts new file mode 100644 index 0000000..3f16643 --- /dev/null +++ b/tests/unit/resourceSlice.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { decimal, eq } from "@/utils/decimal"; +import { + resourceStore, + serializeReading, + deserializeReading, + selectReadings, + selectTotal, +} from "@/store/slices/resourceSlice"; +import { RESOURCE_PRECISION, type MeterReading } from "@/types/meter"; + +function reading(value: string, timestamp = 0): MeterReading { + return { + meterId: "m1", + resource: "electricity", + timestamp, + value: decimal(value, RESOURCE_PRECISION.electricity) as MeterReading["value"], + }; +} + +beforeEach(() => resourceStore.dispatch({ type: "RESET" })); + +describe("serialize / deserialize", () => { + it("round-trips a reading losslessly through string encoding", () => { + const r = reading("12.345"); + const s = serializeReading(r); + expect(s.value).toEqual({ value: "12.345", precision: 3, scale: 1000 }); + const back = deserializeReading(s); + expect(eq(back.value, r.value)).toBe(true); + }); +}); + +describe("resourceStore", () => { + it("stores readings in serialized form and rehydrates them", () => { + resourceStore.dispatch({ type: "ADD_READING", payload: reading("1.500") }); + resourceStore.dispatch({ type: "ADD_READING", payload: reading("2.500") }); + + // Raw state is JSON-safe (string values, no BigNumber instances). + const raw = resourceStore.getState().electricity; + expect(raw).toHaveLength(2); + expect(typeof raw[0].value.value).toBe("string"); + + const readings = selectReadings(resourceStore.getState(), "electricity"); + expect(readings.map((r) => r.value.toFixed())).toEqual(["1.500", "2.500"]); + }); + + it("computes an exact total via the selector", () => { + resourceStore.dispatch({ + type: "ADD_READINGS", + payload: [reading("0.001"), reading("0.002"), reading("0.003")], + }); + expect(selectTotal(resourceStore.getState(), "electricity").toFixed()).toBe("0.006"); + }); + + it("CLEAR_RESOURCE empties one resource only", () => { + resourceStore.dispatch({ type: "ADD_READING", payload: reading("1.000") }); + resourceStore.dispatch({ type: "CLEAR_RESOURCE", payload: { resource: "electricity" } }); + expect(resourceStore.getState().electricity).toHaveLength(0); + }); +});