From bbe69d633bb32b5947e0877aa3ec226d20139d8f Mon Sep 17 00:00:00 2001 From: zinodict121 Date: Sat, 22 Aug 2026 08:19:01 -0700 Subject: [PATCH] chore: remove unused React components, hooks, and utilities --- src/components/boundaries/CascadeContext.tsx | 50 -- src/components/boundaries/DomainBoundary.tsx | 59 -- src/components/boundaries/ErrorBoundary.tsx | 37 - src/components/boundaries/ErrorPanel.tsx | 34 - src/components/boundaries/Skeletons.tsx | 50 -- src/components/boundaries/index.tsx | 115 ---- src/components/common/AriaLiveRegion.tsx | 101 --- src/components/dashboard/ConsumptionPanel.tsx | 111 --- src/components/dashboard/DashboardPage.tsx | 99 --- src/components/dashboard/ResourceChart.tsx | 111 --- .../dashboard/ResourceTelemetryChart.tsx | 244 ------- .../dashboard/SchedulerDashboard.tsx | 121 ---- src/components/dashboard/TileCacheOverlay.tsx | 54 -- src/components/layout/ChartsArea.tsx | 61 -- src/components/layout/DataGrid.tsx | 71 -- src/components/layout/MapPanel.tsx | 34 - src/components/layout/Sidebar.tsx | 61 -- src/components/map/AssetHeatmapLayer.tsx | 51 -- src/components/map/ThreeOverlay.tsx | 241 ------- src/components/map/UtilityNetworkLayer.ts | 294 -------- src/components/map/boundaryManager.ts | 52 -- src/components/map/heatmapStore.ts | 61 -- src/components/map/lod/AssetMesh.ts | 144 ---- src/components/map/lod/QuadTree.ts | 230 ------- src/components/panels/ConnectionStatusBar.tsx | 110 --- .../panels/ReserveAttestationPanel.tsx | 162 ----- src/components/panels/ToastContainer.tsx | 121 ---- src/components/panels/ZKSubmissionPanel.tsx | 247 ------- src/components/settings/LanguageSwitcher.tsx | 38 -- src/components/spatial/DeviceCard.tsx | 50 -- src/components/spatial/Provisioner.tsx | 356 ---------- src/components/ui/VirtualList.tsx | 198 ------ src/hooks/useContainerSize.ts | 130 ---- src/hooks/useContractEvents.ts | 211 ------ src/hooks/useContractState.ts | 154 ----- src/hooks/useEscrow.ts | 146 ---- src/hooks/useGeoLocation.ts | 64 -- src/hooks/useKeyboardNavigation.ts | 135 ---- src/hooks/useLocale.ts | 33 - src/hooks/useMapCameraSync.ts | 133 ---- src/hooks/useMapViewport.ts | 49 -- src/hooks/useNetworkTopology.ts | 108 --- src/hooks/useNonceManager.ts | 40 -- src/hooks/useOfflineSync.ts | 283 -------- src/hooks/useOnlineStatus.ts | 45 -- src/hooks/useProofOfReserve.ts | 96 --- src/hooks/usePushNotifications.ts | 162 ----- src/hooks/useResourceData.ts | 111 --- src/hooks/useRetryQueue.ts | 183 ----- src/hooks/useScheduledExecution.ts | 108 --- src/hooks/useSuspenseResource.ts | 15 - src/hooks/useTelemetryStream.ts | 242 ------- src/hooks/useVirtualList.ts | 359 ---------- src/hooks/useWebSocket.ts | 447 ------------ src/hooks/useWorker.ts | 187 ----- src/hooks/useZKSubmission.ts | 190 ------ src/services/proofOfReserve.ts | 278 -------- src/services/reconnectMachine.ts | 201 ------ src/services/webhookDelivery.ts | 196 ------ src/store/slices/crdtSlice.ts | 170 ----- src/store/slices/notificationSlice.ts | 151 ----- src/tests/merkleWasm.bench.ts | 76 --- src/utils/aggregation.ts | 118 ---- src/utils/aggregation/ResourceUnits.ts | 100 --- src/utils/aggregation/resourceMath.ts | 114 ---- src/utils/aggregation/unitConversion.ts | 55 -- src/utils/backoff.ts | 48 -- src/utils/cameraMatrix.ts | 135 ---- src/utils/capacityPlanning.ts | 152 ----- src/utils/chaosBlueprint.ts | 153 ----- src/utils/crdtMerge.ts | 187 ----- src/utils/dbMigration.ts | 178 ----- src/utils/decimal.ts | 270 -------- src/utils/edgeBundling.ts | 167 ----- src/utils/edgeSimplification.ts | 84 --- src/utils/errors.ts | 102 --- src/utils/formatting.ts | 14 - src/utils/frameBuffer.ts | 93 --- src/utils/frustum.ts | 90 --- src/utils/geo.ts | 69 -- src/utils/kafkaConsumerLag.ts | 145 ---- src/utils/lamportClock.ts | 78 --- src/utils/lruEviction.ts | 160 ----- src/utils/math.ts | 258 ------- src/utils/merkleTree.ts | 51 -- src/utils/merkleWasm.ts | 79 --- src/utils/porMerkle.ts | 160 ----- src/utils/sanitize.ts | 12 - src/utils/sharedBuffer.ts | 111 --- src/utils/suspenseResource.ts | 108 --- src/utils/telemetry/binaryFraming.ts | 56 -- src/utils/telemetry/dataStore.ts | 124 ---- src/utils/telemetry/frameValidator.ts | 382 ----------- src/utils/telemetry/types.ts | 92 --- src/utils/tileMath.ts | 189 ------ src/utils/timerWheelCore.ts | 166 ----- src/utils/topicRouter.ts | 180 ----- src/utils/vectorClock.ts | 97 --- src/utils/wasmLoader.ts | 95 --- tests/components/AssetHeatmapLayer.test.tsx | 57 -- .../components/ResourceTelemetryChart.test.ts | 112 --- tests/components/VirtualList.test.tsx | 247 ------- tests/components/boundaries.test.tsx | 147 ---- tests/hooks/useContainerSize.test.ts | 182 ----- tests/hooks/useEscrow.test.ts | 160 ----- tests/hooks/useMapCameraSync.test.ts | 108 --- tests/hooks/useRetryQueue.test.ts | 640 ------------------ tests/hooks/useVirtualList.test.ts | 318 --------- tests/hooks/useWebSocket.test.ts | 194 ------ tests/unit/aggregation.test.ts | 137 ---- tests/unit/backoff.test.ts | 58 -- tests/unit/cameraMatrix.test.ts | 91 --- tests/unit/capacityPlanning.test.ts | 48 -- tests/unit/chaosBlueprint.test.ts | 55 -- tests/unit/crdtMerge.test.ts | 148 ---- tests/unit/crdtSlice.test.ts | 89 --- tests/unit/dbMigration.test.ts | 102 --- tests/unit/decimal.test.ts | 185 ----- tests/unit/edgeBundling.test.ts | 99 --- tests/unit/edgeSimplification.test.ts | 104 --- tests/unit/errors.test.ts | 63 -- tests/unit/frameBuffer.test.ts | 70 -- tests/unit/frustum.test.ts | 66 -- tests/unit/kafkaConsumerLag.test.ts | 80 --- tests/unit/lamportClock.test.ts | 70 -- tests/unit/lruEviction.test.ts | 98 --- tests/unit/math.test.ts | 142 ---- tests/unit/networkTopology.test.ts | 60 -- tests/unit/notificationSlice.test.ts | 139 ---- tests/unit/porMerkle.test.ts | 114 ---- tests/unit/proofOfReserve.test.ts | 153 ----- tests/unit/quadTree.test.ts | 107 --- tests/unit/reconnectMachine.test.ts | 126 ---- tests/unit/resourceMath.test.ts | 111 --- tests/unit/resourceSlice.test.ts | 60 -- tests/unit/resourceUnits.test.ts | 59 -- tests/unit/sanitize.test.ts | 66 -- tests/unit/sharedBuffer.test.ts | 72 -- tests/unit/suspenseResource.test.ts | 129 ---- tests/unit/tileMath.test.ts | 130 ---- tests/unit/timerWheelCore.test.ts | 115 ---- tests/unit/topicRouter.test.ts | 170 ----- tests/unit/unitConversion.test.ts | 53 -- tests/unit/utilityNetworkLayer.test.ts | 73 -- tests/unit/vectorClock.test.ts | 154 ----- tests/unit/webhookDelivery.test.ts | 114 ---- tests/utils/frameValidator.test.ts | 216 ------ 147 files changed, 19234 deletions(-) delete mode 100644 src/components/boundaries/CascadeContext.tsx delete mode 100644 src/components/boundaries/DomainBoundary.tsx delete mode 100644 src/components/boundaries/ErrorBoundary.tsx delete mode 100644 src/components/boundaries/ErrorPanel.tsx delete mode 100644 src/components/boundaries/Skeletons.tsx delete mode 100644 src/components/boundaries/index.tsx delete mode 100644 src/components/common/AriaLiveRegion.tsx delete mode 100644 src/components/dashboard/ConsumptionPanel.tsx delete mode 100644 src/components/dashboard/DashboardPage.tsx delete mode 100644 src/components/dashboard/ResourceChart.tsx delete mode 100644 src/components/dashboard/ResourceTelemetryChart.tsx delete mode 100644 src/components/dashboard/SchedulerDashboard.tsx delete mode 100644 src/components/dashboard/TileCacheOverlay.tsx delete mode 100644 src/components/layout/ChartsArea.tsx delete mode 100644 src/components/layout/DataGrid.tsx delete mode 100644 src/components/layout/MapPanel.tsx delete mode 100644 src/components/layout/Sidebar.tsx delete mode 100644 src/components/map/AssetHeatmapLayer.tsx delete mode 100644 src/components/map/ThreeOverlay.tsx delete mode 100644 src/components/map/UtilityNetworkLayer.ts delete mode 100644 src/components/map/boundaryManager.ts delete mode 100644 src/components/map/heatmapStore.ts delete mode 100644 src/components/map/lod/AssetMesh.ts delete mode 100644 src/components/map/lod/QuadTree.ts delete mode 100644 src/components/panels/ConnectionStatusBar.tsx delete mode 100644 src/components/panels/ReserveAttestationPanel.tsx delete mode 100644 src/components/panels/ToastContainer.tsx delete mode 100644 src/components/panels/ZKSubmissionPanel.tsx delete mode 100644 src/components/settings/LanguageSwitcher.tsx delete mode 100644 src/components/spatial/DeviceCard.tsx delete mode 100644 src/components/spatial/Provisioner.tsx delete mode 100644 src/components/ui/VirtualList.tsx delete mode 100644 src/hooks/useContainerSize.ts delete mode 100644 src/hooks/useContractEvents.ts delete mode 100644 src/hooks/useContractState.ts delete mode 100644 src/hooks/useEscrow.ts delete mode 100644 src/hooks/useGeoLocation.ts delete mode 100644 src/hooks/useKeyboardNavigation.ts delete mode 100644 src/hooks/useLocale.ts delete mode 100644 src/hooks/useMapCameraSync.ts delete mode 100644 src/hooks/useMapViewport.ts delete mode 100644 src/hooks/useNetworkTopology.ts delete mode 100644 src/hooks/useNonceManager.ts delete mode 100644 src/hooks/useOfflineSync.ts delete mode 100644 src/hooks/useOnlineStatus.ts delete mode 100644 src/hooks/useProofOfReserve.ts delete mode 100644 src/hooks/usePushNotifications.ts delete mode 100644 src/hooks/useResourceData.ts delete mode 100644 src/hooks/useRetryQueue.ts delete mode 100644 src/hooks/useScheduledExecution.ts delete mode 100644 src/hooks/useSuspenseResource.ts delete mode 100644 src/hooks/useTelemetryStream.ts delete mode 100644 src/hooks/useVirtualList.ts delete mode 100644 src/hooks/useWebSocket.ts delete mode 100644 src/hooks/useWorker.ts delete mode 100644 src/hooks/useZKSubmission.ts delete mode 100644 src/services/proofOfReserve.ts delete mode 100644 src/services/reconnectMachine.ts delete mode 100644 src/services/webhookDelivery.ts delete mode 100644 src/store/slices/crdtSlice.ts delete mode 100644 src/store/slices/notificationSlice.ts delete mode 100644 src/tests/merkleWasm.bench.ts delete mode 100644 src/utils/aggregation.ts delete mode 100644 src/utils/aggregation/ResourceUnits.ts delete mode 100644 src/utils/aggregation/resourceMath.ts delete mode 100644 src/utils/aggregation/unitConversion.ts delete mode 100644 src/utils/backoff.ts delete mode 100644 src/utils/cameraMatrix.ts delete mode 100644 src/utils/capacityPlanning.ts delete mode 100644 src/utils/chaosBlueprint.ts delete mode 100644 src/utils/crdtMerge.ts delete mode 100644 src/utils/dbMigration.ts delete mode 100644 src/utils/decimal.ts delete mode 100644 src/utils/edgeBundling.ts delete mode 100644 src/utils/edgeSimplification.ts delete mode 100644 src/utils/errors.ts delete mode 100644 src/utils/formatting.ts delete mode 100644 src/utils/frameBuffer.ts delete mode 100644 src/utils/frustum.ts delete mode 100644 src/utils/geo.ts delete mode 100644 src/utils/kafkaConsumerLag.ts delete mode 100644 src/utils/lamportClock.ts delete mode 100644 src/utils/lruEviction.ts delete mode 100644 src/utils/math.ts delete mode 100644 src/utils/merkleTree.ts delete mode 100644 src/utils/merkleWasm.ts delete mode 100644 src/utils/porMerkle.ts delete mode 100644 src/utils/sanitize.ts delete mode 100644 src/utils/sharedBuffer.ts delete mode 100644 src/utils/suspenseResource.ts delete mode 100644 src/utils/telemetry/binaryFraming.ts delete mode 100644 src/utils/telemetry/dataStore.ts delete mode 100644 src/utils/telemetry/frameValidator.ts delete mode 100644 src/utils/telemetry/types.ts delete mode 100644 src/utils/tileMath.ts delete mode 100644 src/utils/timerWheelCore.ts delete mode 100644 src/utils/topicRouter.ts delete mode 100644 src/utils/vectorClock.ts delete mode 100644 src/utils/wasmLoader.ts delete mode 100644 tests/components/AssetHeatmapLayer.test.tsx delete mode 100644 tests/components/ResourceTelemetryChart.test.ts delete mode 100644 tests/components/VirtualList.test.tsx delete mode 100644 tests/components/boundaries.test.tsx delete mode 100644 tests/hooks/useContainerSize.test.ts delete mode 100644 tests/hooks/useEscrow.test.ts delete mode 100644 tests/hooks/useMapCameraSync.test.ts delete mode 100644 tests/hooks/useRetryQueue.test.ts delete mode 100644 tests/hooks/useVirtualList.test.ts delete mode 100644 tests/hooks/useWebSocket.test.ts delete mode 100644 tests/unit/aggregation.test.ts delete mode 100644 tests/unit/backoff.test.ts delete mode 100644 tests/unit/cameraMatrix.test.ts delete mode 100644 tests/unit/capacityPlanning.test.ts delete mode 100644 tests/unit/chaosBlueprint.test.ts delete mode 100644 tests/unit/crdtMerge.test.ts delete mode 100644 tests/unit/crdtSlice.test.ts delete mode 100644 tests/unit/dbMigration.test.ts delete mode 100644 tests/unit/decimal.test.ts delete mode 100644 tests/unit/edgeBundling.test.ts delete mode 100644 tests/unit/edgeSimplification.test.ts delete mode 100644 tests/unit/errors.test.ts delete mode 100644 tests/unit/frameBuffer.test.ts delete mode 100644 tests/unit/frustum.test.ts delete mode 100644 tests/unit/kafkaConsumerLag.test.ts delete mode 100644 tests/unit/lamportClock.test.ts delete mode 100644 tests/unit/lruEviction.test.ts delete mode 100644 tests/unit/math.test.ts delete mode 100644 tests/unit/networkTopology.test.ts delete mode 100644 tests/unit/notificationSlice.test.ts delete mode 100644 tests/unit/porMerkle.test.ts delete mode 100644 tests/unit/proofOfReserve.test.ts delete mode 100644 tests/unit/quadTree.test.ts delete mode 100644 tests/unit/reconnectMachine.test.ts delete mode 100644 tests/unit/resourceMath.test.ts delete mode 100644 tests/unit/resourceSlice.test.ts delete mode 100644 tests/unit/resourceUnits.test.ts delete mode 100644 tests/unit/sanitize.test.ts delete mode 100644 tests/unit/sharedBuffer.test.ts delete mode 100644 tests/unit/suspenseResource.test.ts delete mode 100644 tests/unit/tileMath.test.ts delete mode 100644 tests/unit/timerWheelCore.test.ts delete mode 100644 tests/unit/topicRouter.test.ts delete mode 100644 tests/unit/unitConversion.test.ts delete mode 100644 tests/unit/utilityNetworkLayer.test.ts delete mode 100644 tests/unit/vectorClock.test.ts delete mode 100644 tests/unit/webhookDelivery.test.ts delete mode 100644 tests/utils/frameValidator.test.ts diff --git a/src/components/boundaries/CascadeContext.tsx b/src/components/boundaries/CascadeContext.tsx deleted file mode 100644 index 8276390..0000000 --- a/src/components/boundaries/CascadeContext.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"use client"; - -import { createContext, useContext, useMemo, useState, type ReactNode } from "react"; - -/** - * Fallback-cascade context. A composite boundary blocks its subtree when a - * critical child fails, so downstream resources can skip fetching (saving - * bandwidth) rather than each showing their own loader. - */ - -export interface CascadeValue { - blocked: boolean; - block: () => void; - reset: () => void; -} - -const CascadeContext = createContext({ - blocked: false, - block: () => {}, - reset: () => {}, -}); - -export function CascadeProvider({ children }: { children: ReactNode }) { - const [blocked, setBlocked] = useState(false); - const value = useMemo( - () => ({ blocked, block: () => setBlocked(true), reset: () => setBlocked(false) }), - [blocked] - ); - return {children}; -} - -export function useCascade(): CascadeValue { - return useContext(CascadeContext); -} - -/** - * Renders `children` only when the cascade is not blocked; otherwise renders - * `whenBlocked` (default: nothing) — used to skip telemetry fetching when the - * blockchain boundary has failed. - */ -export function CascadeGate({ - children, - whenBlocked = null, -}: { - children: ReactNode; - whenBlocked?: ReactNode; -}) { - const { blocked } = useCascade(); - return <>{blocked ? whenBlocked : children}; -} diff --git a/src/components/boundaries/DomainBoundary.tsx b/src/components/boundaries/DomainBoundary.tsx deleted file mode 100644 index dd4f43b..0000000 --- a/src/components/boundaries/DomainBoundary.tsx +++ /dev/null @@ -1,59 +0,0 @@ -"use client"; - -import { Suspense, useCallback, useState, type ReactNode } from "react"; -import { ErrorBoundary } from "@/components/boundaries/ErrorBoundary"; -import { ErrorPanel } from "@/components/boundaries/ErrorPanel"; -import { cacheStore } from "@/store/slices/cacheSlice"; - -/** - * Generic named boundary: an {@link ErrorBoundary} wrapping a `Suspense`. - * - * On Retry it invalidates the boundary's cache groups (setting them stale so the - * next read re-fetches) and remounts via a key bump, which clears the error and - * re-runs the suspending children. - */ - -export interface DomainBoundaryProps { - /** Cache groups invalidated on retry (e.g. ["blockchain", "telemetry"]). */ - groups: string[]; - /** Suspense fallback (skeleton). */ - fallback: ReactNode; - errorTitle: string; - children: ReactNode; - /** Called when an error is first caught (e.g. to set the cascade flag). */ - onError?: (error: Error) => void; - /** Called on Retry, before invalidation + remount (e.g. reset the cascade). */ - onRetry?: () => void; -} - -export function DomainBoundary({ - groups, - fallback, - errorTitle, - children, - onError, - onRetry, -}: DomainBoundaryProps) { - const [attempt, setAttempt] = useState(0); - const groupsKey = groups.join(","); - - const retry = useCallback(() => { - onRetry?.(); - for (const group of groupsKey.split(",")) { - if (group) cacheStore.invalidateGroup(group); - } - setAttempt((a) => a + 1); - }, [groupsKey, onRetry]); - - return ( - ( - - )} - > - {children} - - ); -} diff --git a/src/components/boundaries/ErrorBoundary.tsx b/src/components/boundaries/ErrorBoundary.tsx deleted file mode 100644 index 824949c..0000000 --- a/src/components/boundaries/ErrorBoundary.tsx +++ /dev/null @@ -1,37 +0,0 @@ -"use client"; - -import { Component, type ErrorInfo, type ReactNode } from "react"; - -/** - * Generic error boundary. Catches errors thrown during render (including a - * Suspense resource's rejected promise) and renders a fallback. Remounting it - * with a new `key` clears the error — the retry mechanism the boundaries use. - */ - -export interface ErrorBoundaryProps { - children: ReactNode; - fallback: (error: Error) => ReactNode; - /** Invoked when an error is first caught (e.g. to set a cascade flag). */ - onError?: (error: Error) => void; -} - -interface ErrorBoundaryState { - error: Error | null; -} - -export class ErrorBoundary extends Component { - state: ErrorBoundaryState = { error: null }; - - static getDerivedStateFromError(error: Error): ErrorBoundaryState { - return { error }; - } - - componentDidCatch(error: Error, _info: ErrorInfo): void { - this.props.onError?.(error); - } - - render(): ReactNode { - if (this.state.error) return this.props.fallback(this.state.error); - return this.props.children; - } -} diff --git a/src/components/boundaries/ErrorPanel.tsx b/src/components/boundaries/ErrorPanel.tsx deleted file mode 100644 index 348cea5..0000000 --- a/src/components/boundaries/ErrorPanel.tsx +++ /dev/null @@ -1,34 +0,0 @@ -"use client"; - -/** Error fallback with a Retry action, shown when a boundary catches an error. */ - -export interface ErrorPanelProps { - title: string; - error: Error; - onRetry: () => void; - className?: string; -} - -export function ErrorPanel({ title, error, onRetry, className }: ErrorPanelProps) { - return ( -
-
-
-

{title}

-

- {error.message} -

-
- -
-
- ); -} diff --git a/src/components/boundaries/Skeletons.tsx b/src/components/boundaries/Skeletons.tsx deleted file mode 100644 index 223443d..0000000 --- a/src/components/boundaries/Skeletons.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"use client"; - -import type { DomainKey } from "@/types/suspense"; - -/** Domain-specific skeleton loaders shown as Suspense fallbacks. */ - -const DOMAIN_LABEL: Record = { - blockchain: "Loading contract state…", - telemetry: "Connecting telemetry…", - metadata: "Loading metadata…", - spatial: "Loading map tiles…", -}; - -const DOMAIN_HEIGHT: Record = { - blockchain: "h-[160px]", - telemetry: "h-[200px]", - metadata: "h-[120px]", - spatial: "h-[300px]", -}; - -export interface SkeletonProps { - domain: DomainKey; - className?: string; -} - -export function SkeletonLoader({ domain, className }: SkeletonProps) { - return ( -
- {DOMAIN_LABEL[domain]} -
- ); -} - -/** Composite skeleton (e.g. while a dashboard's children all suspend). */ -export function CompositeSkeleton({ domains }: { domains: DomainKey[] }) { - return ( -
- {domains.map((d) => ( - - ))} -
- ); -} diff --git a/src/components/boundaries/index.tsx b/src/components/boundaries/index.tsx deleted file mode 100644 index 2a3f03b..0000000 --- a/src/components/boundaries/index.tsx +++ /dev/null @@ -1,115 +0,0 @@ -"use client"; - -import type { ReactNode } from "react"; -import { DomainBoundary } from "@/components/boundaries/DomainBoundary"; -import { SkeletonLoader, CompositeSkeleton } from "@/components/boundaries/Skeletons"; -import { CascadeProvider, useCascade } from "@/components/boundaries/CascadeContext"; - -/** - * Named per-domain boundaries (isolated) and the two composite boundaries - * (dashboard, map) that coordinate the fallback cascade. - */ - -export { ErrorBoundary } from "@/components/boundaries/ErrorBoundary"; -export { ErrorPanel } from "@/components/boundaries/ErrorPanel"; -export { SkeletonLoader, CompositeSkeleton } from "@/components/boundaries/Skeletons"; -export { DomainBoundary } from "@/components/boundaries/DomainBoundary"; -export { - CascadeProvider, - CascadeGate, - useCascade, -} from "@/components/boundaries/CascadeContext"; - -// --- Primary domain boundaries (standalone, error-isolated) ----------------- - -export function BlockchainBoundary({ children }: { children: ReactNode }) { - return ( - } - errorTitle="Contract state unavailable" - > - {children} - - ); -} - -export function TelemetryBoundary({ children }: { children: ReactNode }) { - return ( - } - errorTitle="Telemetry stream unavailable" - > - {children} - - ); -} - -export function MetadataBoundary({ children }: { children: ReactNode }) { - return ( - } - errorTitle="Metadata unavailable" - > - {children} - - ); -} - -export function SpatialBoundary({ children }: { children: ReactNode }) { - return ( - } - errorTitle="Map tiles unavailable" - > - {children} - - ); -} - -// --- Composite boundaries --------------------------------------------------- - -function DashboardBoundaryInner({ children }: { children: ReactNode }) { - const { block, reset } = useCascade(); - return ( - } - errorTitle="Dashboard data unavailable" - onError={block} - onRetry={reset} - > - {children} - - ); -} - -/** - * Composite of the blockchain + telemetry domains. A critical blockchain error - * surfaces a single dashboard error panel; because the shared boundary unmounts - * its subtree, the telemetry resource never fetches (and `useCascade().blocked` - * is set for any descendant that needs to know). - */ -export function DashboardBoundary({ children }: { children: ReactNode }) { - return ( - - {children} - - ); -} - -/** Composite of the metadata + spatial domains. */ -export function MapBoundary({ children }: { children: ReactNode }) { - return ( - } - errorTitle="Map data unavailable" - > - {children} - - ); -} diff --git a/src/components/common/AriaLiveRegion.tsx b/src/components/common/AriaLiveRegion.tsx deleted file mode 100644 index b9e0b59..0000000 --- a/src/components/common/AriaLiveRegion.tsx +++ /dev/null @@ -1,101 +0,0 @@ -"use client"; - -import { useEffect, useRef, useState, type ReactNode } from "react"; - -type AriaLivePoliteness = "polite" | "assertive" | "off"; - -interface AriaLiveRegionProps { - children?: ReactNode; - message?: string; - politeness?: AriaLivePoliteness; - role?: "status" | "alert" | "log"; - labelledBy?: string; - className?: string; -} - -export function AriaLiveRegion({ - children, - message, - politeness = "polite", - role = "status", - labelledBy, - className, -}: AriaLiveRegionProps) { - const [announcement, setAnnouncement] = useState(""); - const regionRef = useRef(null); - const prevMessageRef = useRef(undefined); - - useEffect(() => { - if (message !== undefined && message !== prevMessageRef.current) { - setAnnouncement(message); - prevMessageRef.current = message; - } - }, [message]); - - const announced = announcement || message || ""; - - return ( -
- {announced} - {children} -
- ); -} - -export function useAriaLiveAnnouncer() { - const [message, setMessage] = useState(""); - - function announce(msg: string, priority: "polite" | "assertive" = "polite") { - setMessage(msg); - setTimeout(() => setMessage(""), 100); - return { politeness: priority }; - } - - function announceError(msg: string) { - return announce(`Error: ${msg}`, "assertive"); - } - - function announceSuccess(msg: string) { - return announce(`Success: ${msg}`, "polite"); - } - - return { - message, - announce, - announceError, - announceSuccess, - AriaLive: ( - - ), - AriaLiveAssertive: ( - - ), - }; -} diff --git a/src/components/dashboard/ConsumptionPanel.tsx b/src/components/dashboard/ConsumptionPanel.tsx deleted file mode 100644 index 3c2129d..0000000 --- a/src/components/dashboard/ConsumptionPanel.tsx +++ /dev/null @@ -1,111 +0,0 @@ -"use client"; - -import { useEffect, useMemo, useState } from "react"; -import { - aggregateReadings, - perResourceUnits, - PRECISION_THRESHOLD, - type Reading, -} from "@/utils/aggregation/resourceMath"; -import { RESOURCE_TYPES, type ResourceType } from "@/utils/aggregation/unitConversion"; - -/** - * Real-time resource-consumption panel. - * - * Aggregation is exact (BigInt); the only conversion to `Number` happens here, - * at the display tail, formatted with `Intl.NumberFormat`. A subtle badge - * surfaces the precision audit when the legacy `Number` pipeline would have - * drifted beyond the tolerance. - */ - -const RESOURCE_LABEL: Record = { - water: "Water", - energy: "Energy", - bandwidth: "Bandwidth", -}; - -export interface ConsumptionPanelProps { - /** Readings for the current window. */ - readings: Reading[]; - /** Pull a fresh window on an interval (ms); omit for a static snapshot. */ - refreshIntervalMs?: number; - /** Source for live refreshes (defaults to the static `readings` prop). */ - readingsSource?: () => Reading[]; - className?: string; -} - -export function ConsumptionPanel({ - readings, - refreshIntervalMs, - readingsSource, - className, -}: ConsumptionPanelProps) { - const [window, setWindow] = useState(readings); - - useEffect(() => { - setWindow(readings); - }, [readings]); - - // Dashboard refresh: re-pull the window on the configured interval. - useEffect(() => { - if (!refreshIntervalMs || !readingsSource) return; - const id = setInterval(() => setWindow(readingsSource()), refreshIntervalMs); - return () => clearInterval(id); - }, [refreshIntervalMs, readingsSource]); - - const result = useMemo(() => aggregateReadings(window), [window]); - const byResource = useMemo(() => perResourceUnits(result), [result]); - - const driftDetected = result.relativeError > PRECISION_THRESHOLD; - - return ( -
-
-
-

Resource Consumption

-

- {result.readingCount.toLocaleString()} readings · unified resource - units -

-
- {driftDetected && ( - - precision-corrected - - )} -
- -
- - Total - -

- {result.total.toDisplay(4, 2)} -

-
- -
- {RESOURCE_TYPES.map((type) => ( -
-
{RESOURCE_LABEL[type]}
-
- {byResource[type].toDisplay(4, 2)} -
-
- ))} -
-
- ); -} - -export default ConsumptionPanel; diff --git a/src/components/dashboard/DashboardPage.tsx b/src/components/dashboard/DashboardPage.tsx deleted file mode 100644 index 1229337..0000000 --- a/src/components/dashboard/DashboardPage.tsx +++ /dev/null @@ -1,99 +0,0 @@ -"use client"; - -import { useMemo } from "react"; -import { - DashboardBoundary, - MapBoundary, -} from "@/components/boundaries"; -import { createResource } from "@/utils/suspenseResource"; -import { useSuspenseResource } from "@/hooks/useSuspenseResource"; -import { - CACHE_TTL_MS, - domainCacheKey, - type SuspenseResource, -} from "@/types/suspense"; - -/** - * Top-level page composing the Suspense boundaries with proper nesting: - * DashboardBoundary { blockchain + telemetry } (critical-cascade) - * MapBoundary { metadata + spatial } - * - * (The blueprint names `src/pages/Dashboard.tsx`, but this is an App Router - * project, so it ships as a component to avoid a Pages Router conflict.) - */ - -export interface DashboardData { - blockchain: SuspenseResource<{ ledger: number }>; - telemetry: SuspenseResource<{ readings: number }>; - metadata: SuspenseResource<{ tariffs: number }>; - spatial: SuspenseResource<{ tiles: number }>; -} - -export interface DashboardPageProps { - /** Inject resources (tests / real wiring); defaults are demo stubs. */ - resources?: DashboardData; -} - -function defaultResources(): DashboardData { - const stub = (domain: Parameters[0], value: T) => - createResource(() => Promise.resolve(value), { - cacheKey: domainCacheKey(domain, "summary"), - ttlMs: CACHE_TTL_MS[domain], - }); - return { - blockchain: stub("blockchain", { ledger: 0 }), - telemetry: stub("telemetry", { readings: 0 }), - metadata: stub("metadata", { tariffs: 0 }), - spatial: stub("spatial", { tiles: 0 }), - }; -} - -function Stat({ label, value }: { label: string; value: number | string }) { - return ( -
-

{label}

-

{value}

-
- ); -} - -function BlockchainSection({ resource }: { resource: DashboardData["blockchain"] }) { - const data = useSuspenseResource(resource); - return ; -} -function TelemetrySection({ resource }: { resource: DashboardData["telemetry"] }) { - const data = useSuspenseResource(resource); - return ; -} -function MetadataSection({ resource }: { resource: DashboardData["metadata"] }) { - const data = useSuspenseResource(resource); - return ; -} -function SpatialSection({ resource }: { resource: DashboardData["spatial"] }) { - const data = useSuspenseResource(resource); - return ; -} - -export function DashboardPage({ resources }: DashboardPageProps) { - const data = useMemo(() => resources ?? defaultResources(), [resources]); - - return ( -
- -
- - -
-
- - -
- - -
-
-
- ); -} - -export default DashboardPage; diff --git a/src/components/dashboard/ResourceChart.tsx b/src/components/dashboard/ResourceChart.tsx deleted file mode 100644 index baa2c14..0000000 --- a/src/components/dashboard/ResourceChart.tsx +++ /dev/null @@ -1,111 +0,0 @@ -"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 ( -
- No data -
- ); - } - - return ( -
-
- - {latest} - {unit && {unit}} - - - {minY.toFixed(2)} – {maxY.toFixed(2)} - -
- - - -
- ); -} - -export default ResourceChart; diff --git a/src/components/dashboard/ResourceTelemetryChart.tsx b/src/components/dashboard/ResourceTelemetryChart.tsx deleted file mode 100644 index a63cc51..0000000 --- a/src/components/dashboard/ResourceTelemetryChart.tsx +++ /dev/null @@ -1,244 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useRef, useState } from "react"; -import type { TelemetryDataStore } from "@/utils/telemetry/dataStore"; -import type { ChartPoint } from "@/utils/telemetry/types"; - -const DEFAULT_COLORS = ["#22c55e", "#3b82f6", "#f59e0b", "#ef4444", "#a855f7"]; - -export interface ResourceTelemetryChartProps { - /** Mutable data store to render (read every animation frame). */ - store: TelemetryDataStore | null; - /** Fixed pixel height of the chart area. @default 200 */ - height?: number; - /** Stroke color per series. */ - colors?: string[]; - /** Optional legend labels per series. */ - labels?: string[]; -} - -interface PlotPoint { - x: number; - y: number; - synthetic: boolean; - /** true for NaN sentinels => break the line. */ - gap: boolean; -} - -interface SegmentPoint { - x: number; - y: number; -} - -export interface DrawOptions { - width: number; - height: number; - colors?: string[]; - padding?: number; - labels?: string[]; -} - -/** - * Pure renderer for a multi-line telemetry chart. - * - * - `NaN` values act as gap separators: the line is broken into disconnected - * segments (this is how the 300-frame cap's sentinel is visualised). - * - `synthetic` points are stroked dashed and at reduced opacity so - * interpolated/decimated data is visually distinguishable from live data. - * - * Extracted from the component so it can be unit-tested with a fake context. - */ -export function drawTelemetry( - ctx: CanvasRenderingContext2D, - datasets: ChartPoint[][], - opts: DrawOptions -): void { - const { width, height } = opts; - const colors = opts.colors ?? DEFAULT_COLORS; - const padding = opts.padding ?? 10; - - ctx.clearRect(0, 0, width, height); - - // Compute a shared value range across every series (ignoring NaN sentinels). - let min = Infinity; - let max = -Infinity; - for (const ds of datasets) { - if (!ds) continue; - for (const p of ds) { - if (Number.isNaN(p.value)) continue; - if (p.value < min) min = p.value; - if (p.value > max) max = p.value; - } - } - if (!Number.isFinite(min) || !Number.isFinite(max)) { - min = 0; - max = 100; - } - if (max - min < 1e-9) { - max = min + 1; - } - const range = max - min; - const plotHeight = height - 2 * padding; - - for (let s = 0; s < datasets.length; s++) { - const ds = datasets[s]; - if (!ds || ds.length === 0) continue; - const color = colors[s % colors.length]; - const n = ds.length; - const stepX = n > 1 ? width / (n - 1) : 0; - - const points: PlotPoint[] = new Array(n); - for (let i = 0; i < n; i++) { - const p = ds[i]; - if (Number.isNaN(p.value)) { - points[i] = { x: 0, y: 0, synthetic: false, gap: true }; - continue; - } - points[i] = { - x: i * stepX, - y: height - padding - ((p.value - min) / range) * plotHeight, - synthetic: p.synthetic, - gap: false, - }; - } - - drawSeriesSegments(ctx, points, color); - } - - // Legend - ctx.setLineDash([]); - ctx.globalAlpha = 1; - if (opts.labels && opts.labels.length > 0) { - ctx.font = "12px monospace"; - for (let s = 0; s < opts.labels.length; s++) { - const color = colors[s % colors.length]; - ctx.fillStyle = color; - ctx.fillRect(8 + s * 120, 6, 10, 10); - ctx.fillStyle = "var(--foreground)"; - ctx.fillText(opts.labels[s], 24 + s * 120, 15); - } - } -} - -function drawSeriesSegments( - ctx: CanvasRenderingContext2D, - points: PlotPoint[], - color: string -): void { - let segment: SegmentPoint[] = []; - let segmentStyle: "solid" | "dashed" | null = null; - let last: SegmentPoint | null = null; - - const flush = () => { - if (segment.length >= 2 && segmentStyle !== null) { - ctx.beginPath(); - ctx.moveTo(segment[0].x, segment[0].y); - for (let k = 1; k < segment.length; k++) { - ctx.lineTo(segment[k].x, segment[k].y); - } - if (segmentStyle === "dashed") { - ctx.setLineDash([5, 4]); - ctx.globalAlpha = 0.5; - } else { - ctx.setLineDash([]); - ctx.globalAlpha = 1; - } - ctx.strokeStyle = color; - ctx.lineWidth = 2; - ctx.stroke(); - } - segment = []; - segmentStyle = null; - }; - - for (let i = 0; i < points.length; i++) { - const p = points[i]; - if (p.gap) { - flush(); - last = null; - continue; - } - const desired: "solid" | "dashed" = p.synthetic ? "dashed" : "solid"; - if (segmentStyle !== null && segmentStyle !== desired) { - flush(); - } - if (segmentStyle === null) { - segmentStyle = desired; - if (last) segment.push(last); // keep visual continuity across style change - } - segment.push({ x: p.x, y: p.y }); - last = { x: p.x, y: p.y }; - } - flush(); - - ctx.setLineDash([]); - ctx.globalAlpha = 1; -} - -/** - * Canvas-based multi-line telemetry chart. Reads the provided store every - * animation frame (so live streaming never triggers React re-renders) and - * renders gap separators / synthetic points per {@link drawTelemetry}. - */ -export function ResourceTelemetryChart({ - store, - height = 200, - colors, - labels, -}: ResourceTelemetryChartProps) { - const canvasRef = useRef(null); - const containerRef = useRef(null); - const [dimensions, setDimensions] = useState({ width: 800, height }); - const rafRef = useRef(0); - - useEffect(() => { - const container = containerRef.current; - if (!container || typeof ResizeObserver === "undefined") return; - const observer = new ResizeObserver((entries) => { - for (const entry of entries) { - setDimensions({ - width: Math.floor(entry.contentRect.width), - height: Math.floor(entry.contentRect.height), - }); - } - }); - observer.observe(container); - return () => observer.disconnect(); - }, []); - - const draw = useCallback(() => { - const canvas = canvasRef.current; - if (!canvas) return; - const ctx = canvas.getContext("2d"); - if (!ctx || !store) return; - drawTelemetry(ctx, store.datasets, { - width: dimensions.width, - height: dimensions.height, - colors, - labels, - }); - }, [dimensions, store, colors, labels]); - - useEffect(() => { - rafRef.current = requestAnimationFrame(function loop() { - draw(); - rafRef.current = requestAnimationFrame(loop); - }); - return () => cancelAnimationFrame(rafRef.current); - }, [draw]); - - return ( -
- -
- ); -} diff --git a/src/components/dashboard/SchedulerDashboard.tsx b/src/components/dashboard/SchedulerDashboard.tsx deleted file mode 100644 index 45e3292..0000000 --- a/src/components/dashboard/SchedulerDashboard.tsx +++ /dev/null @@ -1,121 +0,0 @@ -"use client"; - -import { useMemo } from "react"; -import { - useSchedule, - selectJobs, - scheduleStore, -} from "@/store/slices/scheduleSlice"; -import { getTimerWheel } from "@/services/timerWheel"; -import type { JobStatus } from "@/types/scheduler"; - -/** - * Admin view of all scheduled Soroban executions: next fire time, interval, - * status, missed-fire count and a cancel action. - * - * (The blueprint names `src/pages/SchedulerDashboard.tsx`, but this project uses - * the App Router, so it ships as a component to avoid creating a conflicting - * Pages Router directory.) - */ - -const STATUS_STYLE: Record = { - scheduled: "bg-blue-500/10 text-blue-600", - firing: "bg-amber-500/10 text-amber-600", - done: "bg-green-500/10 text-green-600", - cancelled: "bg-muted text-muted-foreground", -}; - -function formatTime(ms: number): string { - return new Date(ms).toLocaleTimeString(); -} - -function formatInterval(ms: number | null): string { - if (!ms) return "once"; - if (ms % 86_400_000 === 0) return `${ms / 86_400_000}d`; - if (ms % 3_600_000 === 0) return `${ms / 3_600_000}h`; - if (ms % 60_000 === 0) return `${ms / 60_000}m`; - return `${Math.round(ms / 1000)}s`; -} - -export interface SchedulerDashboardProps { - className?: string; -} - -export function SchedulerDashboard({ className }: SchedulerDashboardProps) { - const state = useSchedule(); - const jobs = useMemo(() => selectJobs(state), [state]); - - const handleCancel = (id: string) => { - getTimerWheel().cancel(id); - scheduleStore.dispatch({ type: "JOB_CANCELLED", payload: { id } }); - }; - - return ( -
-
-

Scheduled Executions

- - {jobs.length} job{jobs.length === 1 ? "" : "s"} - -
- - {jobs.length === 0 ? ( -

- No scheduled jobs. -

- ) : ( - - - - - - - - - - - - {jobs.map((job) => ( - - - - - - - - - ))} - -
JobNext fireIntervalStatusMissed -
{job.name} - {formatTime(job.nextFire)} - - {formatInterval(job.intervalMs)} - - - {job.status} - - - {job.missedCount > 0 ? ( - {job.missedCount} - ) : ( - "0" - )} - - {job.status === "scheduled" && ( - - )} -
- )} -
- ); -} - -export default SchedulerDashboard; diff --git a/src/components/dashboard/TileCacheOverlay.tsx b/src/components/dashboard/TileCacheOverlay.tsx deleted file mode 100644 index 26986a8..0000000 --- a/src/components/dashboard/TileCacheOverlay.tsx +++ /dev/null @@ -1,54 +0,0 @@ -"use client"; - -import { - useTileCacheStats, - selectHitRatio, -} from "@/store/slices/tileCacheSlice"; -import { TILE_CACHE_CAPACITY } from "@/types/tile"; - -/** - * Debug overlay surfacing tile-cache health (hit ratio, byte usage, pending - * downloads). Gated behind a feature flag so it never ships to operators by - * default. - */ - -export interface TileCacheOverlayProps { - /** Render only when true (the feature flag). */ - enabled?: boolean; -} - -function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -} - -export function TileCacheOverlay({ enabled = false }: TileCacheOverlayProps) { - const stats = useTileCacheStats(); - if (!enabled) return null; - - const hitRatio = selectHitRatio(stats); - const fill = stats.count / TILE_CACHE_CAPACITY; - - return ( -
-
Tile cache
-
- hit ratio - {(hitRatio * 100).toFixed(1)}% - tiles - - {stats.count}/{TILE_CACHE_CAPACITY} ({(fill * 100).toFixed(0)}%) - - bytes - {formatBytes(stats.bytes)} - evictions - {stats.evictions} - pending - {stats.pending} -
-
- ); -} - -export default TileCacheOverlay; diff --git a/src/components/layout/ChartsArea.tsx b/src/components/layout/ChartsArea.tsx deleted file mode 100644 index ba530db..0000000 --- a/src/components/layout/ChartsArea.tsx +++ /dev/null @@ -1,61 +0,0 @@ -"use client"; - -import { useRef, useEffect, type ReactNode } from "react"; -import { useContainerSize } from "@/hooks/useContainerSize"; - -interface ChartSlot { - key: string; - title: string; - content: ReactNode; - /** Optional callback when container dimensions change (e.g., chart.resize()) */ - onResize?: (width: number, height: number) => void; -} - -interface ChartsAreaProps { - charts: ChartSlot[]; -} - -/** - * CSS Container-query-driven Charts Area. - * - * Layout (grid vs stacked) and aspect-ratio are driven by @container - * queries in containers.css. The `useContainerSize` hook is used only - * to provide dimension data to imperative charting libraries (Recharts, - * Chart.js, etc.) that need to call `.resize()`. - */ -export function ChartsArea({ charts }: ChartsAreaProps) { - const ref = useRef(null); - const { width, height } = useContainerSize(ref, { - compactMax: 500, - expandedMin: 500, - }); - - // Notify each chart of dimension changes - useEffect(() => { - for (const chart of charts) { - chart.onResize?.(width, height); - } - }, [width, height, charts]); - - return ( -
- {charts.map((chart) => ( -
-

- {chart.title} -

- {chart.content} -
- ))} - - {charts.length === 0 && ( -
- No charts configured -
- )} -
- ); -} diff --git a/src/components/layout/DataGrid.tsx b/src/components/layout/DataGrid.tsx deleted file mode 100644 index 8cb88c4..0000000 --- a/src/components/layout/DataGrid.tsx +++ /dev/null @@ -1,71 +0,0 @@ -"use client"; - -interface Column { - key: string; - header: string; - /** If true, this column is hidden in compact (< 600px) mode via CSS */ - secondary?: boolean; - render: (row: T) => React.ReactNode; -} - -interface DataGridProps { - columns: Column[]; - rows: T[]; -} - -/** - * CSS Container-query-driven Data Grid. - * - * Column visibility, row density, and font sizing are all driven by - * @container queries in containers.css. The component renders ALL - * columns into the DOM and lets CSS hide secondary columns when the - * container is compact (< 600px). - */ -export function DataGrid({ - columns, - rows, -}: DataGridProps) { - return ( -
-
- - - - {columns.map((col) => ( - - ))} - - - - {rows.map((row) => ( - - {columns.map((col) => ( - - ))} - - ))} - -
- {col.header} -
- {col.render(row)} -
- - {rows.length === 0 && ( -
- No data available -
- )} -
-
- ); -} diff --git a/src/components/layout/MapPanel.tsx b/src/components/layout/MapPanel.tsx deleted file mode 100644 index 8d909f6..0000000 --- a/src/components/layout/MapPanel.tsx +++ /dev/null @@ -1,34 +0,0 @@ -"use client"; - -import { type ReactNode } from "react"; - -interface MapPanelProps { - children?: ReactNode; - /** Toolbar controls rendered inside the map panel header */ - controls?: ReactNode; -} - -/** - * CSS Container-query-driven Map Panel. - * - * All layout is driven by @container queries in containers.css. - * No JS-based class switching — the CSS handles vertical vs horizontal - * control layout based on the container's inline size. - */ -export function MapPanel({ children, controls }: MapPanelProps) { - return ( -
- {/* Controls toolbar — CSS container queries switch direction */} - {controls && ( -
- {controls} -
- )} - - {/* Main content area */} -
- {children} -
-
- ); -} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx deleted file mode 100644 index 6d802d8..0000000 --- a/src/components/layout/Sidebar.tsx +++ /dev/null @@ -1,61 +0,0 @@ -"use client"; - -import { useRef, type ReactNode } from "react"; -import { useContainerSize } from "@/hooks/useContainerSize"; - -interface SidebarProps { - /** Navigation items: { icon: JSX, label: string, href: string } */ - items?: { icon: ReactNode; label: string; href: string }[]; -} - -/** - * CSS Container-query-driven Sidebar. - * - * All layout is driven by @container queries in containers.css. - * The `useContainerSize` hook is only used for the optional dev - * debug indicator — the component does NOT switch classes via JS. - * - * Container states (from containers.css): - * - inline-size < 400px: icon-only, 64px wide - * - 400px <= inline-size < 800px: compact labels, 220px wide - * - inline-size >= 800px: full labels + padding, 320px wide - */ -export function Sidebar({ items = [] }: SidebarProps) { - const ref = useRef(null); - const { width, containerState } = useContainerSize(ref, { - compactMax: 400, - expandedMin: 800, - }); - - return ( - - ); -} diff --git a/src/components/map/AssetHeatmapLayer.tsx b/src/components/map/AssetHeatmapLayer.tsx deleted file mode 100644 index b231151..0000000 --- a/src/components/map/AssetHeatmapLayer.tsx +++ /dev/null @@ -1,51 +0,0 @@ -'use client'; - -import { useEffect, useRef } from 'react'; -import { updateBoundary, getAffectedTiles } from './boundaryManager'; - -interface AssetPosition { - id: string; - lat: number; - lng: number; - value: number; -} - -interface HeatmapProps { - assets: AssetPosition[]; - boundaries: Array<{ - id: string; - layer: 'service_district' | 'flood_zone' | 'grid_region'; - region: { minX: number; minY: number; maxX: number; maxY: number }; - }>; - onTileUpdate?: (tileKey: string, data: Float32Array) => void; -} - -export function AssetHeatmapLayer({ assets, boundaries, onTileUpdate }: HeatmapProps) { - const canvasRef = useRef(null); - - useEffect(() => { - for (const boundary of boundaries) { - const tiles = getAffectedTiles(boundary.layer, boundary.region); - - updateBoundary({ - id: boundary.id, - layer: boundary.layer, - tiles, - updateFn: async (_tileKey: string) => { - const heatValues = new Float32Array(256 * 256); - for (const asset of assets) { - const tx = Math.floor((asset.lng - boundary.region.minX) / (boundary.region.maxX - boundary.region.minX) * 256); - const ty = Math.floor((asset.lat - boundary.region.minY) / (boundary.region.maxY - boundary.region.minY) * 256); - if (tx >= 0 && tx < 256 && ty >= 0 && ty < 256) { - heatValues[ty * 256 + tx] += asset.value; - } - } - onTileUpdate?.(_tileKey, heatValues); - return heatValues; - }, - }); - } - }, [assets, boundaries, onTileUpdate]); - - return ; -} diff --git a/src/components/map/ThreeOverlay.tsx b/src/components/map/ThreeOverlay.tsx deleted file mode 100644 index 687a18c..0000000 --- a/src/components/map/ThreeOverlay.tsx +++ /dev/null @@ -1,241 +0,0 @@ -"use client"; - -import { useEffect, useRef } from "react"; -import * as THREE from "three"; -import { - GPU_MEMORY_BUDGET_BYTES, - LOD_DISTANCES, - type AssetInstance, - type LODLevel, -} from "@/types/spatial"; -import { buildAssetLOD, createCircleSpriteTexture } from "@/components/map/lod/AssetMesh"; -import type { LODDistances } from "@/components/map/lod/QuadTree"; -import { forwardVector } from "@/utils/cameraMatrix"; -import { - useMapCameraSync, - type MapLike, -} from "@/hooks/useMapCameraSync"; -import type { - FrustumCullRequest, - FrustumCullResponse, -} from "@/workers/frustumCull.worker"; - -/** Map local world (x east, y north, z up) → Three.js (x, y up, z). */ -function worldToThree(p: { x: number; y: number; z: number }): THREE.Vector3 { - return new THREE.Vector3(p.x, p.z, -p.y); -} - -export interface ThreeOverlayProps { - /** The Mapbox map (camera source). */ - map: MapLike | null; - /** The Mapbox container the Three canvas is overlaid onto. */ - container: HTMLElement | null; - assets: AssetInstance[]; - enabled?: boolean; -} - -/** - * Three.js overlay composited over the Mapbox WebGL canvas. A transparent, - * pointer-events-none canvas is absolutely positioned over the map container so - * Mapbox keeps handling interaction. Each frame the camera is synced from - * `map.transform`, a worker culls the QuadTree, and the resulting visible assets - * are rendered at their LOD. A memory watchdog degrades LOD distances when the - * estimated GPU footprint exceeds the budget. - */ -export function ThreeOverlay({ - map, - container, - assets, - enabled = true, -}: ThreeOverlayProps) { - const rendererRef = useRef(null); - const sceneRef = useRef(null); - const cameraRef = useRef(null); - const workerRef = useRef(null); - const lodByIdRef = useRef>(new Map()); - const visibleIdsRef = useRef>(new Set()); - const lodDistancesRef = useRef({ ...LOD_DISTANCES }); - const requestIdRef = useRef(0); - - // --- Renderer / scene lifecycle ---------------------------------------- - useEffect(() => { - if (!enabled || !container) return; - - const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true }); - renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); - renderer.setSize(container.clientWidth, container.clientHeight); - const canvas = renderer.domElement; - canvas.style.position = "absolute"; - canvas.style.inset = "0"; - canvas.style.pointerEvents = "none"; // let Mapbox handle interaction - container.appendChild(canvas); - - const scene = new THREE.Scene(); - scene.add(new THREE.AmbientLight(0xffffff, 0.8)); - const sun = new THREE.DirectionalLight(0xffffff, 0.6); - sun.position.set(100, 200, 100); - scene.add(sun); - - const camera = new THREE.PerspectiveCamera( - 60, - container.clientWidth / Math.max(1, container.clientHeight), - 1, - 5000 - ); - - rendererRef.current = renderer; - sceneRef.current = scene; - cameraRef.current = camera; - - const onResize = () => { - renderer.setSize(container.clientWidth, container.clientHeight); - camera.aspect = container.clientWidth / Math.max(1, container.clientHeight); - camera.updateProjectionMatrix(); - }; - window.addEventListener("resize", onResize); - - return () => { - window.removeEventListener("resize", onResize); - canvas.remove(); - renderer.dispose(); - rendererRef.current = null; - sceneRef.current = null; - cameraRef.current = null; - }; - }, [container, enabled]); - - // --- Spawn the frustum-cull worker ------------------------------------- - useEffect(() => { - if (!enabled) return; - let worker: Worker; - try { - worker = new Worker( - new URL("../../workers/frustumCull.worker.ts", import.meta.url), - { type: "module" } - ); - } catch { - return; // worker unsupported — overlay simply renders nothing - } - workerRef.current = worker; - - worker.onmessage = (e: MessageEvent) => { - const msg = e.data; - if (msg.type === "result") { - const ids = new Set(); - for (const v of msg.visible) { - ids.add(v.id); - applyLod(v.id, v.lod); - } - // Hide assets that left the visible set. - for (const id of visibleIdsRef.current) { - if (!ids.has(id)) { - const lod = lodByIdRef.current.get(id); - if (lod) lod.visible = false; - } - } - visibleIdsRef.current = ids; - } - }; - - return () => { - worker.terminate(); - workerRef.current = null; - }; - }, [enabled]); - - // --- Load assets into the scene and the worker ------------------------- - useEffect(() => { - const scene = sceneRef.current; - if (!scene) return; - - const spriteTexture = createCircleSpriteTexture(); - const map_ = lodByIdRef.current; - - for (const asset of assets) { - const lod = buildAssetLOD(asset.type, { spriteTexture }); - lod.position.copy(worldToThree(asset.position)); - lod.visible = false; - scene.add(lod); - map_.set(asset.id, lod); - } - - const loadMsg: FrustumCullRequest = { type: "load", assets }; - workerRef.current?.postMessage(loadMsg); - - return () => { - for (const lod of map_.values()) scene.remove(lod); - map_.clear(); - visibleIdsRef.current.clear(); - }; - }, [assets]); - - // --- Camera sync + render loop ----------------------------------------- - useMapCameraSync({ - map, - enabled, - onSync: (cam, viewProjection) => { - const renderer = rendererRef.current; - const scene = sceneRef.current; - const camera = cameraRef.current; - if (!renderer || !scene || !camera) return; - - // Position/orient the Three camera from the synced Mapbox camera. - camera.position.copy(worldToThree(cam.position)); - const fwd = forwardVector(cam.heading, cam.pitch); - camera.lookAt( - camera.position.x + fwd.x, - camera.position.y + fwd.z, - camera.position.z - fwd.y - ); - camera.updateMatrixWorld(); - - // Update each visible LOD against the camera, then render. - for (const id of visibleIdsRef.current) { - lodByIdRef.current.get(id)?.update(camera); - } - renderer.render(scene, camera); - - maybeDegradeForMemory(renderer); - - // Kick an off-thread cull for the next frame. - const worker = workerRef.current; - if (worker) { - const queryMsg: FrustumCullRequest = { - type: "query", - requestId: ++requestIdRef.current, - viewProjection, - cameraPosition: cam.position, - lodDistances: lodDistancesRef.current, - }; - worker.postMessage(queryMsg); - } - }, - }); - - function applyLod(id: string, lod: LODLevel): void { - const obj = lodByIdRef.current.get(id); - if (!obj) return; - obj.visible = true; - void lod; // exact level is chosen by THREE.LOD.update against the camera - } - - /** - * Rough GPU-memory watchdog. `renderer.info` exposes counts, not bytes, so we - * estimate from geometry/texture counts and degrade the LOD0 distance when the - * estimate crosses the budget (and restore it when it recovers). - */ - function maybeDegradeForMemory(renderer: THREE.WebGLRenderer): void { - const { geometries, textures } = renderer.info.memory; - // Coarse estimate: ~64 KB per geometry, ~256 KB per texture. - const estimate = geometries * 64 * 1024 + textures * 256 * 1024; - if (estimate > GPU_MEMORY_BUDGET_BYTES) { - lodDistancesRef.current = { full: 50, simplified: 200, impostor: 500 }; - } else { - lodDistancesRef.current = { ...LOD_DISTANCES }; - } - } - - return null; -} - -export default ThreeOverlay; diff --git a/src/components/map/UtilityNetworkLayer.ts b/src/components/map/UtilityNetworkLayer.ts deleted file mode 100644 index d56fb85..0000000 --- a/src/components/map/UtilityNetworkLayer.ts +++ /dev/null @@ -1,294 +0,0 @@ -/** - * Mapbox GL JS custom layer rendering a dense utility network with raw WebGL. - * - * Mapbox's native line layer saturates draw calls past ~10k edges; this layer - * uploads the whole network into a single interleaved vertex buffer and draws it - * with one `gl.LINES` call, transformed by the Mapbox projection matrix - * (`u_matrix`) in normalized mercator space. Per-vertex colors encode flow - * direction (hue) and load status (alpha). Geometry is bundled (FDEB) and - * simplified (Douglas-Peucker) before upload. - * - * Implements the structural `CustomLayerInterface` (onAdd / render / prerender / - * onRemove) without taking a hard dependency on `mapbox-gl`. - */ - -import { - FLOW_COLOR, - LOAD_ALPHA, - type BundledEdge, - type LngLat, - type NetworkEdge, - type RGBA, - type VertexData, -} from "@/types/network"; -import { lngLatToMercator } from "@/utils/geo"; -import { simplifyEdges } from "@/utils/edgeSimplification"; -import { bundleEdges } from "@/utils/edgeBundling"; - -/** Minimal structural view of the bits of the Mapbox map we use. */ -export interface MapForLayer { - getZoom(): number; - triggerRepaint(): void; -} - -export interface CustomLayerLike { - id: string; - type: "custom"; - renderingMode?: "2d" | "3d"; - onAdd(map: MapForLayer, gl: WebGLRenderingContext): void; - render(gl: WebGLRenderingContext, matrix: number[]): void; - prerender?(gl: WebGLRenderingContext, matrix: number[]): void; - onRemove(map: MapForLayer, gl: WebGLRenderingContext): void; -} - -const FLOATS_PER_VERTEX = 6; // x, y, r, g, b, a -const MAX_LINE_WIDTH = 4.0; - -type DrawableEdge = Pick & { - points: LngLat[]; -}; - -/** RGBA color for an edge: hue from flow direction, alpha from load status. */ -export function edgeColor(edge: { - flowDirection: NetworkEdge["flowDirection"]; - loadStatus: NetworkEdge["loadStatus"]; -}): RGBA { - const [r, g, b] = FLOW_COLOR[edge.flowDirection]; - return [r, g, b, LOAD_ALPHA[edge.loadStatus]]; -} - -/** - * Build an interleaved vertex buffer and a line-segment index buffer from a set - * of edges. Positions are normalized mercator coordinates; colors are baked per - * vertex. Pure — unit-tested without a GL context. - */ -export function buildVertexData(edges: DrawableEdge[]): VertexData { - const vertexCount = edges.reduce((n, e) => n + e.points.length, 0); - const interleaved = new Float32Array(vertexCount * FLOATS_PER_VERTEX); - const indexPairs: number[] = []; - - let v = 0; - for (const edge of edges) { - const [r, g, b, a] = edgeColor(edge); - const startVertex = v; - for (let i = 0; i < edge.points.length; i++) { - const { x, y } = lngLatToMercator(edge.points[i]); - const o = v * FLOATS_PER_VERTEX; - interleaved[o] = x; - interleaved[o + 1] = y; - interleaved[o + 2] = r; - interleaved[o + 3] = g; - interleaved[o + 4] = b; - interleaved[o + 5] = a; - if (i > 0) indexPairs.push(v - 1, v); - v++; - } - void startVertex; - } - - return { - interleaved, - indices: new Uint32Array(indexPairs), - vertexCount, - }; -} - -const VERTEX_SHADER = ` -precision highp float; -uniform mat4 u_matrix; -attribute vec2 a_pos; // normalized mercator -attribute vec4 a_color; // per-vertex rgba -varying vec4 v_color; -void main() { - v_color = a_color; - gl_Position = u_matrix * vec4(a_pos, 0.0, 1.0); -}`; - -const FRAGMENT_SHADER = ` -precision highp float; -varying vec4 v_color; -uniform float u_opacity; -void main() { - gl_FragColor = vec4(v_color.rgb, v_color.a * u_opacity); -}`; - -function compileShader( - gl: WebGLRenderingContext, - type: number, - source: string -): WebGLShader { - const shader = gl.createShader(type); - if (!shader) throw new Error("Failed to create shader"); - gl.shaderSource(shader, source); - gl.compileShader(shader); - if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { - const log = gl.getShaderInfoLog(shader); - gl.deleteShader(shader); - throw new Error(`Shader compile failed: ${log}`); - } - return shader; -} - -export interface UtilityNetworkLayerOptions { - id?: string; - /** Re-simplify/re-bundle when the integer zoom changes. @default true */ - zoomAware?: boolean; - lineWidth?: number; - opacity?: number; -} - -/** - * The custom layer. Construct with the edge set; `onAdd`/`render`/`onRemove` are - * driven by Mapbox. Call {@link setEdges} (e.g. from a Redux toggle) to re-bind - * the vertex buffer after a visibility/status-filter change. - */ -export class UtilityNetworkLayer implements CustomLayerLike { - readonly id: string; - readonly type = "custom" as const; - readonly renderingMode = "2d" as const; - - private map: MapForLayer | null = null; - private gl: WebGLRenderingContext | null = null; - private program: WebGLProgram | null = null; - private vertexBuffer: WebGLBuffer | null = null; - private indexBuffer: WebGLBuffer | null = null; - private uMatrix: WebGLUniformLocation | null = null; - private uOpacity: WebGLUniformLocation | null = null; - private aPos = -1; - private aColor = -1; - - private edges: NetworkEdge[]; - private vertexData: VertexData = { - interleaved: new Float32Array(0), - indices: new Uint32Array(0), - vertexCount: 0, - }; - private lastZoomBucket = NaN; - private readonly opts: Required; - - constructor(edges: NetworkEdge[], options: UtilityNetworkLayerOptions = {}) { - this.edges = edges; - this.opts = { - id: options.id ?? "utility-network", - zoomAware: options.zoomAware ?? true, - lineWidth: Math.min(options.lineWidth ?? 1.5, MAX_LINE_WIDTH), - opacity: options.opacity ?? 1.0, - }; - this.id = this.opts.id; - } - - /** Replace the edge set and re-upload the buffer (if already added). */ - setEdges(edges: NetworkEdge[]): void { - this.edges = edges; - this.lastZoomBucket = NaN; // force a rebuild - if (this.gl && this.map) { - this.rebuild(this.map.getZoom()); - this.map.triggerRepaint(); - } - } - - /** Process edges for the given zoom: simplify → bundle → vertex data. */ - private processEdges(zoom: number): DrawableEdge[] { - const simplified = simplifyEdges(this.edges, zoom); - return bundleEdges(simplified); - } - - private rebuild(zoom: number): void { - const drawable = this.opts.zoomAware - ? this.processEdges(zoom) - : this.edges.map((e) => ({ - points: e.geometry, - flowDirection: e.flowDirection, - loadStatus: e.loadStatus, - })); - this.vertexData = buildVertexData(drawable); - this.uploadBuffers(); - this.lastZoomBucket = Math.floor(zoom); - } - - private uploadBuffers(): void { - const gl = this.gl; - if (!gl || !this.vertexBuffer || !this.indexBuffer) return; - gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer); - gl.bufferData(gl.ARRAY_BUFFER, this.vertexData.interleaved, gl.DYNAMIC_DRAW); - gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer); - gl.bufferData( - gl.ELEMENT_ARRAY_BUFFER, - this.vertexData.indices, - gl.DYNAMIC_DRAW - ); - } - - onAdd(map: MapForLayer, gl: WebGLRenderingContext): void { - this.map = map; - this.gl = gl; - - const program = gl.createProgram(); - if (!program) throw new Error("Failed to create program"); - const vert = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER); - const frag = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER); - gl.attachShader(program, vert); - gl.attachShader(program, frag); - gl.linkProgram(program); - if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { - throw new Error(`Program link failed: ${gl.getProgramInfoLog(program)}`); - } - this.program = program; - - this.uMatrix = gl.getUniformLocation(program, "u_matrix"); - this.uOpacity = gl.getUniformLocation(program, "u_opacity"); - this.aPos = gl.getAttribLocation(program, "a_pos"); - this.aColor = gl.getAttribLocation(program, "a_color"); - - this.vertexBuffer = gl.createBuffer(); - this.indexBuffer = gl.createBuffer(); - - this.rebuild(map.getZoom()); - } - - /** Re-simplify/bundle when the integer zoom changes (cheap broad-phase). */ - prerender(_gl: WebGLRenderingContext, _matrix: number[]): void { - if (!this.opts.zoomAware || !this.map) return; - const bucket = Math.floor(this.map.getZoom()); - if (bucket !== this.lastZoomBucket) { - this.rebuild(this.map.getZoom()); - } - } - - render(gl: WebGLRenderingContext, matrix: number[]): void { - if (!this.program || this.vertexData.vertexCount === 0) return; - - gl.useProgram(this.program); - gl.uniformMatrix4fv(this.uMatrix, false, matrix); - if (this.uOpacity) gl.uniform1f(this.uOpacity, this.opts.opacity); - - gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer); - const stride = FLOATS_PER_VERTEX * 4; - gl.enableVertexAttribArray(this.aPos); - gl.vertexAttribPointer(this.aPos, 2, gl.FLOAT, false, stride, 0); - gl.enableVertexAttribArray(this.aColor); - gl.vertexAttribPointer(this.aColor, 4, gl.FLOAT, false, stride, 2 * 4); - - gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer); - gl.enable(gl.BLEND); - gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); - gl.lineWidth(this.opts.lineWidth); - gl.drawElements( - gl.LINES, - this.vertexData.indices.length, - gl.UNSIGNED_INT, - 0 - ); - } - - onRemove(_map: MapForLayer, gl: WebGLRenderingContext): void { - if (this.program) gl.deleteProgram(this.program); - if (this.vertexBuffer) gl.deleteBuffer(this.vertexBuffer); - if (this.indexBuffer) gl.deleteBuffer(this.indexBuffer); - this.program = null; - this.vertexBuffer = null; - this.indexBuffer = null; - this.gl = null; - this.map = null; - } -} diff --git a/src/components/map/boundaryManager.ts b/src/components/map/boundaryManager.ts deleted file mode 100644 index e3d4168..0000000 --- a/src/components/map/boundaryManager.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { acquireTileLock, writeTile, getTileGeneration } from './heatmapStore'; - -interface Boundary { - id: string; - layer: 'service_district' | 'flood_zone' | 'grid_region'; - tiles: string[]; - updateFn: (tileKey: string) => Promise; -} - -const pendingUpdates = new Map>(); - -export async function updateBoundary(boundary: Boundary): Promise { - for (const tileKey of boundary.tiles) { - const key = tileKey; - if (pendingUpdates.has(key)) { - await pendingUpdates.get(key); - } - - const task = (async () => { - const release = await acquireTileLock(key); - try { - const gen = getTileGeneration(key); - const data = await boundary.updateFn(key); - writeTile(key, gen, data); - } finally { - release(); - pendingUpdates.delete(key); - } - })(); - - pendingUpdates.set(key, task); - } -} - -export function getAffectedTiles( - layer: Boundary['layer'], - region: { minX: number; minY: number; maxX: number; maxY: number } -): string[] { - const tiles: string[] = []; - const tileSize = 256; - const startX = Math.floor(region.minX / tileSize); - const startY = Math.floor(region.minY / tileSize); - const endX = Math.floor(region.maxX / tileSize); - const endY = Math.floor(region.maxY / tileSize); - - for (let x = startX; x <= endX; x++) { - for (let y = startY; y <= endY; y++) { - tiles.push(layer + ':' + x + ':' + y); - } - } - return tiles; -} diff --git a/src/components/map/heatmapStore.ts b/src/components/map/heatmapStore.ts deleted file mode 100644 index b8ef3ed..0000000 --- a/src/components/map/heatmapStore.ts +++ /dev/null @@ -1,61 +0,0 @@ -type HeatValues = Float32Array; - -interface TileEntry { - generation: number; - data: HeatValues; - lock: Promise; -} - -const store = new Map(); - -export function getTile(tileKey: string): HeatValues | undefined { - const entry = store.get(tileKey); - return entry?.data; -} - -export function getTileGeneration(tileKey: string): number { - return store.get(tileKey)?.generation ?? 0; -} - -export async function acquireTileLock(tileKey: string): Promise<() => void> { - const entry = store.get(tileKey); - if (entry?.lock) { - await entry.lock; - } - - let release: () => void; - const lock = new Promise((resolve) => { - release = resolve; - }); - - const current = store.get(tileKey); - store.set(tileKey, { - generation: (current?.generation ?? 0) + 1, - data: current?.data ?? new Float32Array(256 * 256), - lock, - }); - - return () => release!(); -} - -export function writeTile(tileKey: string, generation: number, data: HeatValues): boolean { - const entry = store.get(tileKey); - if (!entry || generation < entry.generation) { - return false; // stale write, discarded - } - store.set(tileKey, { ...entry, data, lock: Promise.resolve() }); - return true; -} - -export function atomicSwapTile(tileKey: string, data: HeatValues): void { - const entry = store.get(tileKey); - store.set(tileKey, { - generation: (entry?.generation ?? 0) + 1, - data, - lock: Promise.resolve(), - }); -} - -export function clearStore(): void { - store.clear(); -} diff --git a/src/components/map/lod/AssetMesh.ts b/src/components/map/lod/AssetMesh.ts deleted file mode 100644 index 4d07793..0000000 --- a/src/components/map/lod/AssetMesh.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * Three.js mesh factory for utility assets with built-in Level-of-Detail. - * - * Each asset type maps to a {@link THREE.LOD} carrying three representations: - * - LOD0: a detailed mesh (an injected GLTF scene, or a beveled box fallback) - * - LOD1: a simplified box - * - LOD2: a billboarded impostor sprite - * Beyond the impostor distance an empty level hides the asset. - * - * Identical asset types are also offered as a single {@link THREE.InstancedMesh} - * to collapse thousands of draw calls into one. - */ - -import * as THREE from "three"; -import { - LOD_DISTANCES, - type AssetInstance, - type AssetType, -} from "@/types/spatial"; - -interface AssetTemplate { - color: number; - /** Box half-extents (meters): width, height, depth. */ - size: [number, number, number]; -} - -const ASSET_TEMPLATES: Record = { - meter: { color: 0x22c55e, size: [1, 2, 1] }, - valve: { color: 0xf59e0b, size: [1.5, 1, 1.5] }, - substation: { color: 0xef4444, size: [6, 4, 6] }, -}; - -/** Generate a soft circular texture for impostor sprites. */ -export function createCircleSpriteTexture(size = 64): THREE.Texture | null { - if (typeof document === "undefined") return null; - const canvas = document.createElement("canvas"); - canvas.width = size; - canvas.height = size; - const ctx = canvas.getContext("2d"); - if (!ctx) return null; - const r = size / 2; - const gradient = ctx.createRadialGradient(r, r, 0, r, r, r); - gradient.addColorStop(0, "rgba(255,255,255,1)"); - gradient.addColorStop(0.7, "rgba(255,255,255,0.9)"); - gradient.addColorStop(1, "rgba(255,255,255,0)"); - ctx.fillStyle = gradient; - ctx.beginPath(); - ctx.arc(r, r, r, 0, Math.PI * 2); - ctx.fill(); - return new THREE.CanvasTexture(canvas); -} - -function detailedBox(template: AssetTemplate): THREE.Mesh { - const [w, h, d] = template.size; - const geometry = new THREE.BoxGeometry(w, h, d, 2, 2, 2); - const material = new THREE.MeshStandardMaterial({ - color: template.color, - roughness: 0.6, - metalness: 0.1, - }); - return new THREE.Mesh(geometry, material); -} - -function simplifiedBox(template: AssetTemplate): THREE.Mesh { - const [w, h, d] = template.size; - const geometry = new THREE.BoxGeometry(w, h, d); - const material = new THREE.MeshBasicMaterial({ color: template.color }); - return new THREE.Mesh(geometry, material); -} - -function impostorSprite( - template: AssetTemplate, - texture: THREE.Texture | null -): THREE.Sprite { - const material = new THREE.SpriteMaterial({ - color: template.color, - map: texture ?? undefined, - sizeAttenuation: true, - }); - const sprite = new THREE.Sprite(material); - const scale = Math.max(...template.size); - sprite.scale.set(scale, scale, 1); - return sprite; -} - -export interface BuildLODOptions { - /** Detailed LOD0 object (e.g. a loaded GLTF scene). Falls back to a box. */ - lod0?: THREE.Object3D; - /** Shared sprite texture for impostors. */ - spriteTexture?: THREE.Texture | null; -} - -/** - * Build a {@link THREE.LOD} for an asset type. Level distances match the - * {@link LOD_DISTANCES} invariants; an empty level past the impostor distance - * hides the asset entirely. - */ -export function buildAssetLOD( - type: AssetType, - options: BuildLODOptions = {} -): THREE.LOD { - const template = ASSET_TEMPLATES[type]; - const lod = new THREE.LOD(); - - lod.addLevel(options.lod0 ?? detailedBox(template), 0); - lod.addLevel(simplifiedBox(template), LOD_DISTANCES.full); - lod.addLevel(impostorSprite(template, options.spriteTexture ?? null), LOD_DISTANCES.simplified); - // Empty object beyond the impostor distance → culled. - lod.addLevel(new THREE.Object3D(), LOD_DISTANCES.impostor); - - return lod; -} - -/** - * Create one {@link THREE.InstancedMesh} for many identical assets of a type, - * baking each instance's position/rotation/scale into the instance matrix. - */ -export function createInstancedMeshes( - type: AssetType, - instances: AssetInstance[] -): THREE.InstancedMesh { - const template = ASSET_TEMPLATES[type]; - const [w, h, d] = template.size; - const geometry = new THREE.BoxGeometry(w, h, d); - const material = new THREE.MeshStandardMaterial({ color: template.color }); - const mesh = new THREE.InstancedMesh(geometry, material, instances.length); - - const matrix = new THREE.Matrix4(); - const position = new THREE.Vector3(); - const quaternion = new THREE.Quaternion(); - const scale = new THREE.Vector3(); - const up = new THREE.Vector3(0, 1, 0); - - instances.forEach((inst, i) => { - position.set(inst.position.x, inst.position.z, -inst.position.y); - quaternion.setFromAxisAngle(up, inst.rotation ?? 0); - const s = inst.scale ?? 1; - scale.set(s, s, s); - matrix.compose(position, quaternion, scale); - mesh.setMatrixAt(i, matrix); - }); - mesh.instanceMatrix.needsUpdate = true; - return mesh; -} diff --git a/src/components/map/lod/QuadTree.ts b/src/components/map/lod/QuadTree.ts deleted file mode 100644 index e0524f4..0000000 --- a/src/components/map/lod/QuadTree.ts +++ /dev/null @@ -1,230 +0,0 @@ -/** - * QuadTree spatial index for the 3D asset overlay. - * - * The 100 km × 100 km region is recursively subdivided into four quadrants (up - * to {@link QUADTREE_MAX_DEPTH} levels). Leaf nodes hold asset references; - * every node tracks the aggregated 3D extent of its subtree so a frustum query - * can reject whole branches with a single bounding-sphere test. Visible assets - * are returned tagged with a Level-of-Detail derived from camera distance. - * - * Pure data structure — no Three.js — so it can run inside a Web Worker. - */ - -import { - BATCH_CULL_TARGET, - LODLevel, - LOD_DISTANCES, - MAX_DRAWABLE_ASSETS, - QUADTREE_MAX_DEPTH, - regionBounds, - type AABB, - type AssetInstance, - type BoundingSphere, - type Coordinate3D, - type FrustumPlane, - type VisibleAsset, -} from "@/types/spatial"; -import { distance3D, sphereInFrustum } from "@/utils/frustum"; - -/** Leaf capacity before a node subdivides (while under the depth cap). */ -const NODE_CAPACITY = 16; - -export interface LODDistances { - full: number; - simplified: number; - impostor: number; -} - -/** Select the LOD bucket for a camera distance (meters). */ -export function getLODLevel( - distance: number, - distances: LODDistances = LOD_DISTANCES -): LODLevel { - if (distance < distances.full) return LODLevel.Full; - if (distance < distances.simplified) return LODLevel.Simplified; - if (distance < distances.impostor) return LODLevel.Impostor; - return LODLevel.Culled; -} - -export interface QueryOptions { - cameraPosition: Coordinate3D; - /** Override LOD switch distances (e.g. the memory watchdog degrading them). */ - lodDistances?: LODDistances; - /** Cap before batch culling. @default MAX_DRAWABLE_ASSETS */ - maxAssets?: number; - /** Batch-cull target once the cap is exceeded. @default BATCH_CULL_TARGET */ - cullTarget?: number; -} - -class QuadNode { - children: QuadNode[] | null = null; - assets: AssetInstance[] = []; - count = 0; - // Aggregated 3D extent of every asset in this subtree. - private minX = Infinity; - private minY = Infinity; - private minZ = Infinity; - private maxX = -Infinity; - private maxY = -Infinity; - private maxZ = -Infinity; - - constructor(readonly bounds: AABB, readonly depth: number) {} - - expand(p: Coordinate3D): void { - this.count += 1; - if (p.x < this.minX) this.minX = p.x; - if (p.y < this.minY) this.minY = p.y; - if (p.z < this.minZ) this.minZ = p.z; - if (p.x > this.maxX) this.maxX = p.x; - if (p.y > this.maxY) this.maxY = p.y; - if (p.z > this.maxZ) this.maxZ = p.z; - } - - /** Bounding sphere of the aggregated extent (empty → radius 0 at origin). */ - boundingSphere(): BoundingSphere { - if (this.count === 0) { - return { center: { x: 0, y: 0, z: 0 }, radius: 0 }; - } - const center = { - x: (this.minX + this.maxX) / 2, - y: (this.minY + this.maxY) / 2, - z: (this.minZ + this.maxZ) / 2, - }; - const radius = - 0.5 * - Math.hypot( - this.maxX - this.minX, - this.maxY - this.minY, - this.maxZ - this.minZ - ); - return { center, radius }; - } -} - -export class QuadTree { - private root: QuadNode; - private readonly bounds: AABB; - private _size = 0; - - constructor( - bounds: AABB = regionBounds(), - private readonly maxDepth: number = QUADTREE_MAX_DEPTH - ) { - this.bounds = bounds; - this.root = new QuadNode(bounds, 0); - } - - /** Total inserted assets. */ - get size(): number { - return this._size; - } - - /** Insert an asset, subdividing leaves that exceed capacity. */ - insert(asset: AssetInstance): void { - this.insertInto(this.root, asset); - this._size += 1; - } - - /** Insert many assets. */ - insertAll(assets: Iterable): void { - for (const a of assets) this.insert(a); - } - - private insertInto(node: QuadNode, asset: AssetInstance): void { - node.expand(asset.position); - - if (node.children) { - this.insertInto(this.childFor(node, asset.position), asset); - return; - } - - node.assets.push(asset); - if (node.assets.length > NODE_CAPACITY && node.depth < this.maxDepth) { - this.subdivide(node); - } - } - - private subdivide(node: QuadNode): void { - const { minX, minY, maxX, maxY } = node.bounds; - const midX = (minX + maxX) / 2; - const midY = (minY + maxY) / 2; - const d = node.depth + 1; - // Order: SW, SE, NW, NE (matches childFor's index math). - node.children = [ - new QuadNode({ minX, minY, maxX: midX, maxY: midY }, d), - new QuadNode({ minX: midX, minY, maxX, maxY: midY }, d), - new QuadNode({ minX, minY: midY, maxX: midX, maxY }, d), - new QuadNode({ minX: midX, minY: midY, maxX, maxY }, d), - ]; - const pending = node.assets; - node.assets = []; - for (const a of pending) { - this.insertInto(this.childFor(node, a.position), a); - } - } - - private childFor(node: QuadNode, p: Coordinate3D): QuadNode { - const { minX, minY, maxX, maxY } = node.bounds; - const midX = (minX + maxX) / 2; - const midY = (minY + maxY) / 2; - const east = p.x >= midX ? 1 : 0; - const north = p.y >= midY ? 1 : 0; - return node.children![north * 2 + east]; - } - - /** - * Return the assets visible within `planes`, each tagged with a LOD level. - * Branches whose bounding sphere lies wholly outside the frustum are skipped. - * When the result exceeds `maxAssets`, the nearest `cullTarget` are kept. - */ - queryFrustum(planes: FrustumPlane[], options: QueryOptions): VisibleAsset[] { - const { - cameraPosition, - lodDistances = LOD_DISTANCES, - maxAssets = MAX_DRAWABLE_ASSETS, - cullTarget = BATCH_CULL_TARGET, - } = options; - - const results: VisibleAsset[] = []; - this.queryNode(this.root, planes, cameraPosition, lodDistances, results); - - if (results.length > maxAssets) { - // Density too high: keep the nearest assets (off-screen already removed). - results.sort((a, b) => a.distance - b.distance); - results.length = cullTarget; - } - return results; - } - - private queryNode( - node: QuadNode, - planes: FrustumPlane[], - camera: Coordinate3D, - distances: LODDistances, - out: VisibleAsset[] - ): void { - if (node.count === 0) return; - if (!sphereInFrustum(planes, node.boundingSphere())) return; - - if (node.children) { - for (const child of node.children) { - this.queryNode(child, planes, camera, distances, out); - } - return; - } - - for (const asset of node.assets) { - const distance = distance3D(camera, asset.position); - const lod = getLODLevel(distance, distances); - if (lod !== LODLevel.Culled) { - out.push({ asset, lod, distance }); - } - } - } - - /** Remove all assets. */ - clear(): void { - this.root = new QuadNode(this.bounds, 0); - this._size = 0; - } -} diff --git a/src/components/panels/ConnectionStatusBar.tsx b/src/components/panels/ConnectionStatusBar.tsx deleted file mode 100644 index 81c3341..0000000 --- a/src/components/panels/ConnectionStatusBar.tsx +++ /dev/null @@ -1,110 +0,0 @@ -"use client"; - -import { useConnectionState } from "@/store/slices/connectionSlice"; -import { - MAX_RECONNECT_ATTEMPTS, - type ConnectionQuality, - type ConnectionStatus, -} from "@/types/connection"; - -/** - * Fixed top bar showing live connection quality (green / yellow / red). It - * reads {@link useConnectionState} and surfaces reconnection progress, plus a - * persistent error banner once the reconnection stratum gives up. - */ - -const QUALITY_STYLES: Record< - ConnectionQuality, - { dot: string; bar: string; label: string } -> = { - good: { dot: "bg-green-500", bar: "bg-green-500/10 text-green-600", label: "Connected" }, - degraded: { - dot: "bg-amber-500 animate-pulse", - bar: "bg-amber-500/10 text-amber-600", - label: "Unstable", - }, - down: { dot: "bg-red-500", bar: "bg-red-500/10 text-red-600", label: "Disconnected" }, -}; - -function statusLabel(status: ConnectionStatus, attempt: number): string { - switch (status) { - case "connected": - return "Live"; - case "connecting": - return "Connecting…"; - case "reconnecting": - return `Reconnecting (attempt ${attempt}/${MAX_RECONNECT_ATTEMPTS})…`; - case "recovering": - return "Recovering subscription…"; - case "failed": - return "Connection lost"; - default: - return "Idle"; - } -} - -export interface ConnectionStatusBarProps { - /** Optional retry handler wired to the failure banner. */ - onRetry?: () => void; - className?: string; -} - -export function ConnectionStatusBar({ onRetry, className }: ConnectionStatusBarProps) { - const { status, quality, latency, nodeId, attempt, lastError } = - useConnectionState(); - - const styles = QUALITY_STYLES[quality]; - const failed = status === "failed"; - - return ( -
-
-
-
-
- {status === "connected" && latency >= 0 && ( - {latency === 0 ? "stable" : `${latency} ms drift`} - )} - Link quality: {styles.label} -
-
- - {failed && ( -
- - {lastError ?? - `Reconnection failed after ${MAX_RECONNECT_ATTEMPTS} attempts.`}{" "} - Telemetry is paused. - - {onRetry && ( - - )} -
- )} -
- ); -} - -export default ConnectionStatusBar; diff --git a/src/components/panels/ReserveAttestationPanel.tsx b/src/components/panels/ReserveAttestationPanel.tsx deleted file mode 100644 index f210e21..0000000 --- a/src/components/panels/ReserveAttestationPanel.tsx +++ /dev/null @@ -1,162 +0,0 @@ -"use client"; - -import { useMemo } from "react"; -import { - useProofOfReserve, - type UseProofOfReserveResult, -} from "@/hooks/useProofOfReserve"; -import type { ProofOfReserveConfig, ProofOfReserveDeps } from "@/services/proofOfReserve"; -import type { ProofStatus } from "@/types/reserve"; - -/** - * Panel showing the latest Proof-of-Reserve attestation: proof hash, ledger, - * time since the last proof, and a red banner when insolvency is detected. - */ - -export interface ReserveAttestationPanelProps { - config: ProofOfReserveConfig; - deps?: ProofOfReserveDeps; - /** Override the hook (testing). */ - controller?: UseProofOfReserveResult; - className?: string; -} - -const STATUS_LABEL: Record = { - idle: "No attestation yet", - fetching: "Fetching commitment & inventory…", - proving: "Generating range proof…", - submitting: "Submitting attestation…", - confirmed: "Attested", - insolvent: "Insolvency detected", - error: "Proof failed", -}; - -function timeSince(ms: number): string { - const secs = Math.max(0, Math.floor((Date.now() - ms) / 1000)); - if (secs < 60) return `${secs}s ago`; - const mins = Math.floor(secs / 60); - if (mins < 60) return `${mins}m ago`; - const hrs = Math.floor(mins / 60); - return `${hrs}h ago`; -} - -function shortHash(hex: string): string { - return hex.length > 14 ? `${hex.slice(0, 10)}…${hex.slice(-4)}` : hex; -} - -export function ReserveAttestationPanel({ - config, - deps, - controller, - className, -}: ReserveAttestationPanelProps) { - const internal = useProofOfReserve(deps); - const { state, generate, reset } = controller ?? internal; - - const busy = - state.status === "fetching" || - state.status === "proving" || - state.status === "submitting"; - - const sinceLabel = useMemo( - () => (state.result ? timeSince(state.result.provedAt) : null), - [state.result] - ); - - return ( -
-
-
-

Proof of Reserve

-

- Cryptographic attestation that reserves back on-chain liabilities. -

-
- - {STATUS_LABEL[state.status]} - -
- - {/* Insolvency banner */} - {state.insolvency && ( -
-

Reserves do not cover liabilities.

-

- reserves {state.insolvency.auditTotal} < liability{" "} - {state.insolvency.liability} (shortfall {state.insolvency.shortfall}) -

-
- )} - - {/* Progress */} - {state.status !== "idle" && !state.insolvency && ( -
-
-
-
- {state.error && state.status === "error" && ( -

{state.error}

- )} -
- )} - - {/* Attestation detail */} - {state.result && ( -
-
Attestation hash
-
- {shortHash(state.result.attestationHash)} -
-
Ledger
-
- {state.result.ledger ?? "—"} -
-
Last proof
-
{sinceLabel}
-
- )} - -
- - {(state.status === "confirmed" || - state.status === "insolvent" || - state.status === "error") && ( - - )} -
-
- ); -} - -export default ReserveAttestationPanel; diff --git a/src/components/panels/ToastContainer.tsx b/src/components/panels/ToastContainer.tsx deleted file mode 100644 index 01eb05a..0000000 --- a/src/components/panels/ToastContainer.tsx +++ /dev/null @@ -1,121 +0,0 @@ -"use client"; - -import { useNotifications, notificationStore } from "@/store/slices/notificationSlice"; -import { - MAX_NOTIFICATION_ACTIONS, - type AppNotification, - type NotificationAction, -} from "@/types/notification"; - -/** - * In-app toast renderer. Reads the coalesced notification queue and shows one - * toast per coalescence key, suffixing "(N more)" when a key has coalesced more - * than once. Action buttons map to a URL or internal route (max 2). - */ - -export interface ToastContainerProps { - /** Navigate to an internal app route (e.g. router.push). */ - onNavigate?: (route: string) => void; - /** Called when an action button is pressed, before navigation. */ - onAction?: (notification: AppNotification, action: NotificationAction) => void; - className?: string; -} - -function topicAccent(topic: string): string { - const domain = topic.split(".")[0]; - switch (domain) { - case "meter": - return "border-l-blue-500"; - case "contract": - return "border-l-amber-500"; - case "system": - return "border-l-red-500"; - default: - return "border-l-border"; - } -} - -export function ToastContainer({ - onNavigate, - onAction, - className, -}: ToastContainerProps) { - const notifications = useNotifications(); - - if (notifications.length === 0) return null; - - const handleAction = ( - notification: AppNotification, - action: NotificationAction - ) => { - onAction?.(notification, action); - if (action.route) { - onNavigate?.(action.route); - } else if (action.url && typeof window !== "undefined") { - window.open(action.url, "_blank", "noopener,noreferrer"); - } - notificationStore.dismiss(notification.id); - }; - - return ( -
- {notifications.map((n) => ( -
-
-
-

- {n.title} - {n.count > 1 && ( - - ({n.count - 1} more) - - )} -

-

- {n.body} -

-

- {n.topic} -

-
- -
- - {n.actions && n.actions.length > 0 && ( -
- {n.actions.slice(0, MAX_NOTIFICATION_ACTIONS).map((action) => ( - - ))} -
- )} -
- ))} -
- ); -} - -export default ToastContainer; diff --git a/src/components/panels/ZKSubmissionPanel.tsx b/src/components/panels/ZKSubmissionPanel.tsx deleted file mode 100644 index 544440a..0000000 --- a/src/components/panels/ZKSubmissionPanel.tsx +++ /dev/null @@ -1,247 +0,0 @@ -"use client"; - -import { useMemo, useState } from "react"; -import type { MeterReading, ZKSubmissionStatus } from "@/types/zk"; -import { CONSUMPTION_MAX_KWH, CONSUMPTION_MIN_KWH } from "@/types/zk"; -import { - useZKSubmission, - type UseZKSubmissionOptions, -} from "@/hooks/useZKSubmission"; -import { useFeatureFlags } from "@/hooks/useFeatureFlags"; - -/** - * UI panel for submitting an encrypted meter reading together with a Groth16 - * zero-knowledge proof. The reading and meter identity never leave the device; - * only the ciphertext, proof and public signals are sent on-chain. A stepper - * surfaces each phase: key download → proving → submitting → verification. - */ - -export interface ZKSubmissionPanelProps - extends Omit { - buildContext: UseZKSubmissionOptions["buildContext"]; - className?: string; -} - -interface Step { - key: Exclude; - label: string; -} - -const STEPS: Step[] = [ - { key: "downloading-key", label: "Cache proving key" }, - { key: "proving", label: "Generate proof" }, - { key: "submitting", label: "Submit encrypted" }, - { key: "confirmed", label: "Verify on-chain" }, -]; - -/** Index of the step a given status belongs to (rejected maps to the last). */ -function statusToStepIndex(status: ZKSubmissionStatus): number { - switch (status) { - case "downloading-key": - return 0; - case "proving": - return 1; - case "submitting": - return 2; - case "confirmed": - case "rejected": - return 3; - default: - return -1; - } -} - -export function ZKSubmissionPanel({ - className, - buildContext, - ...options -}: ZKSubmissionPanelProps) { - const { flags } = useFeatureFlags(); - const { state, submit, cancel, reset } = useZKSubmission({ - ...options, - buildContext, - }); - - const [meterId, setMeterId] = useState(""); - const [consumption, setConsumption] = useState(""); - - const activeIndex = statusToStepIndex(state.status); - const busy = - state.status === "downloading-key" || - state.status === "proving" || - state.status === "submitting"; - - const consumptionError = useMemo(() => { - if (consumption === "") return null; - const n = Number(consumption); - if (!Number.isInteger(n)) return "Enter a whole number of kWh."; - if (n < CONSUMPTION_MIN_KWH || n > CONSUMPTION_MAX_KWH) { - return `Must be between ${CONSUMPTION_MIN_KWH} and ${CONSUMPTION_MAX_KWH} kWh.`; - } - return null; - }, [consumption]); - - const canSubmit = - !busy && - meterId.trim() !== "" && - consumption !== "" && - !consumptionError && - flags.heavyWeightTasks; - - const handleSubmit = () => { - const reading: MeterReading = { - meterId: meterId.trim(), - consumption: Number(consumption), - }; - void submit(reading); - }; - - return ( -
-
-

Anonymous Meter Submission

-

- Your reading is encrypted and proven within range without revealing - the value or meter identity. -

-
- - {!flags.heavyWeightTasks && ( -
- ⚠️ Capacity Shedding Active: Proving tasks are temporarily disabled. range proof generations (Groth16 ZK) are disabled to protect system P99 response times. -
- )} - - {/* Input form */} -
- - -
- - {/* Stepper */} -
    - {STEPS.map((step, i) => { - const done = activeIndex > i || state.status === "confirmed"; - const active = activeIndex === i && busy; - const failed = state.status === "rejected" && i === STEPS.length - 1; - return ( -
  1. - - - {step.label} - -
  2. - ); - })} -
- - {/* Progress bar */} -
-
-
- - {/* Status line */} -
- - {state.status === "idle" - ? "Ready" - : state.status === "confirmed" - ? "Proof verified and submitted." - : state.status === "rejected" - ? state.error ?? "Submission rejected." - : `${STEPS[activeIndex]?.label ?? "Working"}… ${state.progress}%`} - - {state.proofHash && ( - - {state.proofHash.slice(0, 10)}… - - )} -
- - {/* Actions */} -
- {busy ? ( - - ) : ( - - )} - {(state.status === "confirmed" || state.status === "rejected") && ( - - )} -
-
- ); -} - -export default ZKSubmissionPanel; diff --git a/src/components/settings/LanguageSwitcher.tsx b/src/components/settings/LanguageSwitcher.tsx deleted file mode 100644 index afdab0b..0000000 --- a/src/components/settings/LanguageSwitcher.tsx +++ /dev/null @@ -1,38 +0,0 @@ -"use client"; - -import React from "react"; -import { useTranslation } from "@/i18n/useTranslation"; -import { SUPPORTED_LOCALES, Locale } from "@/i18n/config"; - -const FLAGS: Record = { - "en-US": "🇺🇸", - "es-MX": "🇲🇽", - "pt-BR": "🇧🇷", - "fr-FR": "🇫🇷", - "de-DE": "🇩🇪", - "ar-SA": "🇸🇦", - "ja-JP": "🇯🇵", - "zh-CN": "🇨🇳", -}; - -export function LanguageSwitcher() { - const { locale, setLocale, t } = useTranslation(); - - return ( - - ); -} diff --git a/src/components/spatial/DeviceCard.tsx b/src/components/spatial/DeviceCard.tsx deleted file mode 100644 index 32a7a1d..0000000 --- a/src/components/spatial/DeviceCard.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"use client"; - -import { useMemo } from "react"; -import { sanitize, validateLabel } from "@/utils/sanitize"; - -interface DeviceCardProps { - label: string; - location: string; - status: "active" | "inactive" | "alarm"; - metrics?: Record; -} - -export function DeviceCard({ label, location, status, metrics }: DeviceCardProps) { - const safeLabel = useMemo(() => validateLabel(label), [label]); - const safeLocation = useMemo(() => sanitize(location), [location]); - - const statusColor: Record = { - active: "bg-green-500", - inactive: "bg-gray-500", - alarm: "bg-red-500", - }; - - return ( -
-
-

- {safeLabel} -

- -
- {safeLocation && ( -

{safeLocation}

- )} - {metrics && ( -
- {Object.entries(metrics).map(([key, value]) => ( -
- {sanitize(key)} - {sanitize(String(value))} -
- ))} -
- )} -
- ); -} diff --git a/src/components/spatial/Provisioner.tsx b/src/components/spatial/Provisioner.tsx deleted file mode 100644 index dfd9fdd..0000000 --- a/src/components/spatial/Provisioner.tsx +++ /dev/null @@ -1,356 +0,0 @@ -"use client"; - -import { useState, useCallback, useEffect, useRef } from "react"; -import QRCode from "qrcode"; -import { api } from "@/services/api"; - -interface ProvisionPayload { - hardwareId: string; - meterType: string; - latitude: number; - longitude: number; - installDate: string; -} - -interface ProvisionResult { - txHash: string; -} - -export function Provisioner() { - const [step, setStep] = useState(0); - const [payload, setPayload] = useState({ - hardwareId: "", - meterType: "smart-meter-v2", - latitude: 0, - longitude: 0, - installDate: new Date().toISOString().split("T")[0], - }); - const [qrReady, setQrReady] = useState(false); - const [qrRetryCount, setQrRetryCount] = useState(0); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(null); - const [txHash, setTxHash] = useState(""); - const qrCanvasRef = useRef(null); - const containerRef = useRef(null); - - const update = useCallback( - (field: keyof ProvisionPayload, value: string | number) => { - setPayload((prev) => ({ ...prev, [field]: value })); - setError(null); - }, - [] - ); - - // Generate QR code when stepping to the QR view - useEffect(() => { - if (step === 2 && qrCanvasRef.current) { - const data = JSON.stringify({ - hw: payload.hardwareId, - type: payload.meterType, - lat: payload.latitude, - lng: payload.longitude, - ts: payload.installDate, - }); - setQrReady(false); - setError(null); - QRCode.toCanvas(qrCanvasRef.current, data, { - width: 240, - margin: 2, - color: { dark: "#000000", light: "#ffffff" }, - }) - .then(() => { - setQrReady(true); - }) - .catch((err) => { - setQrReady(false); - setError("Failed to generate QR code: " + (err as Error).message); - }); - } - }, [step, payload, qrRetryCount]); - - const goToStep = useCallback((next: number) => { - setError(null); - setStep(next); - }, []); - - const submitProvision = useCallback(async () => { - setSubmitting(true); - setError(null); - try { - const res = await api.post("/api/provision", { - hw: payload.hardwareId, - type: payload.meterType, - lat: payload.latitude, - lng: payload.longitude, - ts: payload.installDate, - }); - if (res.error) { - setError(res.error); - setSubmitting(false); - return; - } - setTxHash(res.data?.txHash ?? ""); - setStep(3); - } catch (err) { - setError((err as Error).message || "Provisioning failed"); - } finally { - setSubmitting(false); - } - }, [payload]); - - // Keyboard navigation: Enter to advance, Escape to go back - useEffect(() => { - const el = containerRef.current; - if (!el) return; - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) { - e.preventDefault(); - if (step === 0 && payload.hardwareId) { - goToStep(1); - } else if (step === 1) { - goToStep(2); - } else if (step === 2 && !submitting) { - void submitProvision(); - } else if (step === 3) { - // Start over on success screen - setStep(0); - setPayload({ - hardwareId: "", - meterType: "smart-meter-v2", - latitude: 0, - longitude: 0, - installDate: new Date().toISOString().split("T")[0], - }); - setQrReady(false); - setQrRetryCount(0); - setTxHash(""); - } - } - if (e.key === "Escape") { - e.preventDefault(); - if (step > 0 && step < 3) { - goToStep(step - 1); - } - } - }; - - el.addEventListener("keydown", handleKeyDown); - return () => el.removeEventListener("keydown", handleKeyDown); - }, [step, payload.hardwareId, submitting, submitProvision, goToStep]); - - if (step === 0) { - return ( -
-

Provision New Meter

-
- - -
- - -
- - {error && ( -

- {error} -

- )} -
-
- ); - } - - if (step === 1) { - return ( -
-

Confirm Details

-
-

Hardware: {payload.hardwareId}

-

Type: {payload.meterType}

-

Location: {payload.latitude}, {payload.longitude}

-

Install Date: {payload.installDate}

-
-
- - -
-
- ); - } - - if (step === 2) { - return ( -
-

Scan QR Code on Device

-
- {error ? ( -
- - - -

{error}

- -
- ) : ( - - )} -
- {qrReady && ( -

- Present this code to the field device to pair. -

- )} -
- - -
-
- ); - } - - return ( -
-

- Provisioned Successfully -

-

- Meter {payload.hardwareId} has been registered on-chain. -

- {txHash && ( -

- TX: {txHash} -

- )} - -
- ); -} diff --git a/src/components/ui/VirtualList.tsx b/src/components/ui/VirtualList.tsx deleted file mode 100644 index 27a3adf..0000000 --- a/src/components/ui/VirtualList.tsx +++ /dev/null @@ -1,198 +0,0 @@ -"use client"; - -import { useCallback, type ReactNode } from "react"; -import { - useVirtualList, - type UseVirtualListOptions, - type VirtualItem, -} from "@/hooks/useVirtualList"; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export interface VirtualListProps { - /** Full dataset to be rendered virtually. */ - items: T[]; - /** - * Render callback for each visible item. Receives the data item, its - * virtual-layout metadata, and a `measureRef` callback that *must* be - * attached to the outermost DOM element of the row so the list can - * measure its real height. - */ - renderItem: ( - item: T, - virtualItem: VirtualItem, - measureRef: (el: HTMLElement | null) => void - ) => ReactNode; - /** - * Derive a stable React key for each item. - * Falls back to the item's array index if omitted. - */ - getItemKey?: (item: T, index: number) => string | number; - /** Height of the scrollable container (px or CSS value). */ - height?: number | string; - /** Optional extra className on the outer container. */ - className?: string; - /** - * Estimated average row height (px). A closer estimate improves initial - * scroll-bar accuracy. - * @default 48 - */ - estimatedItemHeight?: number; - /** - * Extra rows rendered above/below the viewport. - * @default 5 - */ - overscan?: number; - /** - * Session-storage key for scroll-position restoration. - * Omit to disable. - */ - scrollRestorationKey?: string; - /** Async callback invoked near the bottom of the list. */ - onLoadMore?: () => Promise | void; - /** - * Distance (px) from the bottom at which `onLoadMore` fires. - * @default 200 - */ - loadMoreThreshold?: number; - /** Whether a load-more request is in flight. */ - isLoading?: boolean; - /** Rendered at the very bottom while `isLoading` is true. */ - loadingIndicator?: ReactNode; - /** Rendered when `items` is empty and not loading. */ - emptyState?: ReactNode; - /** Accessible label for the list container. */ - ariaLabel?: string; -} - -// --------------------------------------------------------------------------- -// Component -// --------------------------------------------------------------------------- - -export function VirtualList({ - items, - renderItem, - getItemKey, - height = 400, - className, - estimatedItemHeight, - overscan, - scrollRestorationKey, - onLoadMore, - loadMoreThreshold, - isLoading = false, - loadingIndicator, - emptyState, - ariaLabel, -}: VirtualListProps) { - const hookOptions: UseVirtualListOptions = { - items, - estimatedItemHeight, - overscan, - scrollRestorationKey, - onLoadMore, - loadMoreThreshold, - isLoading, - getItemKey, - }; - - const { - containerRef, - innerRef, - virtualItems, - totalHeight, - measureElement, - } = useVirtualList(hookOptions); - - // Factory that produces a stable `measureRef` per row index. - const createMeasureRef = useCallback( - (index: number) => (el: HTMLElement | null) => { - measureElement(index, el); - }, - [measureElement] - ); - - const resolvedHeight = - typeof height === "number" ? `${height}px` : height; - - // Empty state - if (items.length === 0 && !isLoading) { - return ( -
- {emptyState ?? ( -
No items to display
- )} -
- ); - } - - return ( -
-
- {virtualItems.map((vi) => { - const key = getItemKey - ? getItemKey(items[vi.index], vi.index) - : vi.index; - - return ( -
- {renderItem(items[vi.index], vi, createMeasureRef(vi.index))} -
- ); - })} -
- - {isLoading && ( -
- {loadingIndicator ?? ( -
-
- )} -
- )} -
- ); -} - -export default VirtualList; diff --git a/src/hooks/useContainerSize.ts b/src/hooks/useContainerSize.ts deleted file mode 100644 index da4a799..0000000 --- a/src/hooks/useContainerSize.ts +++ /dev/null @@ -1,130 +0,0 @@ -"use client"; - -import { useEffect, useRef, useState, useCallback } from "react"; - -export interface ContainerSize { - /** Container inline width in pixels */ - width: number; - /** Container block height in pixels */ - height: number; - /** Derived state label: compact | medium | expanded */ - containerState: "compact" | "medium" | "expanded"; -} - -interface UseContainerSizeOptions { - /** Thresholds for state classification (px). Defaults to compact < 400 <= medium < 800 <= expanded */ - compactMax?: number; - expandedMin?: number; - /** Debounce delay in ms (default 100) */ - debounceMs?: number; -} - -function classifyState( - width: number, - compactMax: number, - expandedMin: number -): ContainerSize["containerState"] { - if (width < compactMax) return "compact"; - if (width < expandedMin) return "medium"; - return "expanded"; -} - -/** - * Track a container element's dimensions via ResizeObserver and derive a - * container-state label (compact / medium / expanded) suitable for - * imperative chart re-render triggers. - * - * Threshold defaults are read from CSS custom properties defined in - * src/styles/breakpoints.css (e.g. --sidebar-compact-max). When no - * CSS property is available, the fallback values are used. - * - * @param refOrSelector A React ref to a DOM element, or a CSS selector string. - * @param options Thresholds and debounce configuration. - */ -export function useContainerSize( - refOrSelector: React.RefObject | string, - options: UseContainerSizeOptions = {} -): ContainerSize { - const compactMax = - options.compactMax ?? readCssPixelVar("--container-compact-max", 400); - const expandedMin = - options.expandedMin ?? readCssPixelVar("--container-expanded-min", 800); - const debounceMs = options.debounceMs ?? 100; - - // Read CSS custom properties once on mount to avoid - // forcing synchronous style recalculation on every render. - const thresholdsRef = useRef({ compactMax, expandedMin }); - thresholdsRef.current = { compactMax, expandedMin }; - - const [size, setSize] = useState({ - width: 0, - height: 0, - containerState: "compact", - }); - - const timerRef = useRef>(undefined); - - const handleResize = useCallback( - (entry: ResizeObserverEntry) => { - clearTimeout(timerRef.current); - timerRef.current = setTimeout(() => { - const { width, height } = entry.contentRect; - setSize({ - width: Math.floor(width), - height: Math.floor(height), - containerState: classifyState(width, thresholdsRef.current.compactMax, thresholdsRef.current.expandedMin), - }); - }, debounceMs); - }, - [compactMax, expandedMin, debounceMs] - ); - - useEffect(() => { - let el: HTMLElement | null = null; - - if (typeof refOrSelector === "string") { - el = document.querySelector(refOrSelector); - } else { - el = refOrSelector.current; - } - - if (!el) return; - - const observer = new ResizeObserver((entries) => { - for (const entry of entries) { - handleResize(entry); - } - }); - - observer.observe(el); - - // Capture initial size - const rect = el.getBoundingClientRect(); - setSize({ - width: Math.floor(rect.width), - height: Math.floor(rect.height), - containerState: classifyState(rect.width, thresholdsRef.current.compactMax, thresholdsRef.current.expandedMin), - }); - - return () => { - observer.disconnect(); - clearTimeout(timerRef.current); - }; - }, [refOrSelector, handleResize, compactMax, expandedMin]); - - return size; -} - -/** - * Read a CSS custom property from :root and parse it as pixels. - * Falls back to `fallback` if the property is not set or unparseable. - */ -function readCssPixelVar(name: string, fallback: number): number { - if (typeof window === "undefined") return fallback; - const raw = getComputedStyle(document.documentElement) - .getPropertyValue(name) - .trim(); - if (!raw) return fallback; - const num = parseFloat(raw); - return Number.isNaN(num) ? fallback : num; -} diff --git a/src/hooks/useContractEvents.ts b/src/hooks/useContractEvents.ts deleted file mode 100644 index 34c0613..0000000 --- a/src/hooks/useContractEvents.ts +++ /dev/null @@ -1,211 +0,0 @@ -"use client"; - -import { useEffect, useRef, useState, useCallback } from "react"; - -type Network = "testnet" | "futurenet" | "mainnet"; - -interface ContractEvent { - topic: string; - value: string; - contractId: string; - timestamp: number; - block: number; -} - -interface DecodedEvent { - type: string; - severity: "info" | "warning" | "critical"; - message: string; - raw: ContractEvent; -} - -type EventDecoder = (event: ContractEvent) => DecodedEvent; - -const eventDecoders: Record = { - "meter_reading": (e) => ({ - type: "meter_reading", - severity: "info", - message: `Meter reading update: ${e.value}`, - raw: e, - }), - "balance_low": (e) => ({ - type: "balance_low", - severity: "warning", - message: `Low balance alert for contract ${e.contractId.slice(0, 8)}...`, - raw: e, - }), - "tariff_update": (e) => ({ - type: "tariff_update", - severity: "info", - message: `Tariff updated to ${e.value}`, - raw: e, - }), - "unauthorized_access": (e) => ({ - type: "unauthorized_access", - severity: "critical", - message: `Unauthorized access attempt on ${e.contractId.slice(0, 8)}...`, - raw: e, - }), - "device_fault": (e) => ({ - type: "device_fault", - severity: "critical", - message: `Device fault reported: ${e.value}`, - raw: e, - }), - "pricing_update": (e) => ({ - type: "pricing_update", - severity: "info", - message: `Pricing updated to ${e.value}`, - raw: e, - }), -}; - -function decodeBase64XdrString(encoded: string): string { - try { - const bytes = Uint8Array.from(atob(encoded), (c) => c.charCodeAt(0)); - const len = bytes.length; - if (len === 0) return encoded; - let offset = 0; - while (offset < len && bytes[offset] !== 0) { - offset++; - } - const text = new TextDecoder().decode(bytes.slice(0, offset || len)); - return text.replace(/[^\x20-\x7E]/g, "").trim() || encoded; - } catch { - return encoded; - } -} - -function hexToUtf8(hex: string): string { - try { - const bytes: number[] = []; - for (let i = 0; i < hex.length; i += 2) { - bytes.push(parseInt(hex.substring(i, i + 2), 16)); - } - return new TextDecoder().decode(Uint8Array.from(bytes)).replace(/[^\x20-\x7E]/g, "").trim(); - } catch { - return hex; - } -} - -interface SorobanRpcEvent { - id?: string; - topic: string[]; - value: { xdr?: string; data?: string }; - contractId: string; - type?: string; - ledger?: number; - ledgerClosedAt?: string; -} - -function parseRawEvent(raw: unknown): ContractEvent | null { - if (typeof raw !== "object" || raw === null) return null; - const r = raw as SorobanRpcEvent; - - let topicStr = "unknown"; - if (Array.isArray(r.topic) && r.topic.length > 0) { - topicStr = decodeBase64XdrString(r.topic[0]); - if (topicStr === r.topic[0]) { - topicStr = r.topic[0]; - } - } else if (typeof r.topic === "string") { - topicStr = r.topic; - } - - let valueStr = ""; - if (r.value) { - const rawXdr = r.value.xdr || r.value.data; - if (rawXdr) { - valueStr = decodeBase64XdrString(rawXdr); - if (valueStr === rawXdr) { - const hexMatch = rawXdr.match(/[0-9a-fA-F]{2,}/); - if (hexMatch) { - valueStr = hexToUtf8(hexMatch[0]); - } - } - } - } - if (!valueStr) valueStr = JSON.stringify(r.value); - - const contractId = r.contractId || ""; - const block = r.ledger || 0; - const timestamp = r.ledgerClosedAt - ? new Date(r.ledgerClosedAt).getTime() - : Date.now(); - - return { topic: topicStr, value: valueStr, contractId, timestamp, block }; -} - -function decodeEvent(event: ContractEvent): DecodedEvent { - const decoder = eventDecoders[event.topic]; - if (decoder) return decoder(event); - return { - type: "unknown", - severity: "info", - message: `Unhandled event: ${event.topic}`, - raw: event, - }; -} - -const NETWORK_URLS: Record = { - testnet: "wss://testnet.sorobanrpc.com", - futurenet: "wss://futurenet.sorobanrpc.com", - mainnet: "wss://mainnet.sorobanrpc.com", -}; - -export function useContractEvents(contractIds: string[], network: Network = "testnet") { - const [events, setEvents] = useState([]); - const [latestBlock, setLatestBlock] = useState(0); - const wsRef = useRef(null); - const reconnectTimer = useRef | null>(null); - - const connect = useCallback(() => { - const baseUrl = NETWORK_URLS[network]; - const ws = new WebSocket( - `${baseUrl}?stream=events&contracts=${contractIds.join(",")}` - ); - wsRef.current = ws; - - ws.onmessage = (msg) => { - try { - const parsed = JSON.parse(msg.data); - const rawEvents = Array.isArray(parsed) ? parsed : [parsed]; - let latest = 0; - const decodedBatch: DecodedEvent[] = []; - - for (const raw of rawEvents) { - const contractEvent = parseRawEvent(raw); - if (!contractEvent) continue; - if (contractEvent.block > latest) latest = contractEvent.block; - decodedBatch.push(decodeEvent(contractEvent)); - } - - if (latest > 0) setLatestBlock(latest); - if (decodedBatch.length > 0) { - setEvents((prev) => [...decodedBatch, ...prev].slice(0, 200)); - } - } catch { - // skip malformed messages - } - }; - - ws.onclose = () => { - reconnectTimer.current = setTimeout(connect, 3000); - }; - - ws.onerror = () => { - ws.close(); - }; - }, [contractIds, network]); - - useEffect(() => { - if (contractIds.length === 0) return; - connect(); - return () => { - if (wsRef.current) wsRef.current.close(); - if (reconnectTimer.current) clearTimeout(reconnectTimer.current); - }; - }, [connect, contractIds]); - - return { events, latestBlock }; -} diff --git a/src/hooks/useContractState.ts b/src/hooks/useContractState.ts deleted file mode 100644 index beba195..0000000 --- a/src/hooks/useContractState.ts +++ /dev/null @@ -1,154 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useRef } from "react"; -import { LamportClock } from "@/utils/lamportClock"; -import { mergeEvents } from "@/utils/crdtMerge"; -import { - crdtStore, - runStalenessWatch, - useCrdtState, - type CrdtState, -} from "@/store/slices/crdtSlice"; -import { - RECONCILE_TIMEOUT_MS, - type ChainId, - type CrdtEvent, -} from "@/types/crdt"; -import type { - CrdtMergeRequest, - CrdtMergeResponse, -} from "@/workers/crdtMerge.worker"; - -/** - * Subscribes the dashboard to multi-chain Soroban events. Each ingested event - * is stamped with the originating chain's Lamport clock, batched, and merged - * (off-thread when a worker is available, otherwise inline). Merged diffs are - * applied to {@link crdtStore}, and a staleness watch flags any chain that has - * gone quiet past the reconcile timeout. - */ - -/** An event before it is Lamport-stamped (the clock supplies the timestamp). */ -export type RawCrdtEvent = Omit; - -export interface UseContractStateDeps { - createWorker?: () => Worker; - now?: () => number; - /** How often to flush the event batch (ms). @default 16 */ - flushIntervalMs?: number; - /** How often to run the staleness watch (ms). @default 5000 */ - stalenessIntervalMs?: number; - reconcileTimeoutMs?: number; - /** Called when a chain must be fully re-fetched. */ - onReconcile?: (chainId: ChainId) => void; -} - -export interface UseContractStateResult { - state: CrdtState; - /** Stamp and enqueue an event from a chain. */ - ingest: (chainId: ChainId, event: RawCrdtEvent) => void; - reconciling: ChainId[]; -} - -export function useContractState( - deps: UseContractStateDeps = {} -): UseContractStateResult { - const state = useCrdtState(); - - const clocksRef = useRef>(new Map()); - const batchRef = useRef([]); - const workerRef = useRef(null); - const batchSeqRef = useRef(0); - const depsRef = useRef(deps); - depsRef.current = deps; - - const clockFor = useCallback((chainId: ChainId): LamportClock => { - let clock = clocksRef.current.get(chainId); - if (!clock) { - clock = new LamportClock(chainId); - clocksRef.current.set(chainId, clock); - } - return clock; - }, []); - - const ingest = useCallback( - (chainId: ChainId, event: RawCrdtEvent) => { - const timestamp = clockFor(chainId).tick(); - batchRef.current.push({ ...event, timestamp } as CrdtEvent); - }, - [clockFor] - ); - - // Flush + merge loop, worker spawn, and staleness watch. - useEffect(() => { - const now = depsRef.current.now ?? Date.now; - const flushMs = depsRef.current.flushIntervalMs ?? 16; - const staleMs = depsRef.current.stalenessIntervalMs ?? 5000; - const reconcileTimeout = - depsRef.current.reconcileTimeoutMs ?? RECONCILE_TIMEOUT_MS; - - // Try to run the merge off the main thread. - const createWorker = depsRef.current.createWorker; - if (createWorker) { - try { - const worker = createWorker(); - worker.onmessage = (e: MessageEvent) => { - const { diffs, chainSeen } = e.data; - crdtStore.dispatch({ - type: "APPLY_DIFFS", - payload: { diffs, chainSeen, at: now() }, - }); - }; - workerRef.current = worker; - } catch { - workerRef.current = null; - } - } - - const flush = () => { - if (batchRef.current.length === 0) return; - const events = batchRef.current; - batchRef.current = []; - - const worker = workerRef.current; - if (worker) { - const request: CrdtMergeRequest = { - type: "merge", - batchId: ++batchSeqRef.current, - events, - }; - worker.postMessage(request); - } else { - // Inline fallback: merge against the current store state. - const snapshot = crdtStore.getState(); - const { diffs, chainSeen } = mergeEvents( - snapshot.resources, - snapshot.vectorClocks, - events - ); - crdtStore.dispatch({ - type: "APPLY_DIFFS", - payload: { diffs, chainSeen, at: now() }, - }); - } - }; - - const flushTimer = setInterval(flush, flushMs); - const staleTimer = setInterval(() => { - const flagged = runStalenessWatch(now(), reconcileTimeout); - for (const chainId of flagged) depsRef.current.onReconcile?.(chainId); - }, staleMs); - - return () => { - clearInterval(flushTimer); - clearInterval(staleTimer); - const worker = workerRef.current; - if (worker) { - worker.postMessage({ type: "reset" } satisfies CrdtMergeRequest); - worker.terminate(); - workerRef.current = null; - } - }; - }, []); - - return { state, ingest, reconciling: state.reconciling }; -} diff --git a/src/hooks/useEscrow.ts b/src/hooks/useEscrow.ts deleted file mode 100644 index 1b0dc81..0000000 --- a/src/hooks/useEscrow.ts +++ /dev/null @@ -1,146 +0,0 @@ -"use client"; - -import { useState, useCallback, useRef } from "react"; - -type MutationStatus = "idle" | "pending" | "confirmed" | "failed"; - -interface OptimisticMutation { - id: string; - data: TData; - status: MutationStatus; - error: string | null; - submittedAt: number; - confirmedAt: number | null; -} - -interface UseEscrowReturn { - mutations: OptimisticMutation[]; - submit: (data: TData) => Promise; - rollback: (mutationId: string) => void; - retry: (mutationId: string) => Promise; - pendingCount: number; -} - -let mutationCounter = 0; - -export function useEscrow( - onChainSubmit: (data: TData) => Promise, - onRollback: (data: TData) => Promise -): UseEscrowReturn { - const [mutations, setMutations] = useState[]>([]); - const activeMutationsRef = useRef>(new Set()); - - const submit = useCallback( - async (data: TData) => { - const id = `mutation-${++mutationCounter}`; - const mutation: OptimisticMutation = { - id, - data, - status: "pending", - error: null, - submittedAt: Date.now(), - confirmedAt: null, - }; - - setMutations((prev) => [mutation, ...prev].slice(0, 50)); - activeMutationsRef.current.add(id); - - try { - await onChainSubmit(data); - if (!activeMutationsRef.current.has(id)) return; - activeMutationsRef.current.delete(id); - setMutations((prev) => - prev.map((m) => - m.id === id - ? { ...m, status: "confirmed", confirmedAt: Date.now() } - : m - ) - ); - } catch (error) { - if (!activeMutationsRef.current.has(id)) return; - activeMutationsRef.current.delete(id); - await onRollback(data); - setMutations((prev) => - prev.map((m) => - m.id === id - ? { ...m, status: "failed", error: (error as Error).message } - : m - ) - ); - } - }, - [onChainSubmit, onRollback] - ); - - const rollback = useCallback( - (mutationId: string) => { - if (!activeMutationsRef.current.has(mutationId)) return; - - const target = mutations.find((m) => m.id === mutationId); - if (!target) return; - - activeMutationsRef.current.delete(mutationId); - - onRollback(target.data).catch((err) => { - console.error("Failed to execute onRollback side effect:", err); - }); - - setMutations((prev) => - prev.map((m) => - m.id === mutationId ? { ...m, status: "idle", error: "Rolled back" } : m - ) - ); - }, - [mutations, onRollback] - ); - - const retry = useCallback( - async (mutationId: string) => { - const target = mutations.find((m) => m.id === mutationId); - if (!target || target.status !== "failed") return; - - setMutations((prev) => - prev.map((m) => - m.id === mutationId - ? { ...m, status: "pending", error: null, submittedAt: Date.now() } - : m - ) - ); - - activeMutationsRef.current.add(mutationId); - - try { - await onChainSubmit(target.data); - if (!activeMutationsRef.current.has(mutationId)) return; - activeMutationsRef.current.delete(mutationId); - setMutations((prev) => - prev.map((m) => - m.id === mutationId - ? { ...m, status: "confirmed", confirmedAt: Date.now() } - : m - ) - ); - } catch (error) { - if (!activeMutationsRef.current.has(mutationId)) return; - activeMutationsRef.current.delete(mutationId); - await onRollback(target.data); - setMutations((prev) => - prev.map((m) => - m.id === mutationId - ? { ...m, status: "failed", error: (error as Error).message } - : m - ) - ); - } - }, - [mutations, onChainSubmit, onRollback] - ); - - return { - mutations, - submit, - rollback, - retry, - pendingCount: mutations.filter((m) => m.status === "pending").length, - }; -} diff --git a/src/hooks/useGeoLocation.ts b/src/hooks/useGeoLocation.ts deleted file mode 100644 index b41d2e8..0000000 --- a/src/hooks/useGeoLocation.ts +++ /dev/null @@ -1,64 +0,0 @@ -"use client"; - -import { useEffect, useRef, useState } from "react"; -import type { GeoSample } from "@/types/tile"; - -/** - * Watches `navigator.geolocation` (≈1 Hz) and exposes the latest GPS sample with - * heading and speed — the inputs the prefetch scheduler uses to predict the - * operator's viewport trajectory. - */ - -export interface UseGeoLocationOptions { - enabled?: boolean; - /** Injectable geolocation source (tests). */ - geolocation?: Pick; - positionOptions?: PositionOptions; -} - -export interface UseGeoLocationResult { - sample: GeoSample | null; - error: string | null; - supported: boolean; -} - -/** Map a browser GeolocationPosition into our GeoSample. */ -export function toGeoSample(position: GeolocationPosition): GeoSample { - const { coords, timestamp } = position; - return { - lng: coords.longitude, - lat: coords.latitude, - heading: Number.isFinite(coords.heading) ? coords.heading : null, - speed: Number.isFinite(coords.speed) ? coords.speed : null, - timestamp, - }; -} - -export function useGeoLocation( - options: UseGeoLocationOptions = {} -): UseGeoLocationResult { - const { enabled = true } = options; - const geo = - options.geolocation ?? - (typeof navigator !== "undefined" ? navigator.geolocation : undefined); - - const [sample, setSample] = useState(null); - const [error, setError] = useState(null); - const optsRef = useRef(options.positionOptions); - optsRef.current = options.positionOptions; - - useEffect(() => { - if (!enabled || !geo) return; - const watchId = geo.watchPosition( - (position) => { - setSample(toGeoSample(position)); - setError(null); - }, - (err) => setError(err.message), - optsRef.current ?? { enableHighAccuracy: true, maximumAge: 1000, timeout: 5000 } - ); - return () => geo.clearWatch(watchId); - }, [enabled, geo]); - - return { sample, error, supported: !!geo }; -} diff --git a/src/hooks/useKeyboardNavigation.ts b/src/hooks/useKeyboardNavigation.ts deleted file mode 100644 index 2dc0505..0000000 --- a/src/hooks/useKeyboardNavigation.ts +++ /dev/null @@ -1,135 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useRef } from "react"; - -export type KeyAction = - | { type: "arrowUp" } - | { type: "arrowDown" } - | { type: "arrowLeft" } - | { type: "arrowRight" } - | { type: "enter" } - | { type: "space" } - | { type: "escape" } - | { type: "home" } - | { type: "end" } - | { type: "tab"; shiftKey: boolean } - | { type: "character"; key: string }; - -export interface KeyboardNavigationOptions { - onAction: (action: KeyAction) => void; - enabled?: boolean; - preventDefault?: boolean; -} - -const KEY_MAP: Record KeyAction | null> = { - ArrowUp: () => ({ type: "arrowUp" }), - ArrowDown: () => ({ type: "arrowDown" }), - ArrowLeft: () => ({ type: "arrowLeft" }), - ArrowRight: () => ({ type: "arrowRight" }), - Enter: () => ({ type: "enter" }), - " ": () => ({ type: "space" }), - Escape: () => ({ type: "escape" }), - Home: () => ({ type: "home" }), - End: () => ({ type: "end" }), - Tab: (e) => ({ type: "tab", shiftKey: e.shiftKey }), -}; - -export function useKeyboardNavigation( - options: KeyboardNavigationOptions -) { - const { onAction, enabled = true, preventDefault: shouldPreventDefault = true } = options; - const handlerRef = useRef(onAction); - handlerRef.current = onAction; - - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - if (!enabled) return; - const mapper = KEY_MAP[e.key]; - if (!mapper) { - if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { - if (shouldPreventDefault) e.preventDefault(); - handlerRef.current({ type: "character", key: e.key }); - } - return; - } - const action = mapper(e); - if (action) { - if (shouldPreventDefault) e.preventDefault(); - handlerRef.current(action); - } - }, - [enabled, shouldPreventDefault] - ); - - useEffect(() => { - if (!enabled) return; - const element = typeof window !== "undefined" ? window : null; - if (!element) return; - element.addEventListener("keydown", handleKeyDown); - return () => element.removeEventListener("keydown", handleKeyDown); - }, [enabled, handleKeyDown]); -} - -export interface RovingTabIndexOptions { - items: Array<{ id: string; disabled?: boolean }>; - currentIndex: number; - onChange: (index: number) => void; - orientation?: "horizontal" | "vertical"; - loop?: boolean; -} - -export function useRovingTabIndex(options: RovingTabIndexOptions) { - const { items, currentIndex, onChange, orientation = "vertical", loop = true } = options; - - const moveIndex = useCallback( - (direction: number) => { - const enabled = items.filter((item) => !item.disabled); - if (enabled.length === 0) return; - const currentEnabled = enabled.findIndex( - (item) => item.id === items[currentIndex]?.id - ); - let next = currentEnabled + direction; - if (loop) { - next = ((next % enabled.length) + enabled.length) % enabled.length; - } else { - next = Math.max(0, Math.min(next, enabled.length - 1)); - } - const target = items.findIndex((item) => item.id === enabled[next]?.id); - if (target !== -1 && target !== currentIndex) { - onChange(target); - } - }, - [items, currentIndex, onChange, loop] - ); - - useKeyboardNavigation({ - onAction: (action) => { - switch (action.type) { - case "arrowDown": - case "arrowRight": - if (orientation === "vertical" && action.type === "arrowRight") return; - if (orientation === "horizontal" && action.type === "arrowDown") return; - moveIndex(1); - break; - case "arrowUp": - case "arrowLeft": - if (orientation === "vertical" && action.type === "arrowLeft") return; - if (orientation === "horizontal" && action.type === "arrowUp") return; - moveIndex(-1); - break; - case "home": - onChange(0); - break; - case "end": - onChange(items.length - 1); - break; - } - }, - enabled: items.length > 0, - }); - - return { - getTabIndex: (index: number) => (index === currentIndex ? 0 : -1), - getRole: () => (orientation === "horizontal" ? "toolbar" : "listbox"), - }; -} diff --git a/src/hooks/useLocale.ts b/src/hooks/useLocale.ts deleted file mode 100644 index b5b232b..0000000 --- a/src/hooks/useLocale.ts +++ /dev/null @@ -1,33 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { DEFAULT_LOCALE, Locale, SUPPORTED_LOCALES } from "@/i18n/config"; - -export function useLocale() { - const [locale, setLocaleState] = useState(() => { - if (typeof window === "undefined") return DEFAULT_LOCALE; - return (window.localStorage.getItem("locale") as Locale) || DEFAULT_LOCALE; - }); - - useEffect(() => { - const onStorage = (e: StorageEvent) => { - if (e.key === "locale") { - setLocaleState((e.newValue as Locale) || DEFAULT_LOCALE); - } - }; - window.addEventListener("storage", onStorage); - return () => window.removeEventListener("storage", onStorage); - }, []); - - const setLocale = (l: Locale) => { - if (!SUPPORTED_LOCALES.includes(l)) { - console.warn("unsupported locale", l); - return; - } - window.localStorage.setItem("locale", l); - setLocaleState(l); - window.dispatchEvent(new CustomEvent("localeChange", { detail: l })); - }; - - return { locale, setLocale } as const; -} diff --git a/src/hooks/useMapCameraSync.ts b/src/hooks/useMapCameraSync.ts deleted file mode 100644 index fb6f4b6..0000000 --- a/src/hooks/useMapCameraSync.ts +++ /dev/null @@ -1,133 +0,0 @@ -"use client"; - -import { useEffect, useRef } from "react"; -import { buildViewProjection, mercatorMeters, type Mat4 } from "@/utils/cameraMatrix"; -import type { CameraState } from "@/types/spatial"; - -/** - * Synchronizes a Three.js overlay camera with the Mapbox camera every animation - * frame. It reads `map.transform` (center, elevation, bearing, pitch), maps the - * mercator center into local world meters, and builds a view-projection matrix - * that the frustum-culling layer consumes. - * - * The Mapbox dependency is expressed via a minimal structural interface so the - * hook can be driven with a fake map and a fake rAF in tests. - */ - -export interface MapTransformLike { - _center?: { lng: number; lat: number }; - center?: { lng: number; lat: number }; - _elevation?: number; - elevation?: number; - /** Bearing / heading, degrees. */ - bearing: number; - /** Pitch, degrees. */ - pitch: number; - width: number; - height: number; -} - -export interface MapLike { - transform: MapTransformLike; -} - -export interface UseMapCameraSyncOptions { - map: MapLike | null; - /** Receives the camera state and column-major view-projection each frame. */ - onSync: (camera: CameraState, viewProjection: Mat4) => void; - enabled?: boolean; - /** Injectable rAF (tests). */ - raf?: (cb: FrameRequestCallback) => number; - /** Injectable cancel (tests). */ - cancelRaf?: (handle: number) => void; -} - -function centerOf(t: MapTransformLike): { lng: number; lat: number } { - return t._center ?? t.center ?? { lng: 0, lat: 0 }; -} - -function elevationOf(t: MapTransformLike): number { - return t._elevation ?? t.elevation ?? 0; -} - -/** Pure: derive the camera state from a transform, relative to a world origin. */ -export function readCameraState( - t: MapTransformLike, - origin: { lng: number; lat: number } -): CameraState { - const center = centerOf(t); - const altitude = elevationOf(t); - const { x, y } = mercatorMeters(center.lng, center.lat, origin); - return { - position: { x, y, z: altitude }, - longitude: center.lng, - latitude: center.lat, - altitude, - heading: t.bearing, - pitch: t.pitch, - }; -} - -/** Pure: build the view-projection for a transform relative to an origin. */ -export function viewProjectionForTransform( - t: MapTransformLike, - origin: { lng: number; lat: number } -): { camera: CameraState; viewProjection: Mat4 } { - const camera = readCameraState(t, origin); - const aspect = t.height > 0 ? t.width / t.height : 1; - const viewProjection = buildViewProjection({ - position: camera.position, - heading: camera.heading, - pitch: camera.pitch, - aspect, - }); - return { camera, viewProjection }; -} - -export function useMapCameraSync(options: UseMapCameraSyncOptions): void { - const { map, onSync, enabled = true } = options; - - const onSyncRef = useRef(onSync); - onSyncRef.current = onSync; - /** World origin (lng/lat of the first observed center), fixed for the session. */ - const originRef = useRef<{ lng: number; lat: number } | null>(null); - - useEffect(() => { - if (!enabled || !map) return; - const raf = - options.raf ?? - (typeof requestAnimationFrame !== "undefined" - ? requestAnimationFrame - : null); - const cancel = - options.cancelRaf ?? - (typeof cancelAnimationFrame !== "undefined" ? cancelAnimationFrame : null); - if (!raf) return; - - let handle = 0; - let stopped = false; - - const frame = () => { - if (stopped) return; - const t = map.transform; - if (t) { - if (originRef.current === null) { - originRef.current = centerOf(t); - } - const { camera, viewProjection } = viewProjectionForTransform( - t, - originRef.current - ); - onSyncRef.current(camera, viewProjection); - } - handle = raf(frame); - }; - - handle = raf(frame); - return () => { - stopped = true; - if (cancel) cancel(handle); - }; - // raf/cancelRaf are read once at effect start; map/enabled drive re-runs. - }, [map, enabled, options.raf, options.cancelRaf]); -} diff --git a/src/hooks/useMapViewport.ts b/src/hooks/useMapViewport.ts deleted file mode 100644 index 6c60950..0000000 --- a/src/hooks/useMapViewport.ts +++ /dev/null @@ -1,49 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import type { Viewport } from "@/types/tile"; - -/** - * Exposes the Mapbox camera (center, zoom, bearing, pitch) as reactive state, - * updated on every `move`. The map is a minimal structural interface so the hook - * has no hard `mapbox-gl` dependency. - */ - -export interface MapViewportSource { - getCenter(): { lng: number; lat: number }; - getZoom(): number; - getBearing(): number; - getPitch(): number; - on(type: "move", listener: () => void): void; - off(type: "move", listener: () => void): void; -} - -export function readViewport(map: MapViewportSource): Viewport { - const center = map.getCenter(); - return { - lng: center.lng, - lat: center.lat, - zoom: map.getZoom(), - bearing: map.getBearing(), - pitch: map.getPitch(), - }; -} - -export function useMapViewport(map: MapViewportSource | null): Viewport | null { - const [viewport, setViewport] = useState( - map ? readViewport(map) : null - ); - - useEffect(() => { - if (!map) { - setViewport(null); - return; - } - const update = () => setViewport(readViewport(map)); - update(); - map.on("move", update); - return () => map.off("move", update); - }, [map]); - - return viewport; -} diff --git a/src/hooks/useNetworkTopology.ts b/src/hooks/useNetworkTopology.ts deleted file mode 100644 index 5741e85..0000000 --- a/src/hooks/useNetworkTopology.ts +++ /dev/null @@ -1,108 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useState } from "react"; -import type { - FlowDirection, - LngLat, - LoadStatus, - NetworkEdge, -} from "@/types/network"; - -/** - * Fetches the utility-network topology as GeoJSON from the REST endpoint and - * parses LineString features into {@link NetworkEdge}s (flow direction / load - * status from feature properties). - */ - -interface GeoJsonLineFeature { - type: "Feature"; - geometry: { type: "LineString"; coordinates: LngLat[] }; - properties?: { - id?: string; - flowDirection?: FlowDirection; - loadStatus?: LoadStatus; - } | null; -} - -interface GeoJsonFeatureCollection { - type: "FeatureCollection"; - features: GeoJsonLineFeature[]; -} - -const VALID_FLOW: FlowDirection[] = ["forward", "reverse", "bidirectional"]; -const VALID_LOAD: LoadStatus[] = ["nominal", "overloaded", "idle"]; - -/** Parse a GeoJSON FeatureCollection of LineStrings into network edges. */ -export function parseGeoJsonNetwork( - fc: GeoJsonFeatureCollection -): NetworkEdge[] { - const edges: NetworkEdge[] = []; - fc.features.forEach((feature, i) => { - if (feature.geometry?.type !== "LineString") return; - const coords = feature.geometry.coordinates; - if (!Array.isArray(coords) || coords.length < 2) return; - - const props = feature.properties ?? {}; - const flowDirection = VALID_FLOW.includes(props.flowDirection as FlowDirection) - ? (props.flowDirection as FlowDirection) - : "bidirectional"; - const loadStatus = VALID_LOAD.includes(props.loadStatus as LoadStatus) - ? (props.loadStatus as LoadStatus) - : "nominal"; - - edges.push({ - id: props.id ?? `edge-${i}`, - geometry: coords, - flowDirection, - loadStatus, - }); - }); - return edges; -} - -export interface UseNetworkTopologyOptions { - endpoint?: string; - fetchFn?: typeof fetch; - enabled?: boolean; -} - -export interface UseNetworkTopologyResult { - edges: NetworkEdge[]; - loading: boolean; - error: string | null; - refetch: () => void; -} - -export function useNetworkTopology( - options: UseNetworkTopologyOptions = {} -): UseNetworkTopologyResult { - const { endpoint = "/api/network/topology", fetchFn = fetch, enabled = true } = - options; - const [edges, setEdges] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const load = useCallback(async () => { - setLoading(true); - setError(null); - try { - const res = await fetchFn(endpoint, { - headers: { Accept: "application/geo+json, application/json" }, - }); - if (!res.ok) throw new Error(`Topology request failed: HTTP ${res.status}`); - const fc = (await res.json()) as GeoJsonFeatureCollection; - setEdges(parseGeoJsonNetwork(fc)); - } catch (err) { - setError((err as Error).message); - } finally { - setLoading(false); - } - }, [endpoint, fetchFn]); - - useEffect(() => { - if (!enabled) return; - void load(); - }, [enabled, load]); - - return { edges, loading, error, refetch: load }; -} diff --git a/src/hooks/useNonceManager.ts b/src/hooks/useNonceManager.ts deleted file mode 100644 index d49164f..0000000 --- a/src/hooks/useNonceManager.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { useSyncExternalStore, useCallback } from "react"; -import { nonceStore } from "@/store/slices/nonceSlice"; -import { nonceManager } from "@/services/nonceManager"; -import { useWallet } from "@/components/providers/WalletProvider"; - -export function useNonceManager() { - const { account } = useWallet(); - - const state = useSyncExternalStore( - (listener) => nonceStore.subscribe(listener), - () => nonceStore.getState(), - () => nonceStore.getState() - ); - - const accountId = account?.address; - const accountState = accountId ? state[accountId] : null; - - const acquireNonce = useCallback(async () => { - if (!accountId) throw new Error("Wallet not connected"); - return await nonceManager.acquireLease(accountId, account.network); - }, [accountId, account?.network]); - - const releaseNonce = useCallback( - (leaseId: string, nonce: string) => { - if (!accountId) return; - nonceManager.releaseLease(accountId, leaseId, nonce); - }, - [accountId] - ); - - return { - status: accountState?.lastError ? "error" : "ok", - pendingCount: accountState?.pendingCount || 0, - poolSize: accountState?.poolSize || 0, - baseSequence: accountState?.baseSequence || "0", - lastError: accountState?.lastError || null, - acquireNonce, - releaseNonce, - }; -} diff --git a/src/hooks/useOfflineSync.ts b/src/hooks/useOfflineSync.ts deleted file mode 100644 index 565782d..0000000 --- a/src/hooks/useOfflineSync.ts +++ /dev/null @@ -1,283 +0,0 @@ -"use client"; - -import { useState, useEffect, useCallback, useRef } from "react"; -import { SyncMesh } from "@/services/webrtc/SyncMesh"; -import { SignalingClient } from "@/services/webrtc/SignalingClient"; -import type { SignalingEvent } from "@/services/webrtc/SignalingClient"; -import { syncStore } from "@/store/syncStore"; -import type { SyncEntry } from "@/services/webrtc/SyncPeer"; -import { VectorClock } from "@/utils/vectorClock"; -import { QRDiscovery } from "@/services/webrtc/QRDiscovery"; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export interface OfflineSyncStatus { - peers: string[]; - outboxCount: number; - lastSync: number; - signalingConnected: boolean; - mode: "online" | "offline-p2p" | "offline-qr" | "idle"; -} - -export interface UseOfflineSyncOptions { - signalingUrl?: string; - roomId?: string; - peerId?: string; -} - -const DEFAULT_SIGNALING_URL = "wss://signal.utilityprotocol.com"; - -// --------------------------------------------------------------------------- -// Hook -// --------------------------------------------------------------------------- - -export function useOfflineSync( - options: UseOfflineSyncOptions = {} -): { - status: OfflineSyncStatus; - sync: (entries: SyncEntry[]) => void; - connect: () => void; - disconnect: () => void; - /** Generate a QR data URL for manual SDP offer exchange (offline fallback). */ - generateQR: () => Promise; - /** Process a QR payload scanned from another peer's display. */ - processQRPayload: (qrJson: string) => Promise; -} { - const peerId = options.peerId ?? generatePeerId(); - - const meshRef = useRef(null); - const signalingRef = useRef(null); - - const [status, setStatus] = useState({ - peers: [], - outboxCount: 0, - lastSync: 0, - signalingConnected: false, - mode: "idle", - }); - - // Subscribe to sync store changes - useEffect(() => { - const unsub = syncStore.subscribe((state) => { - setStatus((prev) => ({ - ...prev, - peers: state.peers.filter((p) => p.connected).map((p) => p.id), - outboxCount: state.outbox.length, - lastSync: - state.peers.length > 0 - ? Math.max(...state.peers.map((p) => p.lastSync)) - : prev.lastSync, - })); - }); - return unsub; - }, []); - - const ensureMesh = useCallback((): SyncMesh => { - if (!meshRef.current) { - meshRef.current = new SyncMesh(peerId); - meshRef.current.onEvent((event) => { - switch (event.type) { - case "sync-received": { - for (const entry of event.entries) { - syncStore.enqueue(entry); - } - const incomingClock = new VectorClock(); - for (const entry of event.entries) { - for (const [pid, count] of Object.entries(entry.vectorClock)) { - const existing = incomingClock.get(pid); - if (count > existing) incomingClock.tick(pid); - } - } - syncStore.upsertPeer({ - id: event.fromPeerId, - connected: true, - latency: 0, - lastSync: Date.now(), - vectorClock: incomingClock, - }); - syncStore.dequeue( - event.entries.map((e) => e.id) - ); - setStatus((prev) => ({ ...prev, lastSync: Date.now() })); - break; - } - case "peer-joined": - syncStore.upsertPeer({ - id: event.peerId, - connected: true, - latency: 0, - lastSync: Date.now(), - vectorClock: new VectorClock(), - }); - break; - case "peer-left": - syncStore.removePeer(event.peerId); - break; - // Relay SDP answers and ICE candidates to signaling - case "sdp-answer": - signalingRef.current?.sendAnswer(event.fromPeerId, event.sdp); - break; - case "sdp-offer": - signalingRef.current?.sendOffer(event.fromPeerId, event.sdp); - break; - case "ice-candidate": - signalingRef.current?.sendIceCandidate( - event.fromPeerId, - event.candidate - ); - break; - } - }); - } - return meshRef.current; - }, [peerId]); - - const connect = useCallback(() => { - ensureMesh(); - - if (!signalingRef.current) { - const signaling = new SignalingClient({ - serverUrl: options.signalingUrl ?? DEFAULT_SIGNALING_URL, - roomId: options.roomId ?? "default", - peerId, - }); - - signaling.onEvent((event: SignalingEvent) => { - const mesh = meshRef.current; - if (!mesh) return; - - switch (event.type) { - case "connected": - setStatus((prev) => ({ - ...prev, - signalingConnected: true, - mode: "online", - })); - break; - case "offer": - mesh - .join(event.from, event.sdp) - .catch((err: unknown) => { - console.error("Failed to handle offer:", err); - }); - break; - case "answer": - mesh.handleAnswer(event.from, event.sdp); - break; - case "ice-candidate": - mesh.handleIceCandidate(event.from, event.candidate); - break; - case "peer-joined": - // Another peer joined — wait for their offer; do NOT become initiator - setStatus((prev) => ({ ...prev, mode: "online" })); - break; - case "peer-left": - mesh.leave(event.peerId); - break; - case "disconnected": - setStatus((prev) => ({ - ...prev, - signalingConnected: false, - mode: "offline-p2p", - })); - break; - } - }); - - signaling.connect(); - signalingRef.current = signaling; - } - }, [peerId, options.signalingUrl, options.roomId, ensureMesh]); - - const disconnect = useCallback(() => { - signalingRef.current?.disconnect(); - signalingRef.current = null; - meshRef.current?.destroy(); - meshRef.current = null; - syncStore.reset(); - setStatus({ - peers: [], - outboxCount: 0, - lastSync: 0, - signalingConnected: false, - mode: "idle", - }); - }, []); - - const sync = useCallback( - (entries: SyncEntry[]) => { - for (const entry of entries) { - syncStore.enqueue(entry); - } - - const ourClock = syncStore.getState().ourClock; - const clocked = entries.map((entry) => ({ - ...entry, - vectorClock: ourClock.clone().toJSON(), - })); - - meshRef.current?.broadcast(clocked); - }, - [] - ); - - // ------------------------------------------------------------------ - // QR fallback discovery - // ------------------------------------------------------------------ - - /** - * Generate a QR code data URL containing our SDP offer. - * Used when signaling is unavailable (offline mode). - */ - const generateQR = useCallback(async (): Promise => { - setStatus((prev) => ({ ...prev, mode: "offline-qr" })); - - // Create a temporary offer for QR exchange - try { - // Trigger connection which will emit sdp-offer via MeshEvent - const mesh = ensureMesh(); - const tempPeerId = `qr-peer-${Date.now().toString(36)}`; - await mesh.join(tempPeerId); - - // The SDP offer will be emitted via mesh event. - // For QR generation, we need to capture it. - // Since SyncMesh.join() for initiator triggers connect() which - // creates the offer, and the SDP is already emitted. - // For simplicity, return a placeholder and let the caller - // handle the actual QR generation from the emitted event. - return null; - } catch { - return null; - } - }, [ensureMesh]); - - /** - * Process a QR payload scanned from another peer's display. - * This initiates a WebRTC connection using the encoded SDP. - */ - const processQRPayload = useCallback( - async (qrJson: string): Promise => { - const payload = QRDiscovery.decodeQRPayload(qrJson); - if (!payload) return; - - const mesh = ensureMesh(); - - if (payload.type === "sdp-offer") { - await mesh.join(payload.peerId, payload.sdp); - setStatus((prev) => ({ ...prev, mode: "offline-qr" })); - } else if (payload.type === "sdp-answer") { - await mesh.handleAnswer(payload.peerId, payload.sdp); - setStatus((prev) => ({ ...prev, mode: "offline-qr" })); - } - }, - [ensureMesh] - ); - - return { status, sync, connect, disconnect, generateQR, processQRPayload }; -} - -function generatePeerId(): string { - return `peer-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; -} diff --git a/src/hooks/useOnlineStatus.ts b/src/hooks/useOnlineStatus.ts deleted file mode 100644 index fb2dbe0..0000000 --- a/src/hooks/useOnlineStatus.ts +++ /dev/null @@ -1,45 +0,0 @@ -"use client"; - -import { useState, useEffect, useCallback } from "react"; -import { replayOfflineQueue, getOfflineQueueLength } from "@/services/cache"; - -interface OnlineStatus { - isOnline: boolean; - pendingCount: number; -} - -export function useOnlineStatus(): OnlineStatus { - const [isOnline, setIsOnline] = useState( - typeof navigator !== "undefined" ? navigator.onLine : true - ); - const [pendingCount, setPendingCount] = useState(0); - - const handleOnline = useCallback(() => { - setIsOnline(true); - void replayOfflineQueue(); - void getOfflineQueueLength().then(setPendingCount); - }, []); - - const handleOffline = useCallback(() => { - setIsOnline(false); - }, []); - - useEffect(() => { - void getOfflineQueueLength().then(setPendingCount); - - window.addEventListener("online", handleOnline); - window.addEventListener("offline", handleOffline); - - const poll = setInterval(() => { - void getOfflineQueueLength().then(setPendingCount); - }, 10_000); - - return () => { - window.removeEventListener("online", handleOnline); - window.removeEventListener("offline", handleOffline); - clearInterval(poll); - }; - }, [handleOnline, handleOffline]); - - return { isOnline, pendingCount }; -} diff --git a/src/hooks/useProofOfReserve.ts b/src/hooks/useProofOfReserve.ts deleted file mode 100644 index f1b5d8a..0000000 --- a/src/hooks/useProofOfReserve.ts +++ /dev/null @@ -1,96 +0,0 @@ -"use client"; - -import { useCallback, useRef, useState } from "react"; -import { - InsolvencyError, - runProofOfReserve, - type ProofOfReserveConfig, - type ProofOfReserveDeps, - type ProgressPhase, -} from "@/services/proofOfReserve"; -import type { ProofState, ProofStatus } from "@/types/reserve"; - -/** - * React hook wrapping the Proof-of-Reserve flow. Exposes status, 0–100 progress - * and the attestation result, mapping the orchestrator's progress phases - * (fetch 25 → prove 50 → submit 75 → confirm 100) and surfacing an insolvency - * report when reserves fall short. - */ - -const INITIAL_STATE: ProofState = { - status: "idle", - progress: 0, - result: null, - insolvency: null, - error: null, -}; - -const PHASE_STATUS: Record = { - fetching: "fetching", - proving: "proving", - submitting: "submitting", - confirmed: "confirmed", -}; - -export interface UseProofOfReserveResult { - state: ProofState; - /** Run the proof flow for the given config. */ - generate: (config: ProofOfReserveConfig) => Promise; - reset: () => void; -} - -export function useProofOfReserve( - deps?: ProofOfReserveDeps -): UseProofOfReserveResult { - const [state, setState] = useState(INITIAL_STATE); - const runningRef = useRef(false); - const depsRef = useRef(deps); - depsRef.current = deps; - - const reset = useCallback(() => setState(INITIAL_STATE), []); - - const generate = useCallback(async (config: ProofOfReserveConfig) => { - if (runningRef.current) return; - runningRef.current = true; - setState({ ...INITIAL_STATE, status: "fetching", progress: 5 }); - - try { - const outcome = await runProofOfReserve( - config, - depsRef.current ?? {}, - (phase, percent) => { - setState((s) => ({ ...s, status: PHASE_STATUS[phase], progress: percent })); - } - ); - setState({ - status: "confirmed", - progress: 100, - result: outcome.result, - insolvency: null, - error: null, - }); - } catch (err) { - if (err instanceof InsolvencyError) { - setState({ - status: "insolvent", - progress: 100, - result: null, - insolvency: err.report, - error: err.message, - }); - } else if ((err as Error)?.name === "AbortError") { - setState(INITIAL_STATE); - } else { - setState((s) => ({ - ...s, - status: "error", - error: (err as Error).message, - })); - } - } finally { - runningRef.current = false; - } - }, []); - - return { state, generate, reset }; -} diff --git a/src/hooks/usePushNotifications.ts b/src/hooks/usePushNotifications.ts deleted file mode 100644 index 151c189..0000000 --- a/src/hooks/usePushNotifications.ts +++ /dev/null @@ -1,162 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useRef, useState } from "react"; -import { - getActiveSubscription, - isPushSupported, - subscribe as subscribePush, - unsubscribe as unsubscribePush, - type PushSubscriptionManagerDeps, -} from "@/services/pushSubscriptionManager"; -import { topicRouter } from "@/utils/topicRouter"; -import { notificationStore, useNotifications } from "@/store/slices/notificationSlice"; -import type { - AppNotification, - PushTopicHandler, - SwPushMessage, -} from "@/types/notification"; - -export type PushPermission = NotificationPermission | "unsupported"; - -export interface UsePushNotificationsOptions { - /** Inject subscription-manager dependencies (tests / custom transport). */ - deps?: PushSubscriptionManagerDeps; - /** Subscribe automatically once permission is granted. @default false */ - autoSubscribe?: boolean; -} - -export interface UsePushNotificationsResult { - permission: PushPermission; - isSubscribed: boolean; - supported: boolean; - notifications: AppNotification[]; - requestPermission: () => Promise; - subscribe: () => Promise; - unsubscribe: () => Promise; - /** Register a handler for a topic/pattern. Returns an unsubscribe function. */ - registerTopic: (pattern: string, handler: PushTopicHandler) => () => void; - dismiss: (key: string) => void; -} - -function readPermission(): PushPermission { - if (typeof Notification === "undefined") return "unsupported"; - return Notification.permission; -} - -function isSwPushMessage(data: unknown): data is SwPushMessage { - return ( - typeof data === "object" && - data !== null && - (data as { type?: unknown }).type === "push-notification" - ); -} - -/** - * Orchestrates push notifications: requests permission, registers the push - * subscription, and wires foreground delivery. When the SW detects a visible - * client it posts the event here instead of showing a system notification; the - * hook fans it out to topic handlers and the in-app toast queue. - */ -export function usePushNotifications( - options: UsePushNotificationsOptions = {} -): UsePushNotificationsResult { - const { deps, autoSubscribe = false } = options; - const supported = typeof window !== "undefined" && isPushSupported(); - - const [permission, setPermission] = useState(() => - typeof window === "undefined" ? "unsupported" : readPermission() - ); - const [isSubscribed, setIsSubscribed] = useState(false); - const notifications = useNotifications(); - - const depsRef = useRef(deps); - depsRef.current = deps; - - // Foreground delivery: the SW posts coalesced push events to this client. - useEffect(() => { - if (typeof navigator === "undefined" || !navigator.serviceWorker) return; - const onMessage = (event: MessageEvent) => { - if (!isSwPushMessage(event.data)) return; - const { payload } = event.data; - topicRouter.emit(payload); - notificationStore.receive(payload); - }; - navigator.serviceWorker.addEventListener("message", onMessage); - return () => - navigator.serviceWorker.removeEventListener("message", onMessage); - }, []); - - // Reflect any pre-existing subscription on mount. - useEffect(() => { - let cancelled = false; - if (!supported) return; - getActiveSubscription(depsRef.current) - .then((sub) => { - if (!cancelled) setIsSubscribed(sub !== null); - }) - .catch(() => {}); - return () => { - cancelled = true; - }; - }, [supported]); - - const requestPermission = useCallback(async (): Promise => { - if (typeof Notification === "undefined") return "unsupported"; - const result = await Notification.requestPermission(); - setPermission(result); - return result; - }, []); - - const subscribe = useCallback(async (): Promise => { - if (!supported) return false; - let perm = readPermission(); - if (perm === "default") perm = await requestPermission(); - if (perm !== "granted") { - setPermission(perm); - return false; - } - try { - await subscribePush(depsRef.current); - setIsSubscribed(true); - return true; - } catch { - setIsSubscribed(false); - return false; - } - }, [supported, requestPermission]); - - const unsubscribe = useCallback(async (): Promise => { - const result = await unsubscribePush(depsRef.current); - if (result) setIsSubscribed(false); - return result; - }, []); - - const registerTopic = useCallback( - (pattern: string, handler: PushTopicHandler) => - topicRouter.insert(pattern, handler), - [] - ); - - const dismiss = useCallback((key: string) => { - notificationStore.dismiss(key); - }, []); - - // Optional auto-subscribe once permission is already granted. - useEffect(() => { - if (autoSubscribe && supported && permission === "granted" && !isSubscribed) { - void subscribe(); - } - }, [autoSubscribe, supported, permission, isSubscribed, subscribe]); - - return { - permission, - isSubscribed, - supported, - notifications, - requestPermission, - subscribe, - unsubscribe, - registerTopic, - dismiss, - }; -} diff --git a/src/hooks/useResourceData.ts b/src/hooks/useResourceData.ts deleted file mode 100644 index 9bcf51e..0000000 --- a/src/hooks/useResourceData.ts +++ /dev/null @@ -1,111 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { decimal, type Decimal } from "@/utils/decimal"; -import { aggregateReadings } from "@/utils/aggregation"; -import { resourceStore, useResourceReadings } from "@/store/slices/resourceSlice"; -import { - RESOURCE_PRECISION, - type MeterReading, - type ResourceConsumption, - type ResourceKind, -} from "@/types/meter"; - -/** - * Fetches metered readings for a resource and converts the API's string-encoded - * decimals into exact {@link Decimal} values at the resource's canonical - * precision before storing them. Values are never parsed as `number`, so no - * precision is lost on ingestion. - */ - -/** Shape of a reading as delivered by the REST API (decimals are strings). */ -export interface RawReading { - meterId: string; - timestamp: number; - /** String-encoded decimal, e.g. "12.345". */ - value: string; -} - -export interface UseResourceDataOptions { - resource: ResourceKind; - /** Override the endpoint. Defaults to `/api/meter/readings?resource=`. */ - endpoint?: string; - /** Injectable transport for tests. */ - fetchFn?: typeof fetch; - /** Fetch on mount. @default true */ - enabled?: boolean; -} - -export interface UseResourceDataResult { - readings: MeterReading[]; - total: Decimal; - consumption: ResourceConsumption; - loading: boolean; - error: string | null; - refetch: () => void; -} - -/** Parse a raw API reading into a precision-tagged {@link MeterReading}. */ -export function parseRawReading( - resource: ResourceKind, - raw: RawReading -): MeterReading { - return { - meterId: raw.meterId, - resource, - timestamp: raw.timestamp, - value: decimal(raw.value, RESOURCE_PRECISION[resource]) as MeterReading["value"], - }; -} - -export function useResourceData( - options: UseResourceDataOptions -): UseResourceDataResult { - const { resource, endpoint, fetchFn = fetch, enabled = true } = options; - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const readings = useResourceReadings(resource); - const fetchRef = useRef(fetchFn); - fetchRef.current = fetchFn; - - const url = - endpoint ?? `/api/meter/readings?resource=${encodeURIComponent(resource)}`; - - const load = useCallback(async () => { - setLoading(true); - setError(null); - try { - const res = await fetchRef.current(url, { - headers: { Accept: "application/json" }, - }); - if (!res.ok) throw new Error(`Readings request failed: HTTP ${res.status}`); - const raw = (await res.json()) as RawReading[]; - const parsed = raw.map((r) => parseRawReading(resource, r)); - resourceStore.dispatch({ type: "ADD_READINGS", payload: parsed }); - } catch (err) { - setError((err as Error).message); - } finally { - setLoading(false); - } - }, [resource, url]); - - useEffect(() => { - if (!enabled) return; - void load(); - }, [enabled, load]); - - const consumption = useMemo( - () => aggregateReadings(resource, readings), - [resource, readings] - ); - - return { - readings, - total: consumption.total, - consumption, - loading, - error, - refetch: load, - }; -} diff --git a/src/hooks/useRetryQueue.ts b/src/hooks/useRetryQueue.ts deleted file mode 100644 index dbe5ec4..0000000 --- a/src/hooks/useRetryQueue.ts +++ /dev/null @@ -1,183 +0,0 @@ -"use client"; - -import { useState, useCallback, useRef, useEffect } from "react"; - -interface QueuedTransaction { - id: string; - txHash: string | null; - status: "pending" | "submitted" | "confirmed" | "failed"; - retryCount: number; - maxRetries: number; - error: string | null; - createdAt: number; -} - -interface UseRetryQueueReturn { - queue: QueuedTransaction[]; - enqueue: (id: string, maxRetries?: number) => void; - updateTxHash: (id: string, txHash: string) => void; - markConfirmed: (id: string) => void; - markFailed: (id: string, error: string) => void; - retry: (id: string) => Promise; - purge: () => void; - pendingCount: number; -} - -const RETRY_DELAYS = [1000, 5000, 15000, 30000, 60000]; -const STORAGE_KEY = "retry-queue-state"; - -// Serialize queue for localStorage -function serializeQueue(queue: QueuedTransaction[]): string { - return JSON.stringify(queue); -} - -// Deserialize queue from localStorage -function deserializeQueue(stored: string | null): QueuedTransaction[] { - if (!stored) return []; - try { - return JSON.parse(stored); - } catch { - return []; - } -} - -export function useRetryQueue(): UseRetryQueueReturn { - // Initialize queue from localStorage - const [queue, setQueue] = useState(() => { - if (typeof window === "undefined") return []; - const stored = localStorage.getItem(STORAGE_KEY); - return deserializeQueue(stored); - }); - - const timersRef = useRef>>(new Map()); - - // Persist queue to localStorage whenever it changes - useEffect(() => { - if (typeof window !== "undefined") { - localStorage.setItem(STORAGE_KEY, serializeQueue(queue)); - } - }, [queue]); - - const cleanupTimer = useCallback((id: string) => { - const timer = timersRef.current.get(id); - if (timer) { - clearTimeout(timer); - timersRef.current.delete(id); - } - }, []); - - const enqueue = useCallback((id: string, maxRetries = 5) => { - setQueue((prev) => { - if (prev.find((t) => t.id === id)) return prev; - const newQueue = [ - { - id, - txHash: null, - status: "pending" as const, - retryCount: 0, - maxRetries, - error: null, - createdAt: Date.now(), - }, - ...prev, - ]; - return newQueue; - }); - }, []); - - const updateTxHash = useCallback((id: string, txHash: string) => { - setQueue((prev) => - prev.map((t) => - t.id === id ? { ...t, txHash, status: "submitted" as const } : t - ) - ); - }, []); - - const markConfirmed = useCallback( - (id: string) => { - cleanupTimer(id); - setQueue((prev) => - prev.map((t) => - t.id === id ? { ...t, status: "confirmed" as const } : t - ) - ); - }, - [cleanupTimer] - ); - - const markFailed = useCallback( - (id: string, error: string) => { - setQueue((prev) => { - const tx = prev.find((t) => t.id === id); - if (!tx) return prev; - - // First, mark as failed - const updatedQueue = prev.map((t) => - t.id === id ? { ...t, status: "failed" as const, error } : t - ); - - // If retries are available, schedule auto-retry - if (tx.retryCount < tx.maxRetries) { - const delay = RETRY_DELAYS[Math.min(tx.retryCount, RETRY_DELAYS.length - 1)]; - - // Clean up any existing timer for this transaction - cleanupTimer(id); - - const timer = setTimeout(() => { - setQueue((current) => - current.map((t) => - t.id === id - ? { - ...t, - retryCount: t.retryCount + 1, - status: "pending" as const, - error: null, - } - : t - ) - ); - }, delay); - - timersRef.current.set(id, timer); - } - - return updatedQueue; - }); - }, - [cleanupTimer] - ); - - const retry = useCallback(async (id: string) => { - cleanupTimer(id); - setQueue((prev) => - prev.map((t) => - t.id === id - ? { ...t, status: "pending" as const, retryCount: t.retryCount + 1, error: null } - : t - ) - ); - }, [cleanupTimer]); - - const purge = useCallback(() => { - timersRef.current.forEach((timer) => clearTimeout(timer)); - timersRef.current.clear(); - setQueue([]); - }, []); - - useEffect(() => { - return () => { - timersRef.current.forEach((timer) => clearTimeout(timer)); - }; - }, []); - - return { - queue, - enqueue, - updateTxHash, - markConfirmed, - markFailed, - retry, - purge, - pendingCount: queue.filter((t) => t.status === "pending" || t.status === "submitted").length, - }; -} diff --git a/src/hooks/useScheduledExecution.ts b/src/hooks/useScheduledExecution.ts deleted file mode 100644 index 337d237..0000000 --- a/src/hooks/useScheduledExecution.ts +++ /dev/null @@ -1,108 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useRef } from "react"; -import { - getTimerWheel, - type ScheduleHandle, - type TimerWheelService, -} from "@/services/timerWheel"; -import { scheduleStore } from "@/store/slices/scheduleSlice"; -import type { FiredJob } from "@/types/scheduler"; - -/** - * Component-level scheduling. Wraps {@link TimerWheelService}, mirrors job - * metadata into {@link scheduleStore} for the dashboard, and auto-cancels every - * job this component scheduled when it unmounts. - */ - -export interface ScheduleRequest { - /** Human-readable name for the dashboard. */ - name: string; - handlerKey: string; - handler: (fired: FiredJob) => void; - /** Absolute fire time (unix ms). */ - fireAt: number; - /** Recurrence interval (ms); omit for one-shot. */ - intervalMs?: number; -} - -export interface UseScheduledExecutionResult { - schedule: (request: ScheduleRequest) => string; - cancel: (id: string) => void; - /** Current main-thread → worker drift correction (ms). */ - drift: number; - usingFallback: boolean; -} - -export function useScheduledExecution( - service: TimerWheelService = getTimerWheel() -): UseScheduledExecutionResult { - const handlesRef = useRef>(new Map()); - - useEffect(() => { - const handles = handlesRef.current; - return () => { - // Auto-cancel everything this component scheduled. - for (const handle of handles.values()) handle.cancel(); - handles.clear(); - }; - }, []); - - const schedule = useCallback( - (request: ScheduleRequest): string => { - const { name, handlerKey, handler, fireAt, intervalMs } = request; - const handle = service.schedule( - handlerKey, - (fired) => { - handler(fired); - scheduleStore.dispatch({ - type: "JOB_FIRED", - payload: { - id: fired.id, - firedAt: fired.firedAt, - nextFire: intervalMs ? fired.scheduledFor + intervalMs : fired.scheduledFor, - missed: fired.missed, - }, - }); - if (!intervalMs) handlesRef.current.delete(fired.id); - }, - fireAt, - intervalMs - ); - - handlesRef.current.set(handle.id, handle); - scheduleStore.dispatch({ - type: "JOB_SCHEDULED", - payload: { - id: handle.id, - handlerKey, - name, - nextFire: fireAt, - intervalMs: intervalMs ?? null, - status: "scheduled", - missedCount: 0, - lastFiredAt: null, - }, - }); - return handle.id; - }, - [service] - ); - - const cancel = useCallback( - (id: string) => { - handlesRef.current.get(id)?.cancel(); - handlesRef.current.delete(id); - service.cancel(id); - scheduleStore.dispatch({ type: "JOB_CANCELLED", payload: { id } }); - }, - [service] - ); - - return { - schedule, - cancel, - drift: service.drift, - usingFallback: service.usingFallback, - }; -} diff --git a/src/hooks/useSuspenseResource.ts b/src/hooks/useSuspenseResource.ts deleted file mode 100644 index 90d9e81..0000000 --- a/src/hooks/useSuspenseResource.ts +++ /dev/null @@ -1,15 +0,0 @@ -"use client"; - -import { useCacheState } from "@/store/slices/cacheSlice"; -import type { SuspenseResource } from "@/types/suspense"; - -/** - * Read a Suspense resource in a component. Subscribes to the cache so a - * background stale-while-revalidate fetch re-renders the component with fresh - * data when it completes; `resource.read()` throws the pending promise (for the - * enclosing Suspense boundary) or the error (for the ErrorBoundary). - */ -export function useSuspenseResource(resource: SuspenseResource): T { - useCacheState(); // re-render on cache updates (SWR revalidation) - return resource.read(); -} diff --git a/src/hooks/useTelemetryStream.ts b/src/hooks/useTelemetryStream.ts deleted file mode 100644 index 6157519..0000000 --- a/src/hooks/useTelemetryStream.ts +++ /dev/null @@ -1,242 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useRef, useState } from "react"; -import { ingestFrame } from "@/utils/telemetry/dataStore"; -import { TelemetryDataStore } from "@/utils/telemetry/dataStore"; -import { FrameValidator } from "@/utils/telemetry/frameValidator"; -import { parseFrame } from "@/utils/telemetry/binaryFraming"; -import { MAX_CHART_POINTS } from "@/utils/telemetry/types"; - -/** Minimal socket surface the hook depends on (subset of the DOM WebSocket). */ -export interface SocketLike { - binaryType?: string; - readyState: number; - send(data: string | ArrayBuffer): void; - close(code?: number, reason?: string): void; - onopen: ((ev: Event) => void) | null; - onmessage: ((ev: MessageEvent) => void) | null; - onclose: ((ev: CloseEvent) => void) | null; - onerror: ((ev: Event) => void) | null; -} - -export type SocketFactory = (url: string) => SocketLike; - -export type StreamStatus = "idle" | "connecting" | "open" | "closed" | "error"; - -export interface UseTelemetryStreamOptions { - url: string; - /** Number of telemetry series per frame. @default 1 */ - seriesCount?: number; - /** Max points retained per series. @default 10_000 */ - maxPoints?: number; - /** Start/stop the stream. @default true */ - enabled?: boolean; - /** Auto-reconnect after the socket closes. @default true */ - autoReconnect?: boolean; - /** Base delay between reconnect attempts (ms). @default 1000 */ - reconnectDelayMs?: number; - /** Injectable transport (defaults to the browser WebSocket). */ - createSocket?: SocketFactory; -} - -export interface UseTelemetryStreamResult { - /** Mutable data store the chart reads from (stable identity). */ - store: TelemetryDataStore; - status: StreamStatus; - /** Current connection epoch id (changes on every reconnect). */ - connectionId: string | null; - reconnectCount: number; - connect: () => void; - disconnect: () => void; -} - -const DEFAULT_RECONNECT_DELAY_MS = 1000; - -function createDefaultSocket(url: string): SocketLike { - if (typeof WebSocket === "undefined") { - throw new Error("WebSocket is not available in this environment"); - } - const ws = new WebSocket(url); - ws.binaryType = "arraybuffer"; - return ws as unknown as SocketLike; -} - -/** - * Subscribes to a binary telemetry stream and pushes validated points into a - * {@link TelemetryDataStore}. Reconnects are handled transparently; on every - * `onopen` the validator begins a new epoch so a counter reset (common in - * mobile DePIN deployments) never floods the chart with synthetic points. - */ -export function useTelemetryStream( - options: UseTelemetryStreamOptions -): UseTelemetryStreamResult { - const { - url, - seriesCount = 1, - maxPoints = MAX_CHART_POINTS, - enabled = true, - autoReconnect = true, - reconnectDelayMs = DEFAULT_RECONNECT_DELAY_MS, - createSocket = createDefaultSocket, - } = options; - - const [status, setStatus] = useState("idle"); - const [connectionId, setConnectionId] = useState(null); - const [reconnectCount, setReconnectCount] = useState(0); - - const seriesCountRef = useRef(seriesCount); - seriesCountRef.current = seriesCount; - - // Lazily-initialized singletons (stable across renders). - const storeRef = useRef(null); - if (storeRef.current === null) { - storeRef.current = new TelemetryDataStore(seriesCount, maxPoints); - } - const validatorRef = useRef(null); - if (validatorRef.current === null) { - validatorRef.current = new FrameValidator(); - } - const store = storeRef.current; - const prevValuesRef = useRef(Array.from({ length: seriesCount }, () => NaN)); - - const socketRef = useRef(null); - const reconnectTimerRef = useRef | null>(null); - const manualCloseRef = useRef(false); - const hadFirstConnectionRef = useRef(false); - const openRef = useRef<() => void>(() => {}); - - const ingest = useCallback((data: ArrayBuffer) => { - const validator = validatorRef.current; - const store = storeRef.current; - if (!validator || !store) return; - let frame; - try { - frame = parseFrame(data, seriesCountRef.current); - } catch { - return; - } - ingestFrame(validator, store, prevValuesRef.current, frame); - }, []); - - const clearReconnectTimer = useCallback(() => { - if (reconnectTimerRef.current !== null) { - clearTimeout(reconnectTimerRef.current); - reconnectTimerRef.current = null; - } - }, []); - - const disconnect = useCallback(() => { - manualCloseRef.current = true; - clearReconnectTimer(); - const socket = socketRef.current; - if (socket) { - try { - socket.close(); - } catch { - /* ignore */ - } - socketRef.current = null; - } - setStatus("closed"); - }, [clearReconnectTimer]); - - const connect = useCallback(() => { - manualCloseRef.current = false; - openRef.current(); - }, []); - - useEffect(() => { - if (!enabled) { - setStatus("idle"); - return; - } - let disposed = false; - - const scheduleReconnect = () => { - if (disposed || manualCloseRef.current || !autoReconnect) return; - clearReconnectTimer(); - reconnectTimerRef.current = setTimeout(() => { - if (!disposed && !manualCloseRef.current) open(); - }, reconnectDelayMs); - }; - - const open = () => { - if (disposed) return; - setStatus("connecting"); - let socket: SocketLike; - try { - socket = createSocket(url); - } catch { - if (!disposed) setStatus("error"); - scheduleReconnect(); - return; - } - socketRef.current = socket; - - socket.onopen = () => { - if (disposed) return; - const validator = validatorRef.current; - if (validator) { - const id = validator.beginEpoch(); - // Clear per-series predecessor values: the new stream's first frame - // has no valid predecessor to interpolate against. - for (let s = 0; s < prevValuesRef.current.length; s++) { - prevValuesRef.current[s] = NaN; - } - setConnectionId(id); - } - if (hadFirstConnectionRef.current) { - setReconnectCount((c) => c + 1); - } - hadFirstConnectionRef.current = true; - setStatus("open"); - }; - - socket.onmessage = (ev: MessageEvent) => { - if (disposed) return; - const data = ev.data; - if (!(data instanceof ArrayBuffer)) return; - ingest(data); - }; - - socket.onclose = () => { - if (disposed) return; - socketRef.current = null; - setStatus("closed"); - scheduleReconnect(); - }; - - socket.onerror = () => { - if (!disposed) setStatus("error"); - }; - }; - - openRef.current = open; - open(); - - return () => { - disposed = true; - clearReconnectTimer(); - manualCloseRef.current = true; - const socket = socketRef.current; - if (socket) { - try { - socket.close(); - } catch { - /* ignore */ - } - socketRef.current = null; - } - manualCloseRef.current = false; - }; - }, [enabled, url, autoReconnect, reconnectDelayMs, createSocket, ingest, clearReconnectTimer]); - - return { - store, - status, - connectionId, - reconnectCount, - connect, - disconnect, - }; -} diff --git a/src/hooks/useVirtualList.ts b/src/hooks/useVirtualList.ts deleted file mode 100644 index 33299f9..0000000 --- a/src/hooks/useVirtualList.ts +++ /dev/null @@ -1,359 +0,0 @@ -"use client"; - -import { - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from "react"; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export interface VirtualItem { - /** Index in the source data array. */ - index: number; - /** Offset from the top of the scrollable content (px). */ - offsetTop: number; - /** Measured or estimated height (px). */ - height: number; -} - -export interface UseVirtualListOptions { - /** Full dataset — the hook only renders a visible window. */ - items: T[]; - /** - * Estimated average row height (px). Used for initial layout before - * measurements are available. A closer estimate reduces layout jumps. - * @default 48 - */ - estimatedItemHeight?: number; - /** - * Extra items rendered above/below the visible window to reduce flicker - * during fast scrolling. - * @default 5 - */ - overscan?: number; - /** - * Session-storage key to persist scroll position across unmounts. - * Omit to disable scroll restoration. - */ - scrollRestorationKey?: string; - /** - * Called when the user scrolls within `loadMoreThreshold` of the bottom. - * Return a Promise that resolves when the next page is ready. - */ - onLoadMore?: () => Promise | void; - /** - * Distance from the bottom (px) at which `onLoadMore` triggers. - * @default 200 - */ - loadMoreThreshold?: number; - /** If true, indicates that more data is currently being fetched. */ - isLoading?: boolean; - /** - * If true the container element is expected to be an `overflow: auto` - * scrollable. Otherwise the hook attaches to `window` scroll events. - * @default true - */ - useContainerScroll?: boolean; - /** - * Optional function to derive a unique key for each item. - * Falls back to the array index if omitted. - */ - getItemKey?: (item: T, index: number) => string | number; -} - -export interface UseVirtualListReturn { - /** Ref to attach to the scrollable container element. */ - containerRef: React.RefObject; - /** Ref to attach to the inner "spacer" element that defines total height. */ - innerRef: React.RefObject; - /** The visible slice of virtual items to render. */ - virtualItems: VirtualItem[]; - /** Total estimated content height (px). */ - totalHeight: number; - /** Whether new data is currently loading. */ - isLoading: boolean; - /** Register a measured DOM element for a given data index. */ - measureElement: (index: number, element: HTMLElement | null) => void; - /** Programmatically scroll to a specific item index. */ - scrollToIndex: (index: number, behavior?: ScrollBehavior) => void; - /** The original items slice corresponding to `virtualItems`. */ - visibleItems: T[]; -} - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -const DEFAULT_ESTIMATED_HEIGHT = 48; -const DEFAULT_OVERSCAN = 5; -const DEFAULT_LOAD_MORE_THRESHOLD = 200; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** - * Binary-search for the first item whose cumulative bottom edge is at or - * below `scrollTop`. - */ -function findStartIndex( - offsets: number[], - heights: number[], - scrollTop: number -): number { - let lo = 0; - let hi = offsets.length - 1; - while (lo <= hi) { - const mid = (lo + hi) >>> 1; - const bottom = offsets[mid] + heights[mid]; - if (bottom <= scrollTop) { - lo = mid + 1; - } else { - hi = mid - 1; - } - } - return lo; -} - -// --------------------------------------------------------------------------- -// Hook -// --------------------------------------------------------------------------- - -export function useVirtualList( - options: UseVirtualListOptions -): UseVirtualListReturn { - const { - items, - estimatedItemHeight = DEFAULT_ESTIMATED_HEIGHT, - overscan = DEFAULT_OVERSCAN, - scrollRestorationKey, - onLoadMore, - loadMoreThreshold = DEFAULT_LOAD_MORE_THRESHOLD, - isLoading = false, - useContainerScroll = true, - } = options; - - const containerRef = useRef(null); - const innerRef = useRef(null); - - // Per-index measured heights; `undefined` means "not yet measured". - const measuredHeights = useRef>(new Map()); - - // Track whether we're already loading to prevent duplicate triggers. - const loadingRef = useRef(false); - loadingRef.current = isLoading; - - // Debounce RAF handle. - const rafRef = useRef(null); - - // A simple counter that forces re-computation when measurements change. - const [measureVersion, setMeasureVersion] = useState(0); - - // ----------------------------------------------------------------------- - // Derived: offsets + heights arrays - // ----------------------------------------------------------------------- - - const { offsets, heights, totalHeight } = useMemo(() => { - const count = items.length; - const _offsets = new Array(count); - const _heights = new Array(count); - - let cumulative = 0; - for (let i = 0; i < count; i++) { - _offsets[i] = cumulative; - _heights[i] = measuredHeights.current.get(i) ?? estimatedItemHeight; - cumulative += _heights[i]; - } - - return { offsets: _offsets, heights: _heights, totalHeight: cumulative }; - // measureVersion dependency triggers recomputation after DOM measurement. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [items.length, estimatedItemHeight, measureVersion]); - - // ----------------------------------------------------------------------- - // Scroll state - // ----------------------------------------------------------------------- - - const [scrollTop, setScrollTop] = useState(0); - const [containerHeight, setContainerHeight] = useState(0); - - // Store totalHeight in a ref so the scroll handler always reads the latest. - const totalHeightRef = useRef(totalHeight); - totalHeightRef.current = totalHeight; - - const handleScroll = useCallback(() => { - if (rafRef.current !== null) return; // coalesce to one per frame - rafRef.current = requestAnimationFrame(() => { - rafRef.current = null; - const el = containerRef.current; - if (!el) return; - - const top = useContainerScroll ? el.scrollTop : window.scrollY; - const height = useContainerScroll ? el.clientHeight : window.innerHeight; - - setScrollTop(top); - setContainerHeight(height); - - // Persist scroll position for restoration. - if (scrollRestorationKey) { - try { - sessionStorage.setItem( - `vlist-scroll-${scrollRestorationKey}`, - String(top) - ); - } catch { - /* quota exceeded — non-critical */ - } - } - - // Infinite-scroll trigger. - if ( - onLoadMore && - !loadingRef.current && - totalHeightRef.current - (top + height) < loadMoreThreshold - ) { - loadingRef.current = true; - onLoadMore(); - } - }); - }, [useContainerScroll, scrollRestorationKey, onLoadMore, loadMoreThreshold]); - - // Attach / detach scroll listener. - useEffect(() => { - const target = useContainerScroll ? containerRef.current : window; - if (!target) return; - - target.addEventListener("scroll", handleScroll as EventListener, { - passive: true, - }); - - // Capture initial measurements. - handleScroll(); - - return () => { - target.removeEventListener("scroll", handleScroll as EventListener); - if (rafRef.current !== null) { - cancelAnimationFrame(rafRef.current); - rafRef.current = null; - } - }; - }, [handleScroll, useContainerScroll]); - - // ----------------------------------------------------------------------- - // Scroll position restoration - // ----------------------------------------------------------------------- - - useLayoutEffect(() => { - if (!scrollRestorationKey) return; - try { - const saved = sessionStorage.getItem( - `vlist-scroll-${scrollRestorationKey}` - ); - if (saved !== null) { - const pos = Number(saved); - if (!Number.isNaN(pos) && containerRef.current) { - containerRef.current.scrollTop = pos; - } - } - } catch { - /* ignore */ - } - }, [scrollRestorationKey]); - - // ----------------------------------------------------------------------- - // Visible window calculation - // ----------------------------------------------------------------------- - - const virtualItems = useMemo(() => { - if (items.length === 0 || offsets.length === 0) return []; - - const start = Math.max( - 0, - findStartIndex(offsets, heights, scrollTop) - overscan - ); - const visibleEnd = scrollTop + containerHeight; - let end = start; - while (end < items.length && offsets[end] < visibleEnd) { - end++; - } - end = Math.min(items.length - 1, end + overscan); - - const result: VirtualItem[] = []; - for (let i = start; i <= end; i++) { - result.push({ - index: i, - offsetTop: offsets[i], - height: heights[i], - }); - } - return result; - }, [items.length, offsets, heights, scrollTop, containerHeight, overscan]); - - const visibleItems = useMemo( - () => virtualItems.map((vi) => items[vi.index]), - [virtualItems, items] - ); - - // ----------------------------------------------------------------------- - // Dynamic measurement - // ----------------------------------------------------------------------- - - const measureElement = useCallback( - (index: number, element: HTMLElement | null) => { - if (!element) return; - const measured = element.getBoundingClientRect().height; - const prev = measuredHeights.current.get(index); - if (prev !== measured) { - measuredHeights.current.set(index, measured); - setMeasureVersion((v) => v + 1); - } - }, - [] - ); - - // ----------------------------------------------------------------------- - // Programmatic scroll - // ----------------------------------------------------------------------- - - const scrollToIndex = useCallback( - (index: number, behavior: ScrollBehavior = "auto") => { - const clamped = Math.max(0, Math.min(index, items.length - 1)); - const targetOffset = offsets[clamped] ?? 0; - if (useContainerScroll && containerRef.current) { - containerRef.current.scrollTo({ top: targetOffset, behavior }); - } else { - window.scrollTo({ top: targetOffset, behavior }); - } - }, - [items.length, offsets, useContainerScroll] - ); - - // ----------------------------------------------------------------------- - // Clean up on unmount - // ----------------------------------------------------------------------- - - useEffect(() => { - return () => { - if (rafRef.current !== null) { - cancelAnimationFrame(rafRef.current); - } - }; - }, []); - - return { - containerRef, - innerRef, - virtualItems, - totalHeight, - isLoading, - measureElement, - scrollToIndex, - visibleItems, - }; -} diff --git a/src/hooks/useWebSocket.ts b/src/hooks/useWebSocket.ts deleted file mode 100644 index 6114eba..0000000 --- a/src/hooks/useWebSocket.ts +++ /dev/null @@ -1,447 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useRef, useState } from "react"; -import type { SocketLike, SocketFactory } from "@/hooks/useTelemetryStream"; -import { - HEARTBEAT_INTERVAL_MS, - HEARTBEAT_TIMEOUT_MS, - STICKY_NODE_STORAGE_KEY, - type ConnectionStatus, - type InboundControl, - type RecoveryAck, - type RecoveryFrame, - type TelemetryFrame, -} from "@/types/connection"; -import { ReconnectMachine } from "@/services/reconnectMachine"; -import { FrameBuffer } from "@/utils/frameBuffer"; -import { connectionStore } from "@/store/slices/connectionSlice"; - -type TimerId = ReturnType; - -export interface UseWebSocketOptions { - /** Base WebSocket URL; `?nodeId={sticky}` is appended automatically. */ - url: string; - /** Start the connection. @default true */ - enabled?: boolean; - /** Injectable transport (defaults to the browser WebSocket). */ - createSocket?: SocketFactory; - /** Called for every inbound telemetry frame (live and backfilled). */ - onFrame?: (frame: TelemetryFrame) => void; - /** - * REST backfill for frames missed during the outage. Defaults to - * `GET /ws/replay?from=&to=`. Injectable for tests. - */ - replayFetch?: (from: number, to: number) => Promise; - /** Injectable clock (ms). @default Date.now */ - now?: () => number; - /** Injectable RNG for backoff jitter. @default Math.random */ - rng?: () => number; - /** Injectable session storage for the sticky node id. */ - storage?: Pick; -} - -export interface UseWebSocketResult { - status: ConnectionStatus; - attempt: number; - missedHeartbeats: number; - nodeId: string | null; - lastError: string | null; - /** Send an application message over the socket (no-op if not open). */ - send: (data: unknown) => boolean; - connect: () => void; - disconnect: () => void; -} - -function defaultReplayFetch( - from: number, - to: number -): Promise { - return fetch(`/ws/replay?from=${from}&to=${to}`, { - headers: { Accept: "application/json" }, - }).then((res) => { - if (!res.ok) throw new Error(`Replay failed: HTTP ${res.status}`); - return res.json() as Promise; - }); -} - -function getDefaultStorage(): UseWebSocketOptions["storage"] | undefined { - try { - return typeof sessionStorage !== "undefined" ? sessionStorage : undefined; - } catch { - return undefined; // access can throw in sandboxed iframes - } -} - -function isControl(msg: unknown): msg is InboundControl { - return ( - typeof msg === "object" && - msg !== null && - typeof (msg as { type?: unknown }).type === "string" - ); -} - -/** - * Persistent WebSocket with adaptive reconnection. Connection lifecycle is - * delegated to {@link ReconnectMachine}; on entering `reconnecting` the hook - * arms a full-jitter backoff timer, and on reconnect it runs a subscription - * recovery handshake (replaying the buffered frames) before resuming. Status - * transitions are mirrored into {@link connectionStore} for the UI. - */ -export function useWebSocket(options: UseWebSocketOptions): UseWebSocketResult { - const { - url, - enabled = true, - createSocket, - onFrame, - replayFetch = defaultReplayFetch, - now = Date.now, - rng = Math.random, - storage = getDefaultStorage(), - } = options; - - const [status, setStatus] = useState("idle"); - const [attempt, setAttempt] = useState(0); - const [missedHeartbeats, setMissedHeartbeats] = useState(0); - const [nodeId, setNodeId] = useState(null); - const [lastError, setLastError] = useState(null); - - // Stable singletons for the connection lifetime. - const machineRef = useRef(null); - if (machineRef.current === null) { - machineRef.current = new ReconnectMachine({ rng }); - } - const bufferRef = useRef(null); - if (bufferRef.current === null) { - bufferRef.current = new FrameBuffer(); - } - - const socketRef = useRef(null); - const reconnectTimerRef = useRef(null); - const heartbeatTimerRef = useRef(null); - const lastPingAtRef = useRef(0); - const disposedRef = useRef(false); - - // Latest callbacks/config without re-subscribing the socket. - const onFrameRef = useRef(onFrame); - onFrameRef.current = onFrame; - const createSocketRef = useRef(createSocket); - createSocketRef.current = createSocket; - const replayFetchRef = useRef(replayFetch); - replayFetchRef.current = replayFetch; - - // ---- helpers ------------------------------------------------------------ - - const clearReconnectTimer = useCallback(() => { - if (reconnectTimerRef.current !== null) { - clearTimeout(reconnectTimerRef.current); - reconnectTimerRef.current = null; - } - }, []); - - const clearHeartbeatTimer = useCallback(() => { - if (heartbeatTimerRef.current !== null) { - clearTimeout(heartbeatTimerRef.current); - heartbeatTimerRef.current = null; - } - }, []); - - /** Mirror machine context into local state and the global store. */ - const syncFromMachine = useCallback(() => { - const ctx = machineRef.current!.getContext(); - setStatus(ctx.status); - setAttempt(ctx.attempt); - setMissedHeartbeats(ctx.missedHeartbeats); - setLastError(ctx.lastError); - if (ctx.status === "failed") { - connectionStore.dispatch({ - type: "CONNECTION_FAILED", - payload: { error: ctx.lastError ?? "Connection failed" }, - }); - } else { - connectionStore.dispatch({ - type: "CONNECTION_STATUS_CHANGED", - payload: { - status: ctx.status, - attempt: ctx.attempt, - nextDelayMs: ctx.nextDelayMs, - }, - }); - } - }, []); - - const buildUrl = useCallback((): string => { - const sticky = storage?.getItem(STICKY_NODE_STORAGE_KEY) ?? ""; - const sep = url.includes("?") ? "&" : "?"; - return sticky ? `${url}${sep}nodeId=${encodeURIComponent(sticky)}` : url; - }, [storage, url]); - - // Forward declaration so the close handler can re-open. - const openRef = useRef<() => void>(() => {}); - - const scheduleReconnect = useCallback(() => { - const ctx = machineRef.current!.getContext(); - if (ctx.status !== "reconnecting" || ctx.nextDelayMs === null) return; - clearReconnectTimer(); - reconnectTimerRef.current = setTimeout(() => { - reconnectTimerRef.current = null; - if (disposedRef.current) return; - machineRef.current!.send({ type: "RETRY" }); - syncFromMachine(); - openRef.current(); - }, ctx.nextDelayMs); - }, [clearReconnectTimer, syncFromMachine]); - - const handleDisconnect = useCallback(() => { - if (disposedRef.current) return; - clearHeartbeatTimer(); - socketRef.current = null; - machineRef.current!.send({ type: "DISCONNECTED" }); - syncFromMachine(); - if (machineRef.current!.getContext().status === "reconnecting") { - scheduleReconnect(); - } - }, [clearHeartbeatTimer, scheduleReconnect, syncFromMachine]); - - /** Arm the heartbeat watchdog: a missed server ping marks the link dead. */ - const armHeartbeat = useCallback(() => { - clearHeartbeatTimer(); - heartbeatTimerRef.current = setTimeout(() => { - if (disposedRef.current) return; - const missed = machineRef.current!.recordMissedHeartbeat(); - connectionStore.dispatch({ - type: "HEARTBEAT_MISSED", - payload: { missedHeartbeats: missed }, - }); - setMissedHeartbeats(missed); - machineRef.current!.send({ type: "HEARTBEAT_TIMEOUT" }); - syncFromMachine(); - try { - socketRef.current?.close(); - } catch { - /* ignore */ - } - handleDisconnect(); - }, HEARTBEAT_INTERVAL_MS + HEARTBEAT_TIMEOUT_MS); - }, [clearHeartbeatTimer, handleDisconnect, syncFromMachine]); - - const handlePing = useCallback(() => { - const t = now(); - const prev = lastPingAtRef.current; - lastPingAtRef.current = t; - // Inter-ping drift beyond the nominal interval is reported as latency. - if (prev > 0) { - const latency = Math.max(0, t - prev - HEARTBEAT_INTERVAL_MS); - connectionStore.dispatch({ type: "HEARTBEAT_LATENCY", payload: { latency } }); - } - try { - socketRef.current?.send(JSON.stringify({ type: "pong" })); - } catch { - /* ignore */ - } - armHeartbeat(); - }, [armHeartbeat, now]); - - const sendRecovery = useCallback(() => { - const buffer = bufferRef.current!; - const sticky = storage?.getItem(STICKY_NODE_STORAGE_KEY) ?? null; - const frame: RecoveryFrame = { - type: "recovery", - lastSequenceId: buffer.lastSequenceId, - nodeId: sticky, - frames: buffer.drain(), - }; - try { - socketRef.current?.send(JSON.stringify(frame)); - } catch { - /* ignore */ - } - }, [storage]); - - const handleRecoveryAck = useCallback( - async (ack: RecoveryAck) => { - const buffer = bufferRef.current!; - const fromSeq = buffer.lastSequenceId; - - machineRef.current!.send({ type: "RECOVERY_SUCCESS" }); - syncFromMachine(); - connectionStore.dispatch({ type: "CONNECTION_RECOVERED" }); - armHeartbeat(); - - // Backfill anything the buffer could not cover via the REST replay API. - if (ack.missedCount > 0 && ack.serverCurrentSeq > fromSeq) { - try { - const missed = await replayFetchRef.current(fromSeq, ack.serverCurrentSeq); - if (disposedRef.current) return; - for (const f of missed) { - buffer.push(f); - onFrameRef.current?.(f); - } - } catch { - // Backfill is best-effort; the live stream continues regardless. - } - } - }, - [armHeartbeat, syncFromMachine] - ); - - const handleMessage = useCallback( - (raw: unknown) => { - let msg: unknown = raw; - if (typeof raw === "string") { - try { - msg = JSON.parse(raw); - } catch { - return; // ignore non-JSON text frames - } - } - - if (isControl(msg)) { - switch (msg.type) { - case "ping": - handlePing(); - return; - case "node_assigned": - storage?.setItem(STICKY_NODE_STORAGE_KEY, msg.nodeId); - setNodeId(msg.nodeId); - connectionStore.dispatch({ - type: "NODE_ID_ASSIGNED", - payload: { nodeId: msg.nodeId }, - }); - return; - case "recovery_ack": - void handleRecoveryAck(msg); - return; - } - } - - // Otherwise treat it as a telemetry frame and buffer it for replay. - if ( - typeof msg === "object" && - msg !== null && - typeof (msg as { sequenceId?: unknown }).sequenceId === "number" - ) { - const frame = msg as TelemetryFrame; - bufferRef.current!.push(frame); - onFrameRef.current?.(frame); - } - }, - [handlePing, handleRecoveryAck, storage] - ); - - const open = useCallback(() => { - if (disposedRef.current) return; - let socket: SocketLike; - try { - const factory = createSocketRef.current; - if (!factory) throw new Error("No socket factory provided"); - socket = factory(buildUrl()); - } catch (err) { - machineRef.current!.send({ type: "DISCONNECTED" }); - setLastError((err as Error).message); - syncFromMachine(); - if (machineRef.current!.getContext().status === "reconnecting") { - scheduleReconnect(); - } - return; - } - socketRef.current = socket; - - socket.onopen = () => { - if (disposedRef.current) return; - machineRef.current!.send({ type: "CONNECTED" }); - syncFromMachine(); - const ctx = machineRef.current!.getContext(); - if (ctx.status === "recovering") { - sendRecovery(); - } else { - armHeartbeat(); - } - }; - socket.onmessage = (ev: MessageEvent) => { - if (disposedRef.current) return; - handleMessage(ev.data); - }; - socket.onclose = () => handleDisconnect(); - socket.onerror = () => handleDisconnect(); - }, [ - armHeartbeat, - buildUrl, - handleDisconnect, - handleMessage, - scheduleReconnect, - sendRecovery, - syncFromMachine, - ]); - openRef.current = open; - - // ---- public API --------------------------------------------------------- - - const connect = useCallback(() => { - if (disposedRef.current) return; - machineRef.current!.send({ type: "CONNECT" }); - syncFromMachine(); - open(); - }, [open, syncFromMachine]); - - const disconnect = useCallback(() => { - clearReconnectTimer(); - clearHeartbeatTimer(); - machineRef.current!.send({ type: "RESET" }); - syncFromMachine(); - connectionStore.dispatch({ type: "RESET" }); - const socket = socketRef.current; - socketRef.current = null; - if (socket) { - try { - socket.close(); - } catch { - /* ignore */ - } - } - }, [clearHeartbeatTimer, clearReconnectTimer, syncFromMachine]); - - const send = useCallback((data: unknown): boolean => { - const socket = socketRef.current; - if (!socket || socket.readyState !== 1 /* OPEN */) return false; - try { - socket.send(typeof data === "string" ? data : JSON.stringify(data)); - return true; - } catch { - return false; - } - }, []); - - // ---- lifecycle ---------------------------------------------------------- - - useEffect(() => { - if (!enabled) return; - disposedRef.current = false; - connect(); - return () => { - disposedRef.current = true; - clearReconnectTimer(); - clearHeartbeatTimer(); - const socket = socketRef.current; - socketRef.current = null; - if (socket) { - try { - socket.close(); - } catch { - /* ignore */ - } - } - }; - // Intentionally only (re)run when enabled/url changes; callbacks use refs. - }, [enabled, url]); - - return { - status, - attempt, - missedHeartbeats, - nodeId, - lastError, - send, - connect, - disconnect, - }; -} diff --git a/src/hooks/useWorker.ts b/src/hooks/useWorker.ts deleted file mode 100644 index fb2dfa7..0000000 --- a/src/hooks/useWorker.ts +++ /dev/null @@ -1,187 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useRef, useState } from "react"; - -export interface AggregationChunk { - period: string; - total: number; - average: number; - min: number; - max: number; - count: number; -} - -export interface TransformedValue { - original: number; - scaled: number; - normalized: number; -} - -export interface WorkerPayload { - type: "aggregate" | "filter" | "transform"; - data: number[]; - config?: { - chunkSize?: number; - period?: string; - threshold?: number; - }; -} - -type WorkerResult = - | { type: "aggregate_result"; data: AggregationChunk[] } - | { type: "filter_result"; data: number[] } - | { type: "transform_result"; data: TransformedValue[] } - | { type: "error"; data: string }; - -interface UseWorkerReturn { - result: AggregationChunk[] | number[] | TransformedValue[] | null; - loading: boolean; - error: string | null; - run: (payload: WorkerPayload) => void; - terminate: () => void; -} - -// Main-thread fallback implementations -function aggregateOnMain(data: number[], chunkSize: number): AggregationChunk[] { - const chunks: AggregationChunk[] = []; - for (let i = 0; i < data.length; i += chunkSize) { - const chunk = data.slice(i, i + chunkSize); - const sum = chunk.reduce((a, b) => a + b, 0); - chunks.push({ - period: `chunk-${Math.floor(i / chunkSize)}`, - total: sum, - average: chunk.length > 0 ? sum / chunk.length : 0, - min: Math.min(...chunk), - max: Math.max(...chunk), - count: chunk.length, - }); - } - return chunks; -} - -function filterOnMain(data: number[], threshold: number): number[] { - const mean = data.reduce((a, b) => a + b, 0) / data.length; - const stdDev = Math.sqrt( - data.reduce((sq, n) => sq + Math.pow(n - mean, 2), 0) / data.length - ); - return data.filter( - (value) => Math.abs(value - mean) <= threshold * stdDev - ); -} - -function transformOnMain(data: number[]): TransformedValue[] { - const min = Math.min(...data); - const max = Math.max(...data); - const range = max - min || 1; - return data.map((value) => ({ - original: value, - scaled: value / 100, - normalized: (value - min) / range, - })); -} - -function computeOnMain(payload: WorkerPayload): WorkerResult { - const { type, data, config } = payload; - switch (type) { - case "aggregate": { - const chunkSize = config?.chunkSize || 100; - return { type: "aggregate_result", data: aggregateOnMain(data, chunkSize) }; - } - case "filter": { - const threshold = config?.threshold || 3; - return { type: "filter_result", data: filterOnMain(data, threshold) }; - } - case "transform": - return { type: "transform_result", data: transformOnMain(data) }; - default: - return { type: "error", data: `Unknown type: ${type}` }; - } -} - -export function useWorker(): UseWorkerReturn { - const [result, setResult] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const workerRef = useRef(null); - const mountedRef = useRef(true); - - useEffect(() => { - mountedRef.current = true; - - // Initialize the Web Worker - try { - const worker = new Worker( - new URL("../workers/data.worker.ts", import.meta.url), - { type: "module" } - ); - workerRef.current = worker; - - worker.onmessage = (event: MessageEvent) => { - if (!mountedRef.current) return; - const msg = event.data; - if (msg.type === "error") { - setError(msg.data); - setLoading(false); - } else { - setResult(msg.data); - setLoading(false); - } - }; - - worker.onerror = (evt: ErrorEvent) => { - if (!mountedRef.current) return; - setError(evt.message || "Worker error occurred"); - setLoading(false); - }; - } catch { - // Worker creation failed — will fall back to main thread - } - - return () => { - mountedRef.current = false; - if (workerRef.current) { - workerRef.current.terminate(); - workerRef.current = null; - } - }; - }, []); - - const run = useCallback((payload: WorkerPayload) => { - setLoading(true); - setError(null); - - if (workerRef.current) { - // Use Web Worker - workerRef.current.postMessage(payload); - } else { - // Fallback: compute on main thread - try { - const res = computeOnMain(payload); - if (!mountedRef.current) return; - if (res.type === "error") { - setError(res.data); - setLoading(false); - } else { - setResult(res.data); - setLoading(false); - } - } catch (err) { - if (!mountedRef.current) return; - setError((err as Error).message || "Computation failed"); - setLoading(false); - } - } - }, []); - - const terminate = useCallback(() => { - if (workerRef.current) { - workerRef.current.terminate(); - workerRef.current = null; - } - setResult(null); - setError(null); - setLoading(false); - }, []); - - return { result, loading, error, run, terminate }; -} diff --git a/src/hooks/useZKSubmission.ts b/src/hooks/useZKSubmission.ts deleted file mode 100644 index cd5d0a4..0000000 --- a/src/hooks/useZKSubmission.ts +++ /dev/null @@ -1,190 +0,0 @@ -"use client"; - -import { useCallback, useRef, useState } from "react"; -import type { - MeterReading, - ZKProofResult, - ZKSubmissionState, -} from "@/types/zk"; -import { - generateProof, - prepareInputs, - type ProofContext, - type ZKProverConfig, -} from "@/services/zkProver"; -import { verifyProof } from "@/services/zkVerifier"; -import { classifyError } from "@/utils/errors"; - -/** - * Encrypted submission payload handed to the chain. The ciphertext and proof - * are public; the plaintext reading and meter identity never leave the device. - */ -export interface EncryptedSubmission { - encryptedReading: string; - proof: ZKProofResult["proof"]; - publicSignals: string[]; - proofHash: string; -} - -export interface UseZKSubmissionOptions { - /** Soroban contract holding the verification key and accepting submissions. */ - contractId: string; - network?: string; - proverConfig?: ZKProverConfig; - /** - * Resolve the non-reading context for a submission: the membership root, a - * recent block hash (replay protection) and the x25519 ciphertext. - */ - buildContext: (reading: MeterReading) => Promise | ProofContext; - /** - * Submit the encrypted payload on-chain. Should resolve once accepted into - * the ledger. Defaults to a no-op so the hook can be used proof-only. - */ - submitEncrypted?: (payload: EncryptedSubmission) => Promise; -} - -const INITIAL_STATE: ZKSubmissionState = { - status: "idle", - progress: 0, - proofHash: null, - error: null, -}; - -// Phase weighting across the overall 0–100 bar. -const KEY_PHASE_MAX = 55; // downloading-key occupies 0–55%. -const PROVE_PHASE_MAX = 90; // proving occupies 55–90%. - -export interface UseZKSubmissionReturn { - state: ZKSubmissionState; - /** Run the full pipeline: prove → submit → verify. */ - submit: (reading: MeterReading) => Promise; - /** Verify an already-generated proof against the on-chain key. */ - verify: ( - proof: ZKProofResult["proof"], - publicSignals: string[] - ) => Promise; - /** Abort an in-flight submission (key download and proving). */ - cancel: () => void; - reset: () => void; -} - -export function useZKSubmission( - options: UseZKSubmissionOptions -): UseZKSubmissionReturn { - const [state, setState] = useState(INITIAL_STATE); - const abortRef = useRef(null); - const runningRef = useRef(false); - - const cancel = useCallback(() => { - abortRef.current?.abort(); - abortRef.current = null; - runningRef.current = false; - }, []); - - const reset = useCallback(() => { - cancel(); - setState(INITIAL_STATE); - }, [cancel]); - - const submit = useCallback( - async (reading: MeterReading) => { - if (runningRef.current) return; // Guard against double submission. - runningRef.current = true; - - const controller = new AbortController(); - abortRef.current = controller; - const { signal } = controller; - - setState({ - status: "downloading-key", - progress: 0, - proofHash: null, - error: null, - }); - - try { - const context = await options.buildContext(reading); - const inputs = prepareInputs(reading, context); - - const result = await generateProof(inputs, { - config: options.proverConfig, - signal, - onKeyProgress: (p) => { - const pct = p.percent ?? 0; - setState((s) => ({ - ...s, - status: "downloading-key", - progress: Math.round((pct / 100) * KEY_PHASE_MAX), - })); - }, - onProveProgress: (pct) => { - setState((s) => ({ - ...s, - status: "proving", - progress: - KEY_PHASE_MAX + - Math.round((pct / 100) * (PROVE_PHASE_MAX - KEY_PHASE_MAX)), - })); - }, - }); - - if (signal.aborted) return; - - // Submit the encrypted payload on-chain. - setState((s) => ({ - ...s, - status: "submitting", - progress: PROVE_PHASE_MAX, - proofHash: result.proofHash, - })); - - await options.submitEncrypted?.({ - encryptedReading: context.encryptedCiphertext, - proof: result.proof, - publicSignals: result.publicSignals, - proofHash: result.proofHash, - }); - - if (signal.aborted) return; - - // Independently verify against the on-chain verification key. - const ok = await verifyProof(result.proof, result.publicSignals, { - contractId: options.contractId, - network: options.network, - }); - - setState({ - status: ok ? "confirmed" : "rejected", - progress: 100, - proofHash: result.proofHash, - error: ok ? null : "Proof failed on-chain verification.", - }); - } catch (err) { - if ((err as Error)?.name === "AbortError" || signal.aborted) { - setState(INITIAL_STATE); - return; - } - setState((s) => ({ - ...s, - status: "rejected", - error: classifyError(err).message, - })); - } finally { - runningRef.current = false; - abortRef.current = null; - } - }, - [options] - ); - - const verify = useCallback( - (proof: ZKProofResult["proof"], publicSignals: string[]) => - verifyProof(proof, publicSignals, { - contractId: options.contractId, - network: options.network, - }), - [options.contractId, options.network] - ); - - return { state, submit, verify, cancel, reset }; -} diff --git a/src/services/proofOfReserve.ts b/src/services/proofOfReserve.ts deleted file mode 100644 index 9074500..0000000 --- a/src/services/proofOfReserve.ts +++ /dev/null @@ -1,278 +0,0 @@ -"use client"; - -import { - RANGE_MAX, - type AuditInventory, - type AttestationResult, - type InsolvencyReport, - type OnChainCommitment, - type ProofInputs, - type ProofWorkerRequest, - type ProofWorkerResponse, - type RangeProof, -} from "@/types/reserve"; -import { bytesToHex, hexToBytes, sha256 } from "@/utils/porMerkle"; -import { getTracer } from "@/utils/telemetry/tracing"; - -/** - * Proof-of-Reserve orchestrator. Reads the on-chain Merkle/liability commitment - * and the off-chain audited inventory, aborts on insolvency, then drives the - * WASM range prover and (optionally) submits the attestation on-chain. All I/O - * is injectable so the flow can be exercised without a contract or WASM module. - */ - -export interface ProofOfReserveConfig { - contractId: string; - network?: string; - /** Audit window start (unix seconds). */ - from: number; - /** Audit window end (unix seconds). */ - to: number; -} - -export interface ProofOfReserveDeps { - fetchCommitment?: ( - contractId: string, - network: string - ) => Promise; - fetchAudit?: ( - from: number, - to: number, - network: string - ) => Promise; - /** Range prover; defaults to the WASM worker. */ - prove?: (inputs: ProofInputs) => Promise; - /** Submit the attestation; when omitted the proof is produced but not sent. */ - submitAttestation?: ( - proof: RangeProof, - attestationHash: string, - config: ProofOfReserveConfig - ) => Promise<{ ledger: number | null }>; - fetchFn?: typeof fetch; - apiBase?: string; - wasmUrl?: string; - wasmIntegrity?: string; -} - -export type ProgressPhase = - | "fetching" - | "proving" - | "submitting" - | "confirmed"; - -export type ProgressFn = (phase: ProgressPhase, percent: number) => void; - -/** Thrown when off-chain reserves do not cover the on-chain liability. */ -export class InsolvencyError extends Error { - constructor(readonly report: InsolvencyReport) { - super( - `Insolvency detected: reserves ${report.auditTotal} < liability ${report.liability} (shortfall ${report.shortfall})` - ); - this.name = "InsolvencyError"; - } -} - -/** Sum the audited inventory totals as an integer. */ -export function aggregateAudit(inventory: AuditInventory): bigint { - return inventory.entries.reduce((acc, e) => acc + BigInt(e.total), BigInt(0)); -} - -/** Return an insolvency report when reserves < liability, else null. */ -export function checkSolvency( - liability: bigint, - auditTotal: bigint -): InsolvencyReport | null { - if (auditTotal >= liability) return null; - return { - liability: liability.toString(), - auditTotal: auditTotal.toString(), - shortfall: (auditTotal - liability).toString(), - }; -} - -/** Cryptographically-random 32-byte blinding factor as 0x-hex. */ -export function generateRandomness(): string { - const bytes = new Uint8Array(32); - crypto.getRandomValues(bytes); - return `0x${bytesToHex(bytes)}`; -} - -/** Assemble the prover inputs; throws if the surplus exceeds the 64-bit range. */ -export function buildProofInputs( - commitment: OnChainCommitment, - auditTotal: bigint -): ProofInputs { - const liability = BigInt(commitment.totalLiability); - const surplus = auditTotal - liability; - if (surplus < BigInt(0)) { - throw new Error("surplus is negative — solvency must be checked first"); - } - if (surplus > RANGE_MAX) { - throw new Error(`surplus ${surplus} exceeds the 64-bit range proof bound`); - } - return { - merkleRoot: commitment.merkleRoot, - totalLiability: liability.toString(), - auditTotal: auditTotal.toString(), - surplus: surplus.toString(), - randomness: generateRandomness(), - }; -} - -/** SHA-256 of the proof bytes, used as the attestation reference. */ -export async function attestationHashOf(proof: RangeProof): Promise { - return `0x${bytesToHex(await sha256(hexToBytes(proof.proof)))}`; -} - -let workerCounter = 0; - -/** Default prover: run the Bulletproofs WASM in a dedicated worker. */ -export function proveWithWorker( - inputs: ProofInputs, - options: { wasmUrl: string; wasmIntegrity?: string; signal?: AbortSignal } = { - wasmUrl: "/wasm/bulletproofs.wasm", - } -): Promise { - const worker = new Worker( - new URL("../workers/proofWorker.worker.ts", import.meta.url), - { type: "module" } - ); - const id = ++workerCounter; - - return new Promise((resolve, reject) => { - const cleanup = () => worker.terminate(); - const onAbort = () => { - cleanup(); - reject(new DOMException("Aborted", "AbortError")); - }; - if (options.signal?.aborted) return onAbort(); - options.signal?.addEventListener("abort", onAbort, { once: true }); - - worker.onmessage = (e: MessageEvent) => { - const msg = e.data; - if (msg.id !== id) return; - options.signal?.removeEventListener("abort", onAbort); - cleanup(); - if (msg.type === "result") resolve(msg.proof); - else reject(new Error(msg.error)); - }; - worker.onerror = (e: ErrorEvent) => { - cleanup(); - reject(new Error(e.message || "Proof worker crashed")); - }; - - const request: ProofWorkerRequest = { - id, - inputs, - wasmUrl: options.wasmUrl, - wasmIntegrity: options.wasmIntegrity, - }; - worker.postMessage(request); - }); -} - -const defaultFetchCommitment = (): Promise => { - throw new Error("fetchCommitment dependency is required"); -}; - -function makeFetchAudit( - deps: ProofOfReserveDeps -): NonNullable { - if (deps.fetchAudit) return deps.fetchAudit; - const fetchFn = deps.fetchFn ?? fetch.bind(globalThis); - const apiBase = deps.apiBase ?? "/api/resources"; - return async (from, to) => { - const res = await fetchFn(`${apiBase}/audit?from=${from}&to=${to}`, { - headers: { Accept: "application/json" }, - }); - if (!res.ok) throw new Error(`Audit request failed: HTTP ${res.status}`); - return (await res.json()) as AuditInventory; - }; -} - -export interface ProofOfReserveOutcome { - proof: RangeProof; - result: AttestationResult; - commitment: OnChainCommitment; - auditTotal: string; -} - -/** - * Run the full PoR flow. Emits progress (fetch → prove → submit → confirm) and - * throws {@link InsolvencyError} when reserves do not cover liabilities. - */ -export async function runProofOfReserve( - config: ProofOfReserveConfig, - deps: ProofOfReserveDeps = {}, - onProgress: ProgressFn = () => {} -): Promise { - const tracer = getTracer(); - const span = tracer.startSpan("runProofOfReserve"); - span.setAttributes({ - "por.contract_id": config.contractId, - "por.from": config.from, - "por.to": config.to, - "por.network": config.network ?? "testnet", - }); - - return tracer.withSpanAsync(span, async () => { - try { - const network = config.network ?? "testnet"; - const fetchCommitment = deps.fetchCommitment ?? defaultFetchCommitment; - const fetchAudit = makeFetchAudit(deps); - const prove = - deps.prove ?? - ((inputs: ProofInputs) => - proveWithWorker(inputs, { - wasmUrl: deps.wasmUrl ?? "/wasm/bulletproofs.wasm", - wasmIntegrity: deps.wasmIntegrity, - })); - - // 1. Fetch on-chain commitment and off-chain inventory. - onProgress("fetching", 10); - const [commitment, inventory] = await Promise.all([ - fetchCommitment(config.contractId, network), - fetchAudit(config.from, config.to, network), - ]); - onProgress("fetching", 25); - - // 2. Solvency gate. - const auditTotal = aggregateAudit(inventory); - const liability = BigInt(commitment.totalLiability); - const insolvency = checkSolvency(liability, auditTotal); - if (insolvency) throw new InsolvencyError(insolvency); - - // 3. Prove the surplus is in range. - const inputs = buildProofInputs(commitment, auditTotal); - onProgress("proving", 50); - const proof = await prove(inputs); - - // 4. Attest on-chain (optional). - const attestationHash = await attestationHashOf(proof); - onProgress("submitting", 75); - let ledger: number | null = null; - if (deps.submitAttestation) { - ({ ledger } = await deps.submitAttestation(proof, attestationHash, config)); - } - - onProgress("confirmed", 100); - span.setStatus("OK"); - return { - proof, - commitment, - auditTotal: auditTotal.toString(), - result: { - attestationHash, - ledger, - commitment: proof.commitment, - provedAt: Date.now(), - }, - }; - } catch (err) { - span.recordException(err as Error); - throw err; - } finally { - span.end(); - } - }); -} diff --git a/src/services/reconnectMachine.ts b/src/services/reconnectMachine.ts deleted file mode 100644 index a334289..0000000 --- a/src/services/reconnectMachine.ts +++ /dev/null @@ -1,201 +0,0 @@ -/** - * Custom finite state machine governing the WebSocket reconnection lifecycle. - * - * States: idle → connecting → connected, with reconnecting / recovering / - * failed branches. The machine is pure: it computes the next backoff delay and - * exposes it via context, but it schedules nothing itself — the owning hook - * reads {@link ReconnectContext.nextDelayMs}, arms a timer, and feeds a `RETRY` - * event back in. This keeps the transition logic deterministic and testable. - */ - -import { - MAX_RECONNECT_ATTEMPTS, - type ConnectionStatus, - type ReconnectContext, - type ReconnectEvent, -} from "@/types/connection"; -import { fullJitterBackoff } from "@/utils/backoff"; - -export interface ReconnectMachineOptions { - /** Injectable RNG for jitter (defaults to Math.random) — used in tests. */ - rng?: () => number; - /** Override the terminal-failure attempt threshold. */ - maxAttempts?: number; -} - -type Listener = (context: ReconnectContext) => void; - -const INITIAL_CONTEXT: ReconnectContext = { - status: "idle", - attempt: 0, - missedHeartbeats: 0, - nextDelayMs: null, - lastError: null, -}; - -export class ReconnectMachine { - private context: ReconnectContext = { ...INITIAL_CONTEXT }; - private readonly listeners = new Set(); - private readonly rng: () => number; - private readonly maxAttempts: number; - - constructor(options: ReconnectMachineOptions = {}) { - this.rng = options.rng ?? Math.random; - this.maxAttempts = options.maxAttempts ?? MAX_RECONNECT_ATTEMPTS; - } - - getContext(): Readonly { - return this.context; - } - - get status(): ConnectionStatus { - return this.context.status; - } - - get isTerminal(): boolean { - return this.context.status === "failed"; - } - - subscribe(listener: Listener): () => void { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - } - - /** Feed an event into the machine, applying the transition and notifying. */ - send(event: ReconnectEvent): ReconnectContext { - const next = this.transition(this.context, event); - if (next !== this.context) { - this.context = next; - this.notify(); - } - return this.context; - } - - /** - * Record a missed heartbeat. Returns the running count so the caller can - * decide whether to escalate to a `HEARTBEAT_TIMEOUT` event. - */ - recordMissedHeartbeat(): number { - this.context = { - ...this.context, - missedHeartbeats: this.context.missedHeartbeats + 1, - }; - this.notify(); - return this.context.missedHeartbeats; - } - - private transition( - ctx: ReconnectContext, - event: ReconnectEvent - ): ReconnectContext { - // RESET is accepted from any state. - if (event.type === "RESET") { - return { ...INITIAL_CONTEXT }; - } - - switch (ctx.status) { - case "idle": - if (event.type === "CONNECT") { - return { ...INITIAL_CONTEXT, status: "connecting" }; - } - return ctx; - - case "connecting": - switch (event.type) { - case "CONNECTED": - // A successful open after an outage enters recovery; the very first - // connection has nothing to recover and goes straight to connected. - return ctx.attempt > 0 - ? { ...ctx, status: "recovering", nextDelayMs: null } - : { - ...ctx, - status: "connected", - missedHeartbeats: 0, - nextDelayMs: null, - lastError: null, - }; - case "DISCONNECTED": - case "HEARTBEAT_TIMEOUT": - return this.enterReconnecting(ctx, event); - default: - return ctx; - } - - case "connected": - if (event.type === "DISCONNECTED" || event.type === "HEARTBEAT_TIMEOUT") { - return this.enterReconnecting(ctx, event); - } - return ctx; - - case "reconnecting": - if (event.type === "RETRY") { - return { ...ctx, status: "connecting", nextDelayMs: null }; - } - // Already reconnecting; ignore duplicate disconnect signals. - return ctx; - - case "recovering": - switch (event.type) { - case "RECOVERY_SUCCESS": - return { - ...ctx, - status: "connected", - attempt: 0, - missedHeartbeats: 0, - nextDelayMs: null, - lastError: null, - }; - case "DISCONNECTED": - case "HEARTBEAT_TIMEOUT": - return this.enterReconnecting(ctx, event); - default: - return ctx; - } - - case "failed": - // Manual retry restarts the lifecycle from scratch. - if (event.type === "CONNECT") { - return { ...INITIAL_CONTEXT, status: "connecting" }; - } - return ctx; - - default: - return ctx; - } - } - - /** Increment the attempt counter and either schedule a retry or give up. */ - private enterReconnecting( - ctx: ReconnectContext, - event: ReconnectEvent - ): ReconnectContext { - const attempt = ctx.attempt + 1; - const reason = - event.type === "HEARTBEAT_TIMEOUT" - ? "Heartbeat timed out" - : "Connection lost"; - - if (attempt > this.maxAttempts) { - return { - ...ctx, - status: "failed", - nextDelayMs: null, - lastError: `Reconnection failed after ${this.maxAttempts} attempts`, - }; - } - - // Zero-based attempt index for the backoff curve (first retry → 2^0). - const nextDelayMs = fullJitterBackoff(attempt - 1, undefined, undefined, this.rng); - return { - ...ctx, - status: "reconnecting", - attempt, - nextDelayMs, - lastError: reason, - }; - } - - private notify(): void { - for (const listener of this.listeners) listener(this.context); - } -} diff --git a/src/services/webhookDelivery.ts b/src/services/webhookDelivery.ts deleted file mode 100644 index 0f8d0e0..0000000 --- a/src/services/webhookDelivery.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { fullJitterBackoff } from "@/utils/backoff"; - -export type WebhookEvent = { - id: string; - type: string; - createdAt: string; - payload: unknown; -}; - -export type WebhookEndpoint = { - id: string; - url: string; - secret: string; - maxAttempts?: number; -}; - -export type WebhookDeliveryStatus = "pending" | "delivered" | "retrying" | "failed"; - -export type WebhookDeliveryAttempt = { - attempt: number; - status: WebhookDeliveryStatus; - statusCode?: number; - error?: string; - nextAttemptAt?: string; -}; - -export type WebhookDeliveryResult = { - eventId: string; - endpointId: string; - status: WebhookDeliveryStatus; - attempts: WebhookDeliveryAttempt[]; -}; - -export type WebhookDeliveryOptions = { - fetcher?: typeof fetch; - now?: () => Date; - sleep?: (ms: number) => Promise; - rng?: () => number; - baseDelayMs?: number; - maxDelayMs?: number; - defaultMaxAttempts?: number; -}; - -const SIGNATURE_VERSION = "v1"; -const DEFAULT_MAX_ATTEMPTS = 5; -const DEFAULT_BASE_DELAY_MS = 1_000; -const DEFAULT_MAX_DELAY_MS = 60_000; - -const encoder = new TextEncoder(); - -function toHex(buffer: ArrayBuffer): string { - return [...new Uint8Array(buffer)] - .map((byte) => byte.toString(16).padStart(2, "0")) - .join(""); -} - -function stableSerialize(value: unknown): string { - if (value === null || typeof value !== "object") { - return JSON.stringify(value); - } - - if (Array.isArray(value)) { - return `[${value.map(stableSerialize).join(",")}]`; - } - - return `{${Object.entries(value as Record) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, nested]) => `${JSON.stringify(key)}:${stableSerialize(nested)}`) - .join(",")}}`; -} - -async function hmacSha256(secret: string, message: string): Promise { - const key = await crypto.subtle.importKey( - "raw", - encoder.encode(secret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"] - ); - const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(message)); - return toHex(signature); -} - -export function serializeWebhookEvent(event: WebhookEvent): string { - return stableSerialize(event); -} - -export async function signWebhookPayload( - secret: string, - timestamp: string, - body: string -): Promise { - const digest = await hmacSha256(secret, `${timestamp}.${body}`); - return `${SIGNATURE_VERSION}=${digest}`; -} - -export async function verifyWebhookSignature( - secret: string, - timestamp: string, - body: string, - signatureHeader: string -): Promise { - const expected = await signWebhookPayload(secret, timestamp, body); - const expectedBytes = encoder.encode(expected); - const actualBytes = encoder.encode(signatureHeader); - - if (expectedBytes.length !== actualBytes.length) return false; - - let diff = 0; - for (let index = 0; index < expectedBytes.length; index += 1) { - diff |= expectedBytes[index] ^ actualBytes[index]; - } - return diff === 0; -} - -export class WebhookDeliveryService { - private readonly fetcher: typeof fetch; - private readonly now: () => Date; - private readonly sleep: (ms: number) => Promise; - private readonly rng: () => number; - private readonly baseDelayMs: number; - private readonly maxDelayMs: number; - private readonly defaultMaxAttempts: number; - - constructor(options: WebhookDeliveryOptions = {}) { - this.fetcher = options.fetcher ?? fetch; - this.now = options.now ?? (() => new Date()); - this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); - this.rng = options.rng ?? Math.random; - this.baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS; - this.maxDelayMs = options.maxDelayMs ?? DEFAULT_MAX_DELAY_MS; - this.defaultMaxAttempts = options.defaultMaxAttempts ?? DEFAULT_MAX_ATTEMPTS; - } - - async deliver(event: WebhookEvent, endpoint: WebhookEndpoint): Promise { - const body = serializeWebhookEvent(event); - const attempts: WebhookDeliveryAttempt[] = []; - const maxAttempts = endpoint.maxAttempts ?? this.defaultMaxAttempts; - - for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - const timestamp = this.now().toISOString(); - const signature = await signWebhookPayload(endpoint.secret, timestamp, body); - - try { - const response = await this.fetcher(endpoint.url, { - method: "POST", - headers: { - "content-type": "application/json", - "user-agent": "utility-frontend-webhook-delivery/1.0", - "x-webhook-event-id": event.id, - "x-webhook-event-type": event.type, - "x-webhook-signature": signature, - "x-webhook-timestamp": timestamp, - }, - body, - }); - - if (response.ok) { - attempts.push({ attempt, status: "delivered", statusCode: response.status }); - return { eventId: event.id, endpointId: endpoint.id, status: "delivered", attempts }; - } - - const delay = attempt === maxAttempts ? undefined : this.retryDelay(attempt); - attempts.push({ - attempt, - status: attempt === maxAttempts ? "failed" : "retrying", - statusCode: response.status, - nextAttemptAt: delay === undefined ? undefined : this.nextAttemptAt(delay), - }); - } catch (error) { - const delay = attempt === maxAttempts ? undefined : this.retryDelay(attempt); - attempts.push({ - attempt, - status: attempt === maxAttempts ? "failed" : "retrying", - error: error instanceof Error ? error.message : "Unknown webhook delivery error", - nextAttemptAt: delay === undefined ? undefined : this.nextAttemptAt(delay), - }); - } - - const lastAttempt = attempts.at(-1); - if (attempt < maxAttempts && lastAttempt?.nextAttemptAt) { - await this.sleep(new Date(lastAttempt.nextAttemptAt).getTime() - this.now().getTime()); - } - } - - return { eventId: event.id, endpointId: endpoint.id, status: "failed", attempts }; - } - - private retryDelay(attempt: number): number { - return fullJitterBackoff(attempt - 1, this.baseDelayMs, this.maxDelayMs, this.rng); - } - - private nextAttemptAt(delayMs: number): string { - return new Date(this.now().getTime() + delayMs).toISOString(); - } -} diff --git a/src/store/slices/crdtSlice.ts b/src/store/slices/crdtSlice.ts deleted file mode 100644 index 7bfc4c9..0000000 --- a/src/store/slices/crdtSlice.ts +++ /dev/null @@ -1,170 +0,0 @@ -"use client"; - -import { useSyncExternalStore } from "react"; -import { orSetValues } from "@/utils/crdtMerge"; -import { - RECONCILE_TIMEOUT_MS, - type ChainId, - type ResourceDiff, - type ResourceState, - type VectorClock, -} from "@/types/crdt"; - -/** - * Store holding the merged CRDT state, per-resource vector clocks, and the last - * time each chain produced an update. A staleness watcher flags any chain that - * has been silent past the reconcile timeout for a full state re-fetch. - * - * Custom singleton store, matching the codebase pattern. - */ - -export interface CrdtState { - resources: Record; - vectorClocks: Record; - /** Wall-clock ms each chain was last seen. */ - lastSeen: Partial>; - /** Chains currently flagged for a full snapshot re-fetch. */ - reconciling: ChainId[]; -} - -export type CrdtAction = - | { - type: "APPLY_DIFFS"; - payload: { - diffs: ResourceDiff[]; - chainSeen: Partial>; - at: number; - }; - } - | { type: "CHAIN_SEEN"; payload: { chainId: ChainId; at: number } } - | { type: "RECONCILE"; payload: { chainId: ChainId } } - | { type: "RECONCILE_DONE"; payload: { chainId: ChainId } } - | { type: "RESET" }; - -const initialState: CrdtState = { - resources: {}, - vectorClocks: {}, - lastSeen: {}, - reconciling: [], -}; - -/** Chains whose last update is older than the reconcile timeout. */ -export function findStaleChains( - lastSeen: Partial>, - now: number, - timeoutMs: number = RECONCILE_TIMEOUT_MS -): ChainId[] { - const stale: ChainId[] = []; - for (const [chainId, seenAt] of Object.entries(lastSeen) as [ChainId, number][]) { - if (now - seenAt > timeoutMs) stale.push(chainId); - } - return stale; -} - -type Listener = (state: CrdtState) => void; - -class CrdtStore { - private state: CrdtState = initialState; - private listeners = new Set(); - - getState = (): Readonly => this.state; - - subscribe = (listener: Listener): (() => void) => { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - }; - - dispatch(action: CrdtAction): void { - const next = this.reducer(this.state, action); - if (next !== this.state) { - this.state = next; - this.notify(); - } - } - - private reducer(state: CrdtState, action: CrdtAction): CrdtState { - switch (action.type) { - case "APPLY_DIFFS": { - const { diffs, chainSeen, at } = action.payload; - if (diffs.length === 0 && Object.keys(chainSeen).length === 0) return state; - const resources = { ...state.resources }; - const vectorClocks = { ...state.vectorClocks }; - for (const diff of diffs) { - resources[diff.resourceId] = diff.state; - vectorClocks[diff.resourceId] = diff.vectorClock; - } - const lastSeen = { ...state.lastSeen }; - for (const chainId of Object.keys(chainSeen) as ChainId[]) { - lastSeen[chainId] = at; - } - // Any chain we just heard from is no longer stale. - const reconciling = state.reconciling.filter( - (c) => !(c in chainSeen) - ); - return { ...state, resources, vectorClocks, lastSeen, reconciling }; - } - case "CHAIN_SEEN": - return { - ...state, - lastSeen: { ...state.lastSeen, [action.payload.chainId]: action.payload.at }, - reconciling: state.reconciling.filter((c) => c !== action.payload.chainId), - }; - case "RECONCILE": - if (state.reconciling.includes(action.payload.chainId)) return state; - return { - ...state, - reconciling: [...state.reconciling, action.payload.chainId], - }; - case "RECONCILE_DONE": - return { - ...state, - reconciling: state.reconciling.filter((c) => c !== action.payload.chainId), - }; - case "RESET": - return initialState; - default: - return state; - } - } - - private notify(): void { - for (const listener of this.listeners) listener(this.state); - } -} - -/** Shared singleton CRDT store. */ -export const crdtStore = new CrdtStore(); - -/** Flag any stale chains for reconciliation; returns the chains flagged. */ -export function runStalenessWatch( - now: number, - timeoutMs: number = RECONCILE_TIMEOUT_MS -): ChainId[] { - const stale = findStaleChains(crdtStore.getState().lastSeen, now, timeoutMs); - for (const chainId of stale) { - crdtStore.dispatch({ type: "RECONCILE", payload: { chainId } }); - } - return stale; -} - -// --- Selectors / bindings --------------------------------------------------- - -/** Materialised value of a resource (scalar for LWW, array for OR-set). */ -export function selectResourceValue( - state: CrdtState, - resourceId: string -): unknown { - const resource = state.resources[resourceId]; - if (!resource) return undefined; - return resource.kind === "lww" - ? resource.value - : orSetValues(resource as Parameters[0]); -} - -export function useCrdtState(): CrdtState { - return useSyncExternalStore( - crdtStore.subscribe, - crdtStore.getState, - crdtStore.getState - ); -} diff --git a/src/store/slices/notificationSlice.ts b/src/store/slices/notificationSlice.ts deleted file mode 100644 index 4f82fbd..0000000 --- a/src/store/slices/notificationSlice.ts +++ /dev/null @@ -1,151 +0,0 @@ -"use client"; - -import { useSyncExternalStore } from "react"; -import { coalescenceKey } from "@/utils/topicRouter"; -import { - COALESCENCE_WINDOW_MS, - MAX_NOTIFICATION_ACTIONS, - type AppNotification, - type PushPayload, -} from "@/types/notification"; - -/** - * In-app notification queue with 60-second coalescence. - * - * Identical events (same {@link coalescenceKey}) within the window increment a - * count on the existing notification and extend its auto-dismiss timer instead - * of creating duplicates. Implemented as a custom store (matching the codebase - * pattern) consumed via {@link useNotifications}. Timers are injectable so the - * coalescence behaviour is testable with fake clocks. - */ - -interface Entry { - notification: AppNotification; - timer: ReturnType | null; -} - -export interface NotificationStoreDeps { - setTimeoutFn?: (cb: () => void, ms: number) => ReturnType; - clearTimeoutFn?: (id: ReturnType) => void; - now?: () => number; - window?: number; -} - -type Listener = (notifications: AppNotification[]) => void; - -const EMPTY: AppNotification[] = []; - -export class NotificationStore { - private entries = new Map(); - private listeners = new Set(); - /** Cached, referentially-stable snapshot for useSyncExternalStore. */ - private snapshot: AppNotification[] = EMPTY; - - private readonly setTimeoutFn: NonNullable; - private readonly clearTimeoutFn: NonNullable; - private readonly now: () => number; - private readonly window: number; - - constructor(deps: NotificationStoreDeps = {}) { - this.setTimeoutFn = - deps.setTimeoutFn ?? ((cb, ms) => setTimeout(cb, ms)); - this.clearTimeoutFn = deps.clearTimeoutFn ?? ((id) => clearTimeout(id)); - this.now = deps.now ?? Date.now; - this.window = deps.window ?? COALESCENCE_WINDOW_MS; - } - - getState = (): AppNotification[] => this.snapshot; - - subscribe = (listener: Listener): (() => void) => { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - }; - - /** - * Ingest a push payload. Coalesces into an existing entry when one with the - * same key is live, otherwise creates a new one. Returns the coalescence key. - */ - receive(payload: PushPayload): string { - const key = coalescenceKey(payload.topic, payload.body); - const t = this.now(); - const existing = this.entries.get(key); - - if (existing) { - if (existing.timer !== null) this.clearTimeoutFn(existing.timer); - existing.notification = { - ...existing.notification, - // Refresh display fields to the latest occurrence. - title: payload.title, - body: payload.body, - data: payload.data, - actions: payload.actions?.slice(0, MAX_NOTIFICATION_ACTIONS), - lastSeenAt: t, - count: existing.notification.count + 1, - }; - existing.timer = this.arm(key); - } else { - const notification: AppNotification = { - id: key, - topic: payload.topic, - title: payload.title, - body: payload.body, - data: payload.data, - actions: payload.actions?.slice(0, MAX_NOTIFICATION_ACTIONS), - firstSeenAt: t, - lastSeenAt: t, - count: 1, - }; - this.entries.set(key, { notification, timer: this.arm(key) }); - } - - this.commit(); - return key; - } - - /** Manually dismiss a notification (e.g. operator acknowledged it). */ - dismiss(key: string): void { - const entry = this.entries.get(key); - if (!entry) return; - if (entry.timer !== null) this.clearTimeoutFn(entry.timer); - this.entries.delete(key); - this.commit(); - } - - /** Remove all notifications and cancel their timers. */ - clear(): void { - for (const entry of this.entries.values()) { - if (entry.timer !== null) this.clearTimeoutFn(entry.timer); - } - this.entries.clear(); - this.commit(); - } - - /** Current coalescence count for a key (0 if absent). */ - countFor(key: string): number { - return this.entries.get(key)?.notification.count ?? 0; - } - - private arm(key: string): ReturnType { - return this.setTimeoutFn(() => this.dismiss(key), this.window); - } - - /** Recompute the cached snapshot (newest first) and notify subscribers. */ - private commit(): void { - const list = Array.from(this.entries.values(), (e) => e.notification); - list.sort((a, b) => b.lastSeenAt - a.lastSeenAt); - this.snapshot = list.length === 0 ? EMPTY : list; - for (const listener of this.listeners) listener(this.snapshot); - } -} - -/** Shared singleton notification store. */ -export const notificationStore = new NotificationStore(); - -/** React binding: subscribe a component to the coalesced notification queue. */ -export function useNotifications(): AppNotification[] { - return useSyncExternalStore( - notificationStore.subscribe, - notificationStore.getState, - notificationStore.getState - ); -} diff --git a/src/tests/merkleWasm.bench.ts b/src/tests/merkleWasm.bench.ts deleted file mode 100644 index 7297ea1..0000000 --- a/src/tests/merkleWasm.bench.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, it, expect, beforeAll } from 'vitest'; -import { MerkleWasm } from '../utils/merkleWasm'; -import fs from 'fs'; -import path from 'path'; - -// Mock fetch for WASM loader in jsdom environment -global.fetch = async (url: RequestInfo | URL) => { - if (url.toString().includes('merkle_wasm.wasm')) { - const wasmPath = path.resolve(__dirname, '../../public/wasm/merkle_wasm.wasm'); - const buffer = fs.readFileSync(wasmPath); - return { - arrayBuffer: async () => buffer, - ok: true - } as unknown as Response; - } - throw new Error('Unexpected fetch in tests: ' + url); -}; - -// Mock instantiateStreaming since jsdom might not support it properly with our mocked fetch -global.WebAssembly.instantiateStreaming = async (response: Response | PromiseLike, importObject?: WebAssembly.Imports) => { - const res = await response; - const buffer = await res.arrayBuffer(); - return WebAssembly.instantiate(buffer, importObject); -}; - -describe('MerkleWasm Benchmark', () => { - let wasm: MerkleWasm; - - beforeAll(async () => { - wasm = new MerkleWasm(); - await wasm.init(); - }); - - it('should generate 1,000 leaves and measure hashes/sec > 15,000', () => { - const numLeaves = 1000; - const leaves = new Uint8Array(numLeaves * 64); - for (let i = 0; i < leaves.length; i++) { - leaves[i] = Math.floor(Math.random() * 256); - } - - const iterations = 50; - const hashesPerTree = numLeaves * 2 - 1; - const totalHashes = hashesPerTree * iterations; - - const start = performance.now(); - - let lastRoot: Uint8Array | null = null; - let rootPtr = 0; - for (let i = 0; i < iterations; i++) { - const res = wasm.buildTree(leaves); - lastRoot = res.root; - rootPtr = res.rootPtr; - } - - const end = performance.now(); - const durationSec = (end - start) / 1000; - const hashesPerSec = totalHashes / durationSec; - - // eslint-disable-next-line no-console - console.log(`Merkle Tree Construction: ${hashesPerSec.toFixed(2)} hashes/sec over ${iterations} iterations`); - - expect(hashesPerSec).toBeGreaterThanOrEqual(15000); - - const proofStart = performance.now(); - const proof = wasm.generateProof(rootPtr, 500); - const proofEnd = performance.now(); - - // eslint-disable-next-line no-console - console.log(`Proof generation took ${(proofEnd - proofStart).toFixed(2)} ms`); - expect(proofEnd - proofStart).toBeLessThan(70); - - const leafData = leaves.slice(500 * 64, 501 * 64); - const isValid = wasm.verifyProof(lastRoot!, proof, leafData); - expect(isValid).toBe(true); - }); -}); diff --git a/src/utils/aggregation.ts b/src/utils/aggregation.ts deleted file mode 100644 index 4e448dd..0000000 --- a/src/utils/aggregation.ts +++ /dev/null @@ -1,118 +0,0 @@ -/** - * Aggregation over metered readings using exact {@link Decimal} arithmetic. - * - * Every intermediate stays a Decimal — values are never cast back to `number` - * mid-pipeline — so daily totals match the per-reading inputs to the last unit - * (no cumulative drift). - */ - -import { - add, - average as decAverage, - decimal, - max as decMax, - min as decMin, - mul, - sum as decSum, - type Decimal, - type Precision, -} from "@/utils/decimal"; -import { - RESOURCE_PRECISION, - RESOURCE_UNIT, - type MeterReading, - type ResourceConsumption, - type ResourceKind, - type TariffRate, -} from "@/types/meter"; - -function valuesOf(readings: MeterReading[]): Decimal[] { - return readings.map((r) => r.value); -} - -/** Exact total of a set of readings (zero at the resource precision if empty). */ -export function sumReadings( - readings: MeterReading[], - precision: Precision = 0 -): Decimal { - return decSum(valuesOf(readings), precision); -} - -/** Mean of a set of readings. Throws on an empty list. */ -export function averageReadings(readings: MeterReading[]): Decimal { - return decAverage(valuesOf(readings)); -} - -/** Smallest reading value, or null for an empty list. */ -export function minReading(readings: MeterReading[]): Decimal | null { - const values = valuesOf(readings); - if (values.length === 0) return null; - return values.reduce((m, v) => decMin(m, v)); -} - -/** Largest reading value, or null for an empty list. */ -export function maxReading(readings: MeterReading[]): Decimal | null { - const values = valuesOf(readings); - if (values.length === 0) return null; - return values.reduce((m, v) => decMax(m, v)); -} - -/** Running cumulative totals: out[i] = sum(values[0..i]). */ -export function cumulativeTotals(values: Decimal[]): Decimal[] { - const out: Decimal[] = []; - let acc: Decimal | null = null; - for (const v of values) { - acc = acc === null ? v : add(acc, v); - out.push(acc); - } - return out; -} - -/** Fold readings of one resource into a {@link ResourceConsumption}. */ -export function aggregateReadings( - resource: K, - readings: MeterReading[] -): ResourceConsumption { - return { - resource, - total: sumReadings(readings, RESOURCE_PRECISION[resource]), - count: readings.length, - unit: RESOURCE_UNIT[resource], - }; -} - -/** - * Bucket readings into fixed-width time windows keyed by the window start - * (`floor(timestamp / windowMs) * windowMs`), returning a sorted list of - * per-window consumption totals. - */ -export function aggregateByWindow( - resource: K, - readings: MeterReading[], - windowMs: number -): { windowStart: number; consumption: ResourceConsumption }[] { - if (windowMs <= 0) throw new Error("windowMs must be positive"); - const buckets = new Map(); - for (const r of readings) { - const start = Math.floor(r.timestamp / windowMs) * windowMs; - const list = buckets.get(start); - if (list) list.push(r); - else buckets.set(start, [r]); - } - return [...buckets.entries()] - .sort((a, b) => a[0] - b[0]) - .map(([windowStart, group]) => ({ - windowStart, - consumption: aggregateReadings(resource, group), - })); -} - -/** - * Apply a tariff: cost = total × ratePerUnit, returned at currency precision. - * Computed exactly, then quantized once to 2 decimals. - */ -export function applyTariff(total: Decimal, rate: TariffRate): Decimal<2> { - const cost = mul(total, rate.ratePerUnit); - // Re-wrap at currency precision so the cost serializes to 2 decimals. - return decimal(cost.toFixed(), 2); -} diff --git a/src/utils/aggregation/ResourceUnits.ts b/src/utils/aggregation/ResourceUnits.ts deleted file mode 100644 index 6bb3755..0000000 --- a/src/utils/aggregation/ResourceUnits.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * Fixed-point resource quantity backed by BigInt. - * - * The value is stored as an integer `raw` interpreted as `raw / 10^scale`, so - * additions and integer multiplications are exact regardless of magnitude. The - * only lossy operation is {@link ResourceUnits.toNumber} / {@link toDisplay}, - * which is the single, deliberate conversion-to-`Number` at the tail of the - * aggregation pipeline. - * - * ES2017 target → BigInt literals are unavailable; `BigInt()` is used. - */ - -const TEN = BigInt(10); - -function pow10(n: number): bigint { - let result = BigInt(1); - for (let i = 0; i < n; i++) result *= TEN; - return result; -} - -export class ResourceUnits { - /** @param raw integer value scaled by 10^scale. @param scale fractional digits. */ - constructor(readonly raw: bigint, readonly scale: number = 18) { - if (scale < 0 || !Number.isInteger(scale)) { - throw new Error(`scale must be a non-negative integer, got ${scale}`); - } - } - - static zero(scale = 18): ResourceUnits { - return new ResourceUnits(BigInt(0), scale); - } - - /** Wrap an exact integer count of base units at the given scale. */ - static fromBaseUnits(units: bigint, scale = 18): ResourceUnits { - return new ResourceUnits(units * pow10(scale), scale); - } - - /** Re-express this value at a different scale (down-scaling floors). */ - rescale(targetScale: number): ResourceUnits { - if (targetScale === this.scale) return this; - if (targetScale > this.scale) { - return new ResourceUnits(this.raw * pow10(targetScale - this.scale), targetScale); - } - return new ResourceUnits(this.raw / pow10(this.scale - targetScale), targetScale); - } - - /** Exact addition; operands are aligned to the higher scale. */ - add(other: ResourceUnits): ResourceUnits { - const scale = Math.max(this.scale, other.scale); - const a = this.rescale(scale); - const b = other.rescale(scale); - return new ResourceUnits(a.raw + b.raw, scale); - } - - /** Exact multiplication by an integer factor (BigInt or integer Number). */ - multiply(factor: bigint | number): ResourceUnits { - let f: bigint; - if (typeof factor === "bigint") { - f = factor; - } else { - if (!Number.isInteger(factor)) { - throw new Error(`multiply expects an integer factor, got ${factor}`); - } - f = BigInt(factor); - } - return new ResourceUnits(this.raw * f, this.scale); - } - - /** The exact integer count of base units (fractional part floored). */ - toBaseUnits(): bigint { - return this.raw / pow10(this.scale); - } - - /** - * Convert to a JS number — the single lossy step. Splits into integer and - * fractional parts so large magnitudes keep their fractional precision. - */ - toNumber(): number { - const divisor = pow10(this.scale); - const whole = this.raw / divisor; - const frac = this.raw - whole * divisor; - return Number(whole) + Number(frac) / Number(divisor); - } - - /** - * Formatted string for display via `Intl.NumberFormat`. Defaults match the - * dashboard: 2–4 fraction digits. - */ - toDisplay(maximumFractionDigits = 4, minimumFractionDigits = 2): string { - return new Intl.NumberFormat(undefined, { - minimumFractionDigits, - maximumFractionDigits, - }).format(this.toNumber()); - } - - equals(other: ResourceUnits): boolean { - const scale = Math.max(this.scale, other.scale); - return this.rescale(scale).raw === other.rescale(scale).raw; - } -} diff --git a/src/utils/aggregation/resourceMath.ts b/src/utils/aggregation/resourceMath.ts deleted file mode 100644 index d7328b8..0000000 --- a/src/utils/aggregation/resourceMath.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * Exact resource-consumption aggregation. - * - * All conversion and summation happens in BigInt space; the result is an exact - * integer count of base resource units. A precision audit also computes the old - * `Number`-arithmetic total and logs a warning when its relative error exceeds - * {@link PRECISION_THRESHOLD} — observability for the drift this module fixes. - */ - -import { - RESOURCE_TYPES, - convertToBase, - convertToBaseApprox, - type ResourceType, -} from "@/utils/aggregation/unitConversion"; -import { ResourceUnits } from "@/utils/aggregation/ResourceUnits"; - -/** Maximum readings folded into a single aggregation window. */ -export const MAX_READINGS_PER_WINDOW = 10_000; -/** Relative-error budget: |displayed − exact| / exact must stay below this. */ -export const PRECISION_THRESHOLD = 1e-5; - -export interface Reading { - resource: ResourceType; - /** Raw integer meter reading (micro-units). */ - value: bigint | number | string; -} - -export interface AggregationResult { - /** Exact total in base resource units. */ - total: ResourceUnits; - /** Exact integer base units. */ - exactBase: bigint; - /** Old `Number`-pipeline total, retained for the audit comparison. */ - approxBase: number; - /** `|approxBase − exactBase| / exactBase` (0 when exact is 0). */ - relativeError: number; - /** Exact per-resource subtotals. */ - byResource: Record; - readingCount: number; -} - -export interface AggregateOptions { - /** Injectable logger (defaults to console) for the precision audit. */ - logger?: Pick; -} - -/** Relative error between a `Number` approximation and a BigInt exact value. */ -export function relativeError(approx: number, exact: bigint): number { - if (exact === BigInt(0)) return approx === 0 ? 0 : Infinity; - const exactNum = Number(exact); - return Math.abs(approx - exactNum) / Math.abs(exactNum); -} - -function emptyByResource(): Record { - return { water: BigInt(0), energy: BigInt(0), bandwidth: BigInt(0) }; -} - -/** - * Aggregate a window of readings into an exact total. The BigInt pipeline is - * authoritative; the `Number` pipeline runs only to audit precision drift. - */ -export function aggregateReadings( - readings: Reading[], - options: AggregateOptions = {} -): AggregationResult { - const logger = options.logger ?? console; - - if (readings.length > MAX_READINGS_PER_WINDOW) { - logger.warn( - `[resourceMath] window has ${readings.length} readings, exceeding the ${MAX_READINGS_PER_WINDOW} cap` - ); - } - - const byResource = emptyByResource(); - let exactBase = BigInt(0); - let approxBase = 0; - - for (const reading of readings) { - const base = convertToBase(reading.resource, reading.value); - exactBase += base; - byResource[reading.resource] += base; - approxBase += convertToBaseApprox(reading.resource, reading.value); - } - - const relError = relativeError(approxBase, exactBase); - if (relError > PRECISION_THRESHOLD) { - logger.warn( - `[resourceMath] precision audit: Number pipeline drifted ${(relError * 100).toFixed( - 4 - )}% (approx=${approxBase}, exact=${exactBase.toString()}) — using exact BigInt total` - ); - } - - return { - total: ResourceUnits.fromBaseUnits(exactBase, 0), - exactBase, - approxBase, - relativeError: relError, - byResource, - readingCount: readings.length, - }; -} - -/** Exact per-resource totals wrapped as {@link ResourceUnits} (scale 0). */ -export function perResourceUnits( - result: AggregationResult -): Record { - const out = {} as Record; - for (const type of RESOURCE_TYPES) { - out[type] = ResourceUnits.fromBaseUnits(result.byResource[type], 0); - } - return out; -} diff --git a/src/utils/aggregation/unitConversion.ts b/src/utils/aggregation/unitConversion.ts deleted file mode 100644 index e1e6fcd..0000000 --- a/src/utils/aggregation/unitConversion.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Per-resource conversion factors for the consumption dashboard. - * - * Raw meter readings are integers (micro-units); each resource is converted to - * a common "resource unit" base by an integer factor. Conversion is done in - * BigInt so a factor like 3,600,000 never loses low-order digits the way - * `Number` multiplication does (the 0.01%-per-reading drift that accumulated to - * a 3.5% daily dashboard error). - * - * Note: this project targets ES2017, so BigInt *literals* (`1000n`) are not - * available — the `BigInt()` constructor is used throughout. - */ - -export type ResourceType = "water" | "energy" | "bandwidth"; - -/** Exact integer conversion factors (BigInt). */ -export const RESOURCE_FACTORS: Record = { - water: BigInt(1000), // 10^3 (megaliters) - energy: BigInt(3_600_000), // 3.6×10^6 (kWh → base) - bandwidth: BigInt(1_000_000_000), // 10^9 (GB) -}; - -/** The same factors as `number`, used only by the precision-audit comparison. */ -export const RESOURCE_FACTORS_NUMBER: Record = { - water: 1e3, - energy: 3.6e6, - bandwidth: 1e9, -}; - -export const RESOURCE_TYPES: ResourceType[] = ["water", "energy", "bandwidth"]; - -/** Coerce a reading value to a non-negative BigInt integer. */ -export function toBigIntReading(value: bigint | number | string): bigint { - if (typeof value === "bigint") return value; - if (typeof value === "string") return BigInt(value); - if (!Number.isFinite(value)) throw new Error(`invalid reading: ${value}`); - // Meter readings are integers; truncate any incidental fractional part. - return BigInt(Math.trunc(value)); -} - -/** Convert a raw reading to exact base resource units (BigInt). */ -export function convertToBase( - resource: ResourceType, - rawReading: bigint | number | string -): bigint { - return toBigIntReading(rawReading) * RESOURCE_FACTORS[resource]; -} - -/** Convert a raw reading to base units using lossy `Number` math (audit only). */ -export function convertToBaseApprox( - resource: ResourceType, - rawReading: bigint | number | string -): number { - return Number(rawReading) * RESOURCE_FACTORS_NUMBER[resource]; -} diff --git a/src/utils/backoff.ts b/src/utils/backoff.ts deleted file mode 100644 index 7eeb4c9..0000000 --- a/src/utils/backoff.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Capped exponential backoff with full-jitter randomization. - * - * Full jitter (per AWS's "Exponential Backoff And Jitter") spreads retries - * uniformly across `[0, ceiling]` instead of clustering them at a fixed delay, - * which prevents synchronized clients from forming reconnect storms against a - * load-balanced backend. - */ - -import { - BACKOFF_MULTIPLIER, - INITIAL_BACKOFF_MS, - MAX_BACKOFF_MS, -} from "@/types/connection"; - -/** - * The deterministic ceiling for a given attempt: `min(cap, base * mult^attempt)`. - * `attempt` is zero-based (attempt 0 is the first retry). - */ -export function backoffCeiling( - attempt: number, - base: number = INITIAL_BACKOFF_MS, - cap: number = MAX_BACKOFF_MS, - multiplier: number = BACKOFF_MULTIPLIER -): number { - if (attempt < 0) return 0; - // Guard against Infinity from a huge exponent before the min() clamp. - const exp = Math.pow(multiplier, attempt); - const raw = Number.isFinite(exp) ? base * exp : Infinity; - return Math.min(cap, raw); -} - -/** - * Full-jitter delay for an attempt: a uniform random value in - * `[0, backoffCeiling(attempt)]`. `rng` is injectable for deterministic tests - * (defaults to `Math.random`). - */ -export function fullJitterBackoff( - attempt: number, - base: number = INITIAL_BACKOFF_MS, - cap: number = MAX_BACKOFF_MS, - rng: () => number = Math.random -): number { - const ceiling = backoffCeiling(attempt, base, cap); - // Clamp rng into [0, 1) defensively, then scale and round to whole ms. - const r = Math.min(0.999999, Math.max(0, rng())); - return Math.round(r * ceiling); -} diff --git a/src/utils/cameraMatrix.ts b/src/utils/cameraMatrix.ts deleted file mode 100644 index 95eeb9a..0000000 --- a/src/utils/cameraMatrix.ts +++ /dev/null @@ -1,135 +0,0 @@ -/** - * Minimal column-major 4×4 camera matrix math (perspective, look-at, multiply) - * plus a web-mercator meter projection. Kept Three.js-free so the view-frustum - * derived here can be built and tested without a WebGL context — the QuadTree - * culling layer consumes the resulting view-projection matrix directly. - * - * All matrices are 16-element, column-major (element (row r, col c) = m[c*4+r]). - */ - -import type { Coordinate3D } from "@/types/spatial"; - -/** Earth radius (m) for the spherical web-mercator approximation. */ -const EARTH_RADIUS_M = 6_378_137; - -export type Mat4 = number[]; - -function normalize(v: Coordinate3D): Coordinate3D { - const len = Math.hypot(v.x, v.y, v.z) || 1; - return { x: v.x / len, y: v.y / len, z: v.z / len }; -} -function cross(a: Coordinate3D, b: Coordinate3D): Coordinate3D { - return { - x: a.y * b.z - a.z * b.y, - y: a.z * b.x - a.x * b.z, - z: a.x * b.y - a.y * b.x, - }; -} -function dot(a: Coordinate3D, b: Coordinate3D): number { - return a.x * b.x + a.y * b.y + a.z * b.z; -} - -/** Perspective projection (column-major). `fovY` in radians. */ -export function perspective( - fovY: number, - aspect: number, - near: number, - far: number -): Mat4 { - const f = 1 / Math.tan(fovY / 2); - const nf = 1 / (near - far); - const m = new Array(16).fill(0); - m[0] = f / aspect; - m[5] = f; - m[10] = (far + near) * nf; - m[11] = -1; - m[14] = 2 * far * near * nf; - return m; -} - -/** Right-handed view matrix (gluLookAt), column-major. */ -export function lookAt( - eye: Coordinate3D, - center: Coordinate3D, - up: Coordinate3D -): Mat4 { - const z = normalize({ x: eye.x - center.x, y: eye.y - center.y, z: eye.z - center.z }); - const x = normalize(cross(up, z)); - const y = cross(z, x); - return [ - x.x, y.x, z.x, 0, - x.y, y.y, z.y, 0, - x.z, y.z, z.z, 0, - -dot(x, eye), -dot(y, eye), -dot(z, eye), 1, - ]; -} - -/** Column-major 4×4 multiply: returns `a · b`. */ -export function multiply(a: Mat4, b: Mat4): Mat4 { - const out = new Array(16).fill(0); - for (let c = 0; c < 4; c++) { - for (let r = 0; r < 4; r++) { - let s = 0; - for (let k = 0; k < 4; k++) { - s += a[k * 4 + r] * b[c * 4 + k]; - } - out[c * 4 + r] = s; - } - } - return out; -} - -export interface CameraMatrixInput { - /** Camera position in world space (meters). */ - position: Coordinate3D; - /** Heading in degrees, clockwise from north. */ - heading: number; - /** Pitch in degrees below the horizon. */ - pitch: number; - /** Vertical field of view in degrees. @default 60 */ - fov?: number; - aspect: number; - near?: number; - far?: number; -} - -/** World-space forward vector for a heading/pitch (x east, y north, z up). */ -export function forwardVector(headingDeg: number, pitchDeg: number): Coordinate3D { - const h = (headingDeg * Math.PI) / 180; - const p = (pitchDeg * Math.PI) / 180; - return { - x: Math.sin(h) * Math.cos(p), - y: Math.cos(h) * Math.cos(p), - z: -Math.sin(p), - }; -} - -/** Build the column-major view-projection matrix for a camera. */ -export function buildViewProjection(input: CameraMatrixInput): Mat4 { - const { position, heading, pitch, fov = 60, aspect, near = 1, far = 2000 } = input; - const forward = forwardVector(heading, pitch); - const center = { - x: position.x + forward.x, - y: position.y + forward.y, - z: position.z + forward.z, - }; - const proj = perspective((fov * Math.PI) / 180, aspect, near, far); - const view = lookAt(position, center, { x: 0, y: 0, z: 1 }); - return multiply(proj, view); -} - -/** - * Project lng/lat to local world meters relative to a reference point using a - * spherical web-mercator approximation. Accurate to well under a meter across a - * 100 km region, which is sufficient for LOD bucketing. - */ -export function mercatorMeters( - lng: number, - lat: number, - ref: { lng: number; lat: number } = { lng: 0, lat: 0 } -): { x: number; y: number } { - const toRad = Math.PI / 180; - const x = EARTH_RADIUS_M * (lng - ref.lng) * toRad * Math.cos(ref.lat * toRad); - const y = EARTH_RADIUS_M * (lat - ref.lat) * toRad; - return { x, y }; -} diff --git a/src/utils/capacityPlanning.ts b/src/utils/capacityPlanning.ts deleted file mode 100644 index a40c5c1..0000000 --- a/src/utils/capacityPlanning.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { aggregateReadings, type Reading } from "@/utils/aggregation/resourceMath"; -import { RESOURCE_TYPES, type ResourceType } from "@/utils/aggregation/unitConversion"; - -export interface HistoricalReading extends Reading { - /** Unix milliseconds. */ - timestamp: number; -} - -export interface CapacityLimit { - resource: ResourceType; - /** Provisioned capacity in base resource units for one planning window. */ - capacityBase: bigint | number | string; -} - -export interface CapacityForecastOptions { - /** Width of each historical bucket. */ - windowMs: number; - /** Number of future windows to project. */ - horizonWindows: number; - /** Optional growth buffer applied after trend projection. @default 0.15 */ - safetyMargin?: number; -} - -export interface ResourceForecast { - resource: ResourceType; - currentBase: bigint; - projectedBase: bigint; - recommendedCapacityBase: bigint; - utilization: number; - projectedUtilization: number; - trendPerWindowBase: bigint; - windowsObserved: number; - exhaustedAtWindow: number | null; - status: "healthy" | "watch" | "critical"; -} - -export interface CapacityPlan { - generatedAt: number; - windowMs: number; - horizonWindows: number; - forecasts: Record; -} - -function toBaseCapacity(value: bigint | number | string): bigint { - if (typeof value === "bigint") return value; - if (typeof value === "number") return BigInt(Math.trunc(value)); - return BigInt(value); -} - -function bucketReadings(readings: HistoricalReading[], windowMs: number) { - const buckets = new Map(); - for (const reading of readings) { - const windowStart = Math.floor(reading.timestamp / windowMs) * windowMs; - const bucket = buckets.get(windowStart); - if (bucket) bucket.push(reading); - else buckets.set(windowStart, [reading]); - } - return [...buckets.entries()].sort((a, b) => a[0] - b[0]); -} - -function positiveTrend(values: bigint[]): bigint { - if (values.length < 2) return BigInt(0); - let totalIncrease = BigInt(0); - let samples = BigInt(0); - for (let i = 1; i < values.length; i++) { - const delta = values[i] - values[i - 1]; - if (delta > BigInt(0)) totalIncrease += delta; - samples += BigInt(1); - } - return totalIncrease / samples; -} - -function utilization(used: bigint, capacity: bigint): number { - if (capacity <= BigInt(0)) return used > BigInt(0) ? Infinity : 0; - return Number(used) / Number(capacity); -} - -function applySafetyMargin(value: bigint, safetyMargin: number): bigint { - const basisPoints = BigInt(Math.ceil(safetyMargin * 10_000)); - const denominator = BigInt(10_000); - return value + (value * basisPoints + denominator - BigInt(1)) / denominator; -} - -function statusFor(projectedUtilization: number): ResourceForecast["status"] { - if (projectedUtilization >= 0.95) return "critical"; - if (projectedUtilization >= 0.8) return "watch"; - return "healthy"; -} - -/** - * Build a deterministic capacity plan from historical readings. - * - * The hot path is O(readings + resources × windows) and reuses the exact BigInt - * aggregation pipeline so trends cannot drift as history grows. - */ -export function planCapacity( - readings: HistoricalReading[], - limits: CapacityLimit[], - options: CapacityForecastOptions -): CapacityPlan { - if (options.windowMs <= 0) throw new Error("windowMs must be positive"); - if (options.horizonWindows < 0) throw new Error("horizonWindows cannot be negative"); - - const safetyMargin = options.safetyMargin ?? 0.15; - if (safetyMargin < 0) throw new Error("safetyMargin cannot be negative"); - - const capacityByResource = new Map( - limits.map((limit) => [limit.resource, toBaseCapacity(limit.capacityBase)] as const) - ); - const buckets = bucketReadings(readings, options.windowMs); - const forecasts = {} as Record; - - for (const resource of RESOURCE_TYPES) { - const series = buckets.map(([, bucket]) => - aggregateReadings(bucket.filter((reading) => reading.resource === resource), { - logger: { warn: () => {} }, - }).byResource[resource] - ); - const currentBase = series.at(-1) ?? BigInt(0); - const trendPerWindowBase = positiveTrend(series); - const projectedBase = currentBase + trendPerWindowBase * BigInt(options.horizonWindows); - const recommendedCapacityBase = applySafetyMargin(projectedBase, safetyMargin); - const capacityBase = capacityByResource.get(resource) ?? recommendedCapacityBase; - const projectedUtilization = utilization(projectedBase, capacityBase); - const exhaustedAtWindow = - trendPerWindowBase > BigInt(0) && currentBase < capacityBase - ? Number((capacityBase - currentBase + trendPerWindowBase - BigInt(1)) / trendPerWindowBase) - : currentBase >= capacityBase - ? 0 - : null; - - forecasts[resource] = { - resource, - currentBase, - projectedBase, - recommendedCapacityBase, - utilization: utilization(currentBase, capacityBase), - projectedUtilization, - trendPerWindowBase, - windowsObserved: series.length, - exhaustedAtWindow, - status: statusFor(projectedUtilization), - }; - } - - return { - generatedAt: Date.now(), - windowMs: options.windowMs, - horizonWindows: options.horizonWindows, - forecasts, - }; -} diff --git a/src/utils/chaosBlueprint.ts b/src/utils/chaosBlueprint.ts deleted file mode 100644 index 4b49b1e..0000000 --- a/src/utils/chaosBlueprint.ts +++ /dev/null @@ -1,153 +0,0 @@ -export type ChaosExperimentType = - | "latency" - | "network-partition" - | "dependency-failure" - | "resource-pressure" - | "restart"; - -export type ChaosService = - | "frontend" - | "api-gateway" - | "telemetry-stream" - | "wallet-adapter" - | "background-workers"; - -export interface ChaosSteadyState { - p99LatencyMs: number; - availabilityPercent: number; - errorRatePercent: number; -} - -export interface ChaosAbortCondition { - metric: keyof ChaosSteadyState | "securityFinding"; - operator: ">" | ">=" | "<" | "<=" | "="; - threshold: number | boolean; - durationSeconds: number; -} - -export interface ChaosExperiment { - id: string; - name: string; - type: ChaosExperimentType; - services: ChaosService[]; - hypothesis: string; - blastRadiusPercent: number; - durationMinutes: number; - steadyState: ChaosSteadyState; - abortConditions: ChaosAbortCondition[]; - rollback: string[]; - securityReviewRequired: boolean; - monitoringSignals: string[]; -} - -export interface ChaosBlueprint { - environment: "staging"; - p99TargetMs: number; - availabilityTargetPercent: number; - experiments: ChaosExperiment[]; - deploymentStrategy: { - mode: "blue-green-with-canary"; - canaryPercentages: number[]; - analysisWindowMinutes: number; - }; -} - -export const STAGING_CHAOS_BLUEPRINT: ChaosBlueprint = { - environment: "staging", - p99TargetMs: 100, - availabilityTargetPercent: 99.99, - deploymentStrategy: { - mode: "blue-green-with-canary", - canaryPercentages: [1, 5, 25, 50, 100], - analysisWindowMinutes: 15, - }, - experiments: [ - { - id: "frontend-latency-100ms", - name: "Frontend critical-path latency injection", - type: "latency", - services: ["frontend", "api-gateway"], - hypothesis: - "Critical user journeys remain below the 100 ms P99 budget while latency is injected at the edge.", - blastRadiusPercent: 5, - durationMinutes: 10, - steadyState: { p99LatencyMs: 100, availabilityPercent: 99.99, errorRatePercent: 0.1 }, - abortConditions: [ - { metric: "p99LatencyMs", operator: ">", threshold: 100, durationSeconds: 120 }, - { metric: "availabilityPercent", operator: "<", threshold: 99.99, durationSeconds: 60 }, - { metric: "securityFinding", operator: "=", threshold: true, durationSeconds: 0 }, - ], - rollback: ["Disable edge latency fault", "Shift traffic back to blue", "Page staging incident lead"], - securityReviewRequired: true, - monitoringSignals: ["web_vitals_p99", "api_gateway_p99", "synthetic_checkout_success"], - }, - { - id: "telemetry-stream-partition", - name: "Telemetry stream network partition", - type: "network-partition", - services: ["telemetry-stream", "background-workers"], - hypothesis: - "Buffered telemetry drains after partition recovery without dropping availability below target.", - blastRadiusPercent: 10, - durationMinutes: 15, - steadyState: { p99LatencyMs: 100, availabilityPercent: 99.99, errorRatePercent: 0.2 }, - abortConditions: [ - { metric: "availabilityPercent", operator: "<", threshold: 99.99, durationSeconds: 60 }, - { metric: "errorRatePercent", operator: ">=", threshold: 1, durationSeconds: 120 }, - ], - rollback: ["Remove partition rule", "Scale telemetry consumers", "Replay staging queue"], - securityReviewRequired: true, - monitoringSignals: ["telemetry_lag_seconds", "queue_depth", "worker_replay_success"], - }, - { - id: "wallet-adapter-dependency-failure", - name: "Wallet adapter dependency outage", - type: "dependency-failure", - services: ["wallet-adapter", "api-gateway"], - hypothesis: - "Wallet flows fail closed with user-visible recovery messaging and no leaked credentials.", - blastRadiusPercent: 5, - durationMinutes: 10, - steadyState: { p99LatencyMs: 100, availabilityPercent: 99.99, errorRatePercent: 0.1 }, - abortConditions: [ - { metric: "securityFinding", operator: "=", threshold: true, durationSeconds: 0 }, - { metric: "errorRatePercent", operator: ">=", threshold: 1, durationSeconds: 60 }, - ], - rollback: ["Restore dependency route", "Rotate staging wallet secrets if exposed", "Invalidate sessions"], - securityReviewRequired: true, - monitoringSignals: ["wallet_auth_failures", "session_error_rate", "secret_scanner_alerts"], - }, - ], -}; - -export function getExperimentsForService(service: ChaosService, blueprint = STAGING_CHAOS_BLUEPRINT) { - return blueprint.experiments.filter((experiment) => experiment.services.includes(service)); -} - -export function validateChaosBlueprint(blueprint: ChaosBlueprint): string[] { - const failures: string[] = []; - - if (blueprint.p99TargetMs > 100) failures.push("Critical path P99 target must be <= 100 ms."); - if (blueprint.availabilityTargetPercent < 99.99) failures.push("Availability target must be at least 99.99%."); - if (blueprint.deploymentStrategy.mode !== "blue-green-with-canary") { - failures.push("Deployment strategy must be blue-green with canary analysis."); - } - - for (const experiment of blueprint.experiments) { - if (!experiment.securityReviewRequired) failures.push(`${experiment.id} must require security review.`); - if (experiment.blastRadiusPercent <= 0 || experiment.blastRadiusPercent > 10) { - failures.push(`${experiment.id} blast radius must be between 1% and 10% for staging.`); - } - if (experiment.steadyState.p99LatencyMs > blueprint.p99TargetMs) { - failures.push(`${experiment.id} exceeds the blueprint P99 latency target.`); - } - if (experiment.steadyState.availabilityPercent < blueprint.availabilityTargetPercent) { - failures.push(`${experiment.id} falls below the blueprint availability target.`); - } - if (experiment.abortConditions.length === 0) failures.push(`${experiment.id} needs abort conditions.`); - if (experiment.rollback.length === 0) failures.push(`${experiment.id} needs rollback steps.`); - if (experiment.monitoringSignals.length < 3) failures.push(`${experiment.id} needs at least three monitoring signals.`); - } - - return failures; -} diff --git a/src/utils/crdtMerge.ts b/src/utils/crdtMerge.ts deleted file mode 100644 index 0dfff3b..0000000 --- a/src/utils/crdtMerge.ts +++ /dev/null @@ -1,187 +0,0 @@ -/** - * Pure CRDT merge engine. Shared by the merge worker and the tests. - * - * - LWW register: keep the value with the greater Lamport timestamp. - * - OR-set: tag-based add / tombstone remove; an element is present iff it has - * an add-tag that is not tombstoned. Both operations are commutative, - * associative and idempotent, so any interleaving of a batch converges. - */ - -import { compareTimestamp } from "@/utils/lamportClock"; -import { - type ChainId, - type CrdtEvent, - type LWWRegister, - type LamportTimestamp, - type MergeResult, - type ORSet, - type ResourceDiff, - type ResourceState, - type VectorClock, -} from "@/types/crdt"; - -const unique = (arr: T[]): T[] => [...new Set(arr)]; - -// --- LWW register ----------------------------------------------------------- - -export function mergeRegister( - a: LWWRegister, - b: LWWRegister -): LWWRegister { - return compareTimestamp(a.timestamp, b.timestamp) >= 0 ? a : b; -} - -// --- OR-set ----------------------------------------------------------------- - -export function emptyOrSet(): ORSet { - return { kind: "or-set", adds: {}, tombstones: [], values: {} }; -} - -export function orSetAdd( - set: ORSet, - element: T, - tag: string -): ORSet { - return { - kind: "or-set", - adds: { ...set.adds, [element]: unique([...(set.adds[element] ?? []), tag]) }, - tombstones: set.tombstones, - values: { ...set.values, [element]: element }, - }; -} - -/** The add-tags of `element` that are not yet tombstoned (what a remove sees). */ -export function observedTags( - set: ORSet, - element: T -): string[] { - const tombstoned = new Set(set.tombstones); - return (set.adds[element] ?? []).filter((t) => !tombstoned.has(t)); -} - -/** Tombstone a specific set of add-tags (commutative / idempotent). */ -export function orSetRemoveTags( - set: ORSet, - tags: string[] -): ORSet { - if (tags.length === 0) return set; - return { - kind: "or-set", - adds: set.adds, - tombstones: unique([...set.tombstones, ...tags]), - values: set.values, - }; -} - -/** Convenience: remove an element by tombstoning its currently-observed tags. */ -export function orSetRemove( - set: ORSet, - element: T -): ORSet { - return orSetRemoveTags(set, observedTags(set, element)); -} - -export function orSetMerge( - a: ORSet, - b: ORSet -): ORSet { - const adds: Record = { ...a.adds }; - for (const [el, tags] of Object.entries(b.adds)) { - adds[el] = unique([...(adds[el] ?? []), ...tags]); - } - return { - kind: "or-set", - adds, - tombstones: unique([...a.tombstones, ...b.tombstones]), - values: { ...a.values, ...b.values }, - }; -} - -/** Materialise the present elements of an OR-set. */ -export function orSetValues(set: ORSet): T[] { - const tombstoned = new Set(set.tombstones); - const out: T[] = []; - for (const [el, tags] of Object.entries(set.adds)) { - if (tags.some((t) => !tombstoned.has(t))) out.push(set.values[el]); - } - return out; -} - -// --- Vector clock ----------------------------------------------------------- - -function bumpClock(clock: VectorClock, ts: LamportTimestamp): VectorClock { - const current = clock[ts.chainId] ?? 0; - if (ts.counter <= current) return clock; - return { ...clock, [ts.chainId]: ts.counter }; -} - -// --- Event application ------------------------------------------------------- - -function applyEvent( - state: ResourceState | undefined, - event: CrdtEvent -): ResourceState { - switch (event.type) { - case "register-set": { - const incoming: LWWRegister = { - kind: "lww", - value: event.value, - timestamp: event.timestamp, - }; - if (state && state.kind === "lww") return mergeRegister(state, incoming); - return incoming; - } - case "or-set-add": { - const set = - state && state.kind === "or-set" - ? (state as ORSet) - : emptyOrSet(); - return orSetAdd(set, event.element, event.tag); - } - case "or-set-remove": { - const set = - state && state.kind === "or-set" - ? (state as ORSet) - : emptyOrSet(); - // Tombstone exactly the observed tags → order-independent merge. - return orSetRemoveTags(set, event.tags); - } - } -} - -/** - * Merge a batch of incoming events into the current state, grouped by resource. - * Returns the per-resource diffs (final state + vector clock) and the highest - * counter seen per chain (for the staleness watcher). Inputs are not mutated. - */ -export function mergeEvents( - states: Record, - clocks: Record, - events: CrdtEvent[] -): MergeResult { - const touched = new Map(); - const touchedClocks = new Map(); - const chainSeen: Partial> = {}; - - for (const event of events) { - const id = event.resourceId; - const current = touched.get(id) ?? states[id]; - touched.set(id, applyEvent(current, event)); - - const clock = touchedClocks.get(id) ?? clocks[id] ?? {}; - touchedClocks.set(id, bumpClock(clock, event.timestamp)); - - const { chainId, counter } = event.timestamp; - if ((chainSeen[chainId] ?? 0) < counter) chainSeen[chainId] = counter; - } - - const diffs: ResourceDiff[] = []; - for (const [resourceId, state] of touched) { - diffs.push({ - resourceId, - state, - vectorClock: touchedClocks.get(resourceId) ?? {}, - }); - } - return { diffs, chainSeen }; -} diff --git a/src/utils/dbMigration.ts b/src/utils/dbMigration.ts deleted file mode 100644 index 67d7ecf..0000000 --- a/src/utils/dbMigration.ts +++ /dev/null @@ -1,178 +0,0 @@ -export type MigrationDirection = "up" | "down"; - -export interface MigrationContext { - recordMetric?: (name: string, value: number, tags?: Record) => void; - logger?: Pick; -} - -export interface DatabaseMigration { - version: number; - name: string; - up: (context: MigrationContext) => Promise | void; - down: (context: MigrationContext) => Promise | void; -} - -export interface MigrationRecord { - version: number; - name: string; - direction: MigrationDirection; - status: "applied" | "rolled_back"; - startedAt: number; - finishedAt: number; - durationMs: number; -} - -export interface MigrationStore { - getCurrentVersion(): Promise | number; - setCurrentVersion(version: number): Promise | void; - appendHistory(record: MigrationRecord): Promise | void; -} - -export interface MigrationPlanStep { - migration: DatabaseMigration; - direction: MigrationDirection; -} - -export class InMemoryMigrationStore implements MigrationStore { - private version: number; - readonly history: MigrationRecord[] = []; - - constructor(initialVersion = 0) { - this.version = initialVersion; - } - - getCurrentVersion(): number { - return this.version; - } - - setCurrentVersion(version: number): void { - this.version = version; - } - - appendHistory(record: MigrationRecord): void { - this.history.push(record); - } -} - -export class MigrationError extends Error { - constructor(message: string, readonly version: number, readonly direction: MigrationDirection) { - super(message); - this.name = "MigrationError"; - } -} - -export function createMigrationPlan( - migrations: readonly DatabaseMigration[], - currentVersion: number, - targetVersion: number -): MigrationPlanStep[] { - const registry = normalizeMigrations(migrations); - if (currentVersion === targetVersion) return []; - - if (targetVersion > currentVersion) { - return Array.from(registry.values()) - .filter((migration) => migration.version > currentVersion && migration.version <= targetVersion) - .map((migration) => ({ migration, direction: "up" as const })); - } - - return Array.from(registry.values()) - .filter((migration) => migration.version > targetVersion && migration.version <= currentVersion) - .sort((a, b) => b.version - a.version) - .map((migration) => ({ migration, direction: "down" as const })); -} - -export class MigrationRunner { - private readonly migrations: readonly DatabaseMigration[]; - private readonly store: MigrationStore; - private readonly context: MigrationContext; - - constructor(options: { - migrations: readonly DatabaseMigration[]; - store: MigrationStore; - context?: MigrationContext; - }) { - this.migrations = options.migrations; - this.store = options.store; - this.context = options.context ?? {}; - normalizeMigrations(this.migrations); - } - - async migrateTo(targetVersion: number): Promise { - const currentVersion = await this.store.getCurrentVersion(); - const plan = createMigrationPlan(this.migrations, currentVersion, targetVersion); - const records: MigrationRecord[] = []; - - for (const step of plan) { - records.push(await this.runStep(step)); - } - - return records; - } - - async rollback(steps = 1): Promise { - if (!Number.isInteger(steps) || steps < 1) { - throw new RangeError("Rollback steps must be a positive integer."); - } - - const currentVersion = await this.store.getCurrentVersion(); - const targetVersion = Math.max(0, currentVersion - steps); - return this.migrateTo(targetVersion); - } - - private async runStep({ migration, direction }: MigrationPlanStep): Promise { - const startedAt = Date.now(); - this.context.logger?.info?.(`[migration] ${direction} ${migration.version} ${migration.name}`); - - try { - await migration[direction](this.context); - } catch (error) { - this.context.logger?.error?.(error); - this.context.recordMetric?.("db_migration_failure", 1, { - direction, - version: String(migration.version), - }); - throw new MigrationError( - `Failed to run ${direction} migration ${migration.version}: ${migration.name}`, - migration.version, - direction - ); - } - - const finishedAt = Date.now(); - const nextVersion = direction === "up" ? migration.version : migration.version - 1; - await this.store.setCurrentVersion(nextVersion); - - const record: MigrationRecord = { - version: migration.version, - name: migration.name, - direction, - status: direction === "up" ? "applied" : "rolled_back", - startedAt, - finishedAt, - durationMs: finishedAt - startedAt, - }; - await this.store.appendHistory(record); - this.context.recordMetric?.("db_migration_duration_ms", record.durationMs, { - direction, - version: String(migration.version), - }); - return record; - } -} - -function normalizeMigrations(migrations: readonly DatabaseMigration[]): Map { - const sorted = [...migrations].sort((a, b) => a.version - b.version); - const registry = new Map(); - - for (const migration of sorted) { - if (!Number.isInteger(migration.version) || migration.version < 1) { - throw new RangeError("Migration versions must be positive integers."); - } - if (registry.has(migration.version)) { - throw new RangeError(`Duplicate migration version: ${migration.version}.`); - } - registry.set(migration.version, migration); - } - - return registry; -} diff --git a/src/utils/decimal.ts b/src/utils/decimal.ts deleted file mode 100644 index 438de85..0000000 --- a/src/utils/decimal.ts +++ /dev/null @@ -1,270 +0,0 @@ -/** - * Fixed-point decimal arithmetic safety wrapper. - * - * JavaScript `number` arithmetic accumulates rounding error that grows - * unboundedly over aggregation windows. This module wraps BigNumber.js in a - * branded {@link Decimal} type that carries an explicit precision (decimal - * scale), keeps intermediate results exact, detects overflow against a hard - * `MAX_SAFE` ceiling, and serializes losslessly for the store. - * - * Values are kept exact internally; rounding (ROUND_HALF_UP) happens only at - * serialization / display boundaries — that is what eliminates cumulative drift. - */ - -import BigNumber from "bignumber.js"; - -/** A dedicated BigNumber constructor so we never mutate the global config. */ -const BN = BigNumber.clone({ - DECIMAL_PLACES: 28, // significant digits for non-terminating division - ROUNDING_MODE: BigNumber.ROUND_HALF_UP, - EXPONENTIAL_AT: [-40, 40], -}); - -/** Supported precisions (decimal scale) across the tariff taxonomy. */ -export const SUPPORTED_PRECISIONS = [0, 2, 3, 4, 6, 9] as const; -export type Precision = (typeof SUPPORTED_PRECISIONS)[number]; - -/** Overflow ceiling: 10^21 (1 trillion units at 9 decimals) per resource. */ -export const MAX_SAFE = new BN(10).pow(21); - -/** Thrown when an operation would exceed {@link MAX_SAFE}. */ -export class SafetyRangeError extends Error { - constructor(readonly value: string, operation?: string) { - super( - `Value ${value} exceeds the safe range of ±10^21${ - operation ? ` during ${operation}` : "" - }` - ); - this.name = "SafetyRangeError"; - } -} - -/** Thrown for an unsupported precision or malformed numeric input. */ -export class PrecisionError extends Error { - constructor(message: string) { - super(message); - this.name = "PrecisionError"; - } -} - -/** Serialized form stored in the slice (string-encoded to avoid FP corruption). */ -export interface SerializedDecimal { - value: string; - precision: number; - /** Integer scaling factor (10^precision) to convert to minor units. */ - scale: number; -} - -declare const PRECISION_BRAND: unique symbol; - -/** - * A precision-tagged fixed-point value. The phantom `P` lets the compiler track - * the declared precision; the runtime value is a {@link DecimalValue}. - */ -export type Decimal

= 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/src/utils/edgeBundling.ts b/src/utils/edgeBundling.ts deleted file mode 100644 index e41677e..0000000 --- a/src/utils/edgeBundling.ts +++ /dev/null @@ -1,167 +0,0 @@ -/** - * Force-Directed Edge Bundling (FDEB), after Holten & van Wijk. - * - * Each edge is subdivided into control points; compatible edges (similar angle, - * scale and position) attract each other's corresponding control points while a - * spring force keeps each edge smooth. Endpoints stay pinned. Running this on a - * dense planar utility graph declutters overlapping runs into visible bundles. - * - * Works in a normalized metric space (meters / average edge length) so the - * convergence epsilon is scale-independent. - */ - -import { FDEB, type BundledEdge, type LngLat, type NetworkEdge } from "@/types/network"; -import { - centroid, - dist2D, - fromLocalMeters, - toLocalMeters, - type Point2D, -} from "@/utils/geo"; - -export interface CompatibilityScore { - angle: number; - scale: number; - position: number; - total: number; -} - -function len(p: Point2D, q: Point2D): number { - return Math.hypot(q.x - p.x, q.y - p.y); -} -function mid(p: Point2D, q: Point2D): Point2D { - return { x: (p.x + q.x) / 2, y: (p.y + q.y) / 2 }; -} - -/** Compatibility between two straight segments (angle, scale, position). */ -export function edgeCompatibility( - a0: Point2D, - a1: Point2D, - b0: Point2D, - b1: Point2D -): CompatibilityScore { - const lenA = len(a0, a1) || 1e-9; - const lenB = len(b0, b1) || 1e-9; - const dot = (a1.x - a0.x) * (b1.x - b0.x) + (a1.y - a0.y) * (b1.y - b0.y); - - const angle = Math.abs(dot) / (lenA * lenB); - const lavg = (lenA + lenB) / 2; - const scale = - 2 / (lavg / Math.min(lenA, lenB) + Math.max(lenA, lenB) / lavg); - const position = lavg / (lavg + dist2D(mid(a0, a1), mid(b0, b1))); - - return { angle, scale, position, total: angle * scale * position }; -} - -/** Evenly spaced control points (inclusive of endpoints). */ -export function subdivide(from: Point2D, to: Point2D, n: number): Point2D[] { - const points: Point2D[] = []; - for (let i = 0; i < n; i++) { - const t = i / (n - 1); - points.push({ x: from.x + (to.x - from.x) * t, y: from.y + (to.y - from.y) * t }); - } - return points; -} - -export interface BundleParams { - subdivisions: number; - maxIterations: number; - springConstant: number; - compatibilityThreshold: number; - convergenceEpsilon: number; -} - -const DEFAULT_PARAMS: BundleParams = { - subdivisions: FDEB.subdivisions, - maxIterations: FDEB.maxIterations, - springConstant: FDEB.springConstant, - compatibilityThreshold: FDEB.compatibilityThreshold, - convergenceEpsilon: FDEB.convergenceEpsilon, -}; - -/** - * Bundle a set of edges. Returns each edge's bundled control-point polyline in - * `[lng, lat]`. Edge endpoints are preserved exactly. - */ -export function bundleEdges( - edges: NetworkEdge[], - params: Partial = {} -): BundledEdge[] { - const cfg = { ...DEFAULT_PARAMS, ...params }; - if (edges.length === 0) return []; - - const ref = centroid(edges.map((e) => e.geometry[0])); - // Endpoints in metric space. - const segments = edges.map((e) => ({ - from: toLocalMeters(e.geometry[0], ref), - to: toLocalMeters(e.geometry[e.geometry.length - 1], ref), - })); - - // Normalize by the average edge length so the convergence epsilon is - // scale-independent. - const avgLen = - segments.reduce((s, seg) => s + len(seg.from, seg.to), 0) / - segments.length || 1; - const norm = (p: Point2D): Point2D => ({ x: p.x / avgLen, y: p.y / avgLen }); - - const n = cfg.subdivisions; - let paths = segments.map((seg) => subdivide(norm(seg.from), norm(seg.to), n)); - - // Precompute compatible-edge lists (angle compatibility ≥ threshold). - const compatible: Array> = paths.map(() => []); - for (let i = 0; i < segments.length; i++) { - const a = paths[i]; - for (let j = i + 1; j < segments.length; j++) { - const b = paths[j]; - const score = edgeCompatibility(a[0], a[n - 1], b[0], b[n - 1]); - if (score.angle >= cfg.compatibilityThreshold) { - compatible[i].push({ j, weight: score.total }); - compatible[j].push({ j: i, weight: score.total }); - } - } - } - - // Iterate spring + compatibility-attraction forces on the interior points. - for (let iter = 0; iter < cfg.maxIterations; iter++) { - const step = 0.4 * (1 - iter / cfg.maxIterations); - const next = paths.map((p) => p.map((pt) => ({ ...pt }))); - let maxDisp = 0; - - for (let e = 0; e < paths.length; e++) { - const path = paths[e]; - const partners = compatible[e]; - const partnerCount = partners.length || 1; - - for (let i = 1; i < n - 1; i++) { - const cur = path[i]; - // Spring force toward this edge's neighbouring control points. - let fx = cfg.springConstant * (path[i - 1].x + path[i + 1].x - 2 * cur.x); - let fy = cfg.springConstant * (path[i - 1].y + path[i + 1].y - 2 * cur.y); - // Attraction toward compatible edges' matching control points. - for (const { j, weight } of partners) { - fx += (weight * (paths[j][i].x - cur.x)) / partnerCount; - fy += (weight * (paths[j][i].y - cur.y)) / partnerCount; - } - const nx = cur.x + step * fx; - const ny = cur.y + step * fy; - next[e][i].x = nx; - next[e][i].y = ny; - const disp = Math.hypot(nx - cur.x, ny - cur.y); - if (disp > maxDisp) maxDisp = disp; - } - } - - paths = next; - if (maxDisp < cfg.convergenceEpsilon) break; - } - - // Denormalize and reproject to lng/lat. - return edges.map((edge, e) => ({ - id: edge.id, - flowDirection: edge.flowDirection, - loadStatus: edge.loadStatus, - points: paths[e].map((p) => - fromLocalMeters({ x: p.x * avgLen, y: p.y * avgLen }, ref) - ) as LngLat[], - })); -} diff --git a/src/utils/edgeSimplification.ts b/src/utils/edgeSimplification.ts deleted file mode 100644 index 4e1b421..0000000 --- a/src/utils/edgeSimplification.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * Zoom-aware Douglas-Peucker simplification for network edge geometry. - * - * Simplification runs in local metric space (so the epsilon is in meters) and - * the result is reprojected to `[lng, lat]`. Epsilon is 5 m at zoom ≤ 14 and - * 20 m at zoom ≥ 16, interpolated between. - */ - -import { SIMPLIFY_EPSILON, type LngLat, type NetworkEdge } from "@/types/network"; -import { centroid, fromLocalMeters, toLocalMeters, type Point2D } from "@/utils/geo"; - -/** Douglas-Peucker epsilon (meters) for a zoom level. */ -export function zoomEpsilon(zoom: number): number { - const { low, high } = SIMPLIFY_EPSILON; - if (zoom <= 14) return low; - if (zoom >= 16) return high; - // Linear interpolation across the 14–16 band. - return low + ((high - low) * (zoom - 14)) / 2; -} - -/** Perpendicular distance from `p` to the segment `a→b` (meters). */ -export function perpendicularDistance(p: Point2D, a: Point2D, b: Point2D): number { - const dx = b.x - a.x; - const dy = b.y - a.y; - const lenSq = dx * dx + dy * dy; - if (lenSq === 0) return Math.hypot(p.x - a.x, p.y - a.y); - // Distance from point to the infinite line through a,b. - const cross = Math.abs(dy * p.x - dx * p.y + b.x * a.y - b.y * a.x); - return cross / Math.sqrt(lenSq); -} - -/** Simplify a metric polyline with Douglas-Peucker. */ -export function douglasPeucker(points: Point2D[], epsilon: number): Point2D[] { - if (points.length <= 2) return points.slice(); - - let maxDist = 0; - let index = 0; - const first = points[0]; - const last = points[points.length - 1]; - for (let i = 1; i < points.length - 1; i++) { - const d = perpendicularDistance(points[i], first, last); - if (d > maxDist) { - maxDist = d; - index = i; - } - } - - if (maxDist > epsilon) { - const left = douglasPeucker(points.slice(0, index + 1), epsilon); - const right = douglasPeucker(points.slice(index), epsilon); - // Drop the duplicated join point. - return left.slice(0, -1).concat(right); - } - return [first, last]; -} - -/** Simplify one edge's geometry at the given metric epsilon. */ -export function simplifyGeometry( - geometry: LngLat[], - epsilonMeters: number, - ref: LngLat -): LngLat[] { - if (geometry.length <= 2) return geometry.slice(); - const metric = geometry.map((c) => toLocalMeters(c, ref)); - const simplified = douglasPeucker(metric, epsilonMeters); - return simplified.map((p) => fromLocalMeters(p, ref)); -} - -/** - * Simplify a set of edges with a zoom-derived epsilon. The projection is - * centered on the edges' centroid so the metric error stays small. - */ -export function simplifyEdges( - edges: NetworkEdge[], - zoom: number -): NetworkEdge[] { - if (edges.length === 0) return []; - const epsilon = zoomEpsilon(zoom); - const ref = centroid(edges.flatMap((e) => e.geometry)); - return edges.map((e) => ({ - ...e, - geometry: simplifyGeometry(e.geometry, epsilon, ref), - })); -} diff --git a/src/utils/errors.ts b/src/utils/errors.ts deleted file mode 100644 index 03eb626..0000000 --- a/src/utils/errors.ts +++ /dev/null @@ -1,102 +0,0 @@ -export const SOROBAN_ERROR_CODES: Record = { - tx_not_found: "Transaction was not found. Please wait a moment and try again.", - tx_failed: "The transaction could not be processed. Check your balance and try again.", - tx_too_late: "The transaction submission window has expired. Please resubmit.", - tx_bad_seq: "The account sequence number is out of sync. Try reconnecting your wallet.", - insufficient_balance: "Your account does not have enough funds for this operation.", - insufficient_fee: "The resource fee provided is too low. Increase the fee and try again.", - contract_not_found: "The smart contract could not be found on the network.", - contract_error: "The smart contract returned an error. Contact the development team.", - contract_not_activated: "This contract has not been activated yet.", - auth_required: "Wallet authorization is required to perform this action.", - auth_revoked: "Your session authorization has been revoked. Please re-authenticate.", - bad_nonce: "The provided nonce is invalid. Try again with a fresh one.", - network_mismatch: "Your wallet is connected to a different network. Switch to the correct network.", - timeout: "The request timed out. Check your network connection and try again.", - rate_limited: "Too many requests. Please wait a moment before trying again.", - internal_error: "An internal system error occurred. Please try again later.", - unknown_error: "An unexpected error occurred. Please try again or contact support.", -}; - -export const NETWORK_ERROR_KEYWORDS: Record = { - timeout: "The request took too long. Check your internet connection.", - network: "A network error occurred. Verify you are connected to the internet.", - websocket: "The realtime connection dropped. Please refresh and try again.", - socket: "The connection was interrupted. Please check your network and try again.", - abort: "The request was cancelled. Please try again.", - fetch: "Could not reach the server. The API may be down.", - dns: "Could not resolve the server address. Check your DNS settings.", - cors: "Cross-origin request blocked. Contact the server administrator.", - econnrefused: "The server refused the connection. Please try again shortly.", - econnreset: "The connection was reset. Please check your network and try again.", -}; - -export function resolveSorobanError(rawError: string): string { - const normalized = rawError.toLowerCase().trim(); - - if (!normalized) { - return "An unexpected error occurred. Please try again or contact support."; - } - - if (normalized.includes("timeout")) { - return NETWORK_ERROR_KEYWORDS.timeout; - } - - const networkPriority = [ - "websocket", - "socket", - "fetch", - "network", - "dns", - "cors", - "abort", - "econnrefused", - "econnreset", - ]; - - for (const keyword of networkPriority) { - if (normalized.includes(keyword)) { - return NETWORK_ERROR_KEYWORDS[keyword] ?? "A network error occurred. Please try again."; - } - } - - for (const [keyword, message] of Object.entries(SOROBAN_ERROR_CODES)) { - if (normalized.includes(keyword)) { - return message; - } - } - - const summary = normalized.replace(/\s+/g, " ").slice(0, 40); - return `An unexpected error occurred. Please try again or contact support. (${summary})`; -} - -export function classifyError( - error: unknown, -): { message: string; severity: "low" | "medium" | "high"; actionable: boolean } { - if (error === undefined || error === null || error === "") { - return { message: "An unknown error occurred.", severity: "low", actionable: false }; - } - - const msg = error instanceof Error ? error.message : String(error); - const normalized = msg.toLowerCase(); - const resolved = resolveSorobanError(msg); - - if (/auth|unauthorized|revoked|forbidden/.test(normalized)) { - return { message: resolved, severity: "high", actionable: true }; - } - - if (/balance|fee|insufficient|funds|gas/.test(normalized)) { - return { message: resolved, severity: "medium", actionable: true }; - } - - if (/timeout|network|fetch|socket|websocket|dns|cors|abort|econn/.test(normalized)) { - return { message: resolved, severity: "medium", actionable: true }; - } - - return { message: resolved, severity: "low", actionable: false }; -} - -export function formatErrorForDisplay(error: unknown): string { - const { message, severity } = classifyError(error); - return `[${severity.toUpperCase()}] ${message}`; -} diff --git a/src/utils/formatting.ts b/src/utils/formatting.ts deleted file mode 100644 index 5c1052c..0000000 --- a/src/utils/formatting.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Locale } from "@/i18n/config"; - -export function formatNumber(value: number, locale: Locale, opts?: Intl.NumberFormatOptions) { - return new Intl.NumberFormat(locale, opts).format(value); -} - -export function formatCurrency(value: number, locale: Locale, currency: string) { - return new Intl.NumberFormat(locale, { style: "currency", currency }).format(value); -} - -export function formatDate(date: Date | string | number, locale: Locale, opts?: Intl.DateTimeFormatOptions) { - const d = typeof date === "string" || typeof date === "number" ? new Date(date) : date; - return new Intl.DateTimeFormat(locale, opts).format(d); -} diff --git a/src/utils/frameBuffer.ts b/src/utils/frameBuffer.ts deleted file mode 100644 index e682f77..0000000 --- a/src/utils/frameBuffer.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Fixed-capacity circular buffer of telemetry frames used to replay recent data - * during the reconnection recovery handshake. Pushing past capacity overwrites - * the oldest frame (the operator only needs the most recent window to recover). - */ - -import { FRAME_BUFFER_CAPACITY, type TelemetryFrame } from "@/types/connection"; - -export class FrameBuffer { - private readonly buffer: (TelemetryFrame | undefined)[]; - private readonly capacity: number; - /** Index of the next write slot. */ - private head = 0; - /** Number of frames currently stored (≤ capacity). */ - private count = 0; - /** Highest sequence id seen, or -1 when empty. */ - private highestSeq = -1; - - constructor(capacity: number = FRAME_BUFFER_CAPACITY) { - if (capacity <= 0) throw new Error("FrameBuffer capacity must be positive"); - this.capacity = capacity; - this.buffer = new Array(capacity); - } - - /** Number of frames retained. */ - get size(): number { - return this.count; - } - - /** True once the ring has wrapped and is overwriting old frames. */ - get isFull(): boolean { - return this.count === this.capacity; - } - - /** Highest sequence id pushed, or -1 if the buffer is empty. */ - get lastSequenceId(): number { - return this.highestSeq; - } - - /** Append a frame, evicting the oldest if at capacity. */ - push(frame: TelemetryFrame): void { - this.buffer[this.head] = frame; - this.head = (this.head + 1) % this.capacity; - if (this.count < this.capacity) { - this.count += 1; - } - if (frame.sequenceId > this.highestSeq) { - this.highestSeq = frame.sequenceId; - } - } - - /** Return retained frames in chronological (oldest → newest) order. */ - toArray(): TelemetryFrame[] { - const out: TelemetryFrame[] = []; - // The oldest element is `count` slots behind head (mod capacity). - const start = (this.head - this.count + this.capacity) % this.capacity; - for (let i = 0; i < this.count; i++) { - const frame = this.buffer[(start + i) % this.capacity]; - if (frame) out.push(frame); - } - return out; - } - - /** - * Drain the buffer: return all retained frames (oldest → newest) and clear - * the ring. `highestSeq` is preserved so the next recovery handshake can still - * report the last sequence id the client observed. - */ - drain(): TelemetryFrame[] { - const frames = this.toArray(); - this.buffer.fill(undefined); - this.head = 0; - this.count = 0; - return frames; - } - - /** Approximate total bytes retained (sum of frame sizes). */ - byteLength(): number { - let total = 0; - for (const frame of this.buffer) { - if (frame) total += frame.size; - } - return total; - } - - /** Reset the buffer and forget the high-water sequence id. */ - clear(): void { - this.buffer.fill(undefined); - this.head = 0; - this.count = 0; - this.highestSeq = -1; - } -} diff --git a/src/utils/frustum.ts b/src/utils/frustum.ts deleted file mode 100644 index defb4ba..0000000 --- a/src/utils/frustum.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * View-frustum math used by the QuadTree culling layer. Kept free of Three.js - * so it can run in a Web Worker and be unit-tested without a WebGL context. - * - * Matrices are 16-element, column-major (matching `THREE.Matrix4.elements` and - * WebGL), i.e. element (row r, col c) is `m[c * 4 + r]`. - */ - -import type { BoundingSphere, Coordinate3D, FrustumPlane } from "@/types/spatial"; - -/** Normalize a plane so its normal `(a,b,c)` is unit length. */ -export function normalizePlane(p: FrustumPlane): FrustumPlane { - const len = Math.hypot(p.a, p.b, p.c) || 1; - return { a: p.a / len, b: p.b / len, c: p.c / len, d: p.d / len }; -} - -/** - * Extract the six frustum planes from a column-major view-projection matrix - * (Gribb–Hartmann). Returned planes have inward-pointing normals: a point is - * inside the frustum when it lies in the positive half-space of every plane. - */ -export function extractFrustumPlanes(m: ArrayLike): FrustumPlane[] { - // row(i) = [ m(i,0), m(i,1), m(i,2), m(i,3) ] - const row = (i: number): [number, number, number, number] => [ - m[0 * 4 + i], - m[1 * 4 + i], - m[2 * 4 + i], - m[3 * 4 + i], - ]; - const [r0, r1, r2, r3] = [row(0), row(1), row(2), row(3)]; - - const combine = ( - s: [number, number, number, number], - t: [number, number, number, number], - sign: 1 | -1 - ): FrustumPlane => - normalizePlane({ - a: s[0] + sign * t[0], - b: s[1] + sign * t[1], - c: s[2] + sign * t[2], - d: s[3] + sign * t[3], - }); - - return [ - combine(r3, r0, 1), // left: w + x - combine(r3, r0, -1), // right: w - x - combine(r3, r1, 1), // bottom: w + y - combine(r3, r1, -1), // top: w - y - combine(r3, r2, 1), // near: w + z - combine(r3, r2, -1), // far: w - z - ]; -} - -/** Signed distance from a point to a plane (positive = interior side). */ -export function planeDistance(p: FrustumPlane, pt: Coordinate3D): number { - return p.a * pt.x + p.b * pt.y + p.c * pt.z + p.d; -} - -/** - * Conservative sphere/frustum intersection: returns false only when the sphere - * is fully outside at least one plane. May report a near-miss as inside (the - * standard accept-or-maybe test for broad-phase culling). - */ -export function sphereInFrustum( - planes: FrustumPlane[], - sphere: BoundingSphere -): boolean { - for (const plane of planes) { - if (planeDistance(plane, sphere.center) < -sphere.radius) { - return false; - } - } - return true; -} - -/** True when a point lies inside every frustum plane. */ -export function pointInFrustum( - planes: FrustumPlane[], - point: Coordinate3D -): boolean { - for (const plane of planes) { - if (planeDistance(plane, point) < 0) return false; - } - return true; -} - -/** Euclidean distance between two 3D points. */ -export function distance3D(a: Coordinate3D, b: Coordinate3D): number { - return Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z); -} diff --git a/src/utils/geo.ts b/src/utils/geo.ts deleted file mode 100644 index 7b8b508..0000000 --- a/src/utils/geo.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Local equirectangular projection between `[lng, lat]` and meters. - * - * Edge bundling and Douglas-Peucker simplification need a metric space; over a - * city-scale viewport an equirectangular projection around a reference latitude - * is accurate to a fraction of a percent, which is plenty for decluttering and - * geometry simplification. - */ - -import type { LngLat } from "@/types/network"; - -/** Meters per degree of latitude (near-constant). */ -const M_PER_DEG_LAT = 110_540; -/** Meters per degree of longitude at the equator. */ -const M_PER_DEG_LNG = 111_320; - -export interface Point2D { - x: number; - y: number; -} - -/** Project lng/lat to local meters relative to a reference point. */ -export function toLocalMeters( - [lng, lat]: LngLat, - ref: LngLat -): Point2D { - const cosLat = Math.cos((ref[1] * Math.PI) / 180); - return { - x: (lng - ref[0]) * M_PER_DEG_LNG * cosLat, - y: (lat - ref[1]) * M_PER_DEG_LAT, - }; -} - -/** Inverse of {@link toLocalMeters}. */ -export function fromLocalMeters({ x, y }: Point2D, ref: LngLat): LngLat { - const cosLat = Math.cos((ref[1] * Math.PI) / 180) || 1e-12; - return [x / (M_PER_DEG_LNG * cosLat) + ref[0], y / M_PER_DEG_LAT + ref[1]]; -} - -/** Euclidean distance between two metric points. */ -export function dist2D(a: Point2D, b: Point2D): number { - return Math.hypot(a.x - b.x, a.y - b.y); -} - -/** - * Convert `[lng, lat]` to normalized Web Mercator `[0, 1]` — the coordinate - * space Mapbox's custom-layer projection matrix (`u_matrix`) operates in. - */ -export function lngLatToMercator([lng, lat]: LngLat): Point2D { - const x = (lng + 180) / 360; - const siny = Math.min(Math.max(Math.sin((lat * Math.PI) / 180), -0.9999), 0.9999); - const y = 0.5 - Math.log((1 + siny) / (1 - siny)) / (4 * Math.PI); - return { x, y }; -} - -/** - * Pick a reference point (centroid) for a set of edges/points so the projection - * is centered on the data. - */ -export function centroid(points: LngLat[]): LngLat { - if (points.length === 0) return [0, 0]; - let lng = 0; - let lat = 0; - for (const p of points) { - lng += p[0]; - lat += p[1]; - } - return [lng / points.length, lat / points.length]; -} diff --git a/src/utils/kafkaConsumerLag.ts b/src/utils/kafkaConsumerLag.ts deleted file mode 100644 index 2eff570..0000000 --- a/src/utils/kafkaConsumerLag.ts +++ /dev/null @@ -1,145 +0,0 @@ -/** - * Kafka consumer-lag policy helpers shared by operations dashboards and - * auto-scaling controllers. The functions are deterministic and side-effect - * free so they can be reused by backend adapters, UI previews, and runbooks. - */ - -export type LagSeverity = "healthy" | "watch" | "critical"; - -export interface KafkaConsumerLagSample { - /** Consumer-group identifier. */ - groupId: string; - /** Kafka topic name. */ - topic: string; - /** Partition id inside the topic. */ - partition: number; - /** Last committed offset for the consumer group. */ - committedOffset: number; - /** Latest broker high-watermark offset. */ - highWatermark: number; - /** Epoch millisecond timestamp when the sample was collected. */ - sampledAt: number; -} - -export interface LagPolicy { - /** Lag that should page operators and trigger aggressive scale-out. */ - criticalLag: number; - /** Lag that should warn operators and allow moderate scale-out. */ - warningLag: number; - /** Highest allowed lag age before the sample is considered stale. */ - staleAfterMs: number; - /** Minimum replicas that the consumer group should keep warm. */ - minReplicas: number; - /** Maximum replicas permitted by capacity and cost guardrails. */ - maxReplicas: number; - /** Approximate backlog that a single replica can drain during the SLO window. */ - lagPerReplica: number; - /** Replica count change allowed in a single evaluation tick. */ - maxStep: number; -} - -export interface ConsumerGroupLagSummary { - groupId: string; - totalLag: number; - maxPartitionLag: number; - partitions: number; - severity: LagSeverity; - stale: boolean; - sampledAt: number; -} - -export interface ScalingRecommendation { - groupId: string; - currentReplicas: number; - desiredReplicas: number; - reason: string; - severity: LagSeverity; -} - -export const DEFAULT_LAG_POLICY: LagPolicy = { - criticalLag: 50_000, - warningLag: 10_000, - staleAfterMs: 120_000, - minReplicas: 2, - maxReplicas: 30, - lagPerReplica: 5_000, - maxStep: 4, -}; - -function clamp(value: number, min: number, max: number): number { - return Math.min(max, Math.max(min, value)); -} - -export function partitionLag(sample: KafkaConsumerLagSample): number { - if (!Number.isFinite(sample.committedOffset) || !Number.isFinite(sample.highWatermark)) { - throw new Error("Kafka offsets must be finite numbers"); - } - - return Math.max(0, Math.floor(sample.highWatermark - sample.committedOffset)); -} - -export function summarizeConsumerLag( - samples: KafkaConsumerLagSample[], - policy: LagPolicy = DEFAULT_LAG_POLICY, - now: number = Date.now() -): ConsumerGroupLagSummary[] { - const byGroup = new Map(); - for (const sample of samples) { - const groupSamples = byGroup.get(sample.groupId) ?? []; - groupSamples.push(sample); - byGroup.set(sample.groupId, groupSamples); - } - - return Array.from(byGroup.entries()) - .map(([groupId, groupSamples]) => { - const lags = groupSamples.map(partitionLag); - const totalLag = lags.reduce((sum, lag) => sum + lag, 0); - const maxPartitionLag = Math.max(...lags, 0); - const sampledAt = Math.max(...groupSamples.map((sample) => sample.sampledAt)); - const stale = now - sampledAt > policy.staleAfterMs; - const severity: LagSeverity = stale || totalLag >= policy.criticalLag - ? "critical" - : totalLag >= policy.warningLag - ? "watch" - : "healthy"; - - return { - groupId, - totalLag, - maxPartitionLag, - partitions: groupSamples.length, - severity, - stale, - sampledAt, - }; - }) - .sort((a, b) => b.totalLag - a.totalLag || a.groupId.localeCompare(b.groupId)); -} - -export function recommendConsumerReplicas( - summary: ConsumerGroupLagSummary, - currentReplicas: number, - policy: LagPolicy = DEFAULT_LAG_POLICY -): ScalingRecommendation { - if (policy.lagPerReplica <= 0) throw new Error("lagPerReplica must be positive"); - const current = clamp(Math.round(currentReplicas), policy.minReplicas, policy.maxReplicas); - const backlogReplicas = Math.ceil(summary.totalLag / policy.lagPerReplica); - const target = clamp(Math.max(policy.minReplicas, backlogReplicas), policy.minReplicas, policy.maxReplicas); - const limitedTarget = target > current - ? Math.min(target, current + policy.maxStep) - : Math.max(target, current - policy.maxStep); - - return { - groupId: summary.groupId, - currentReplicas: current, - desiredReplicas: limitedTarget, - severity: summary.severity, - reason: summary.stale - ? "lag metrics are stale; hold within guardrails while paging operators" - : limitedTarget > current - ? `scale out to drain ${summary.totalLag} queued messages` - : limitedTarget < current - ? `scale in after lag recovered to ${summary.totalLag}` - : `keep ${current} replicas for ${summary.totalLag} queued messages`, - }; -} diff --git a/src/utils/lamportClock.ts b/src/utils/lamportClock.ts deleted file mode 100644 index 6c32582..0000000 --- a/src/utils/lamportClock.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Lamport timestamps with chain-priority tie-breaking. - * - * A timestamp is `(chainId, counter)`. Ordering is by `counter`, and ties (the - * "concurrent" case) are broken deterministically by chain priority - * (mainnet > testnet > futurenet) so every replica converges to the same winner. - */ - -import { - CHAIN_PRIORITY, - type ChainId, - type LamportTimestamp, -} from "@/types/crdt"; - -/** - * Total order over Lamport timestamps. Returns -1 / 0 / 1. Two timestamps are - * equal only when they share both chain and counter. - */ -export function compareTimestamp( - a: LamportTimestamp, - b: LamportTimestamp -): -1 | 0 | 1 { - if (a.counter !== b.counter) return a.counter < b.counter ? -1 : 1; - const pa = CHAIN_PRIORITY[a.chainId]; - const pb = CHAIN_PRIORITY[b.chainId]; - if (pa !== pb) return pa < pb ? -1 : 1; - return 0; -} - -/** True when `a` strictly dominates `b` in the total order. */ -export function dominates(a: LamportTimestamp, b: LamportTimestamp): boolean { - return compareTimestamp(a, b) === 1; -} - -/** The greater of two timestamps (ties → the higher-priority chain). */ -export function maxTimestamp( - a: LamportTimestamp, - b: LamportTimestamp -): LamportTimestamp { - return compareTimestamp(a, b) >= 0 ? a : b; -} - -/** Per-chain Lamport clock. */ -export class LamportClock { - constructor( - readonly chainId: ChainId, - private counter = 0 - ) {} - - /** Current counter value (without advancing). */ - get value(): number { - return this.counter; - } - - /** Advance the clock and stamp a new local event. */ - tick(): LamportTimestamp { - this.counter += 1; - return { chainId: this.chainId, counter: this.counter }; - } - - /** Merge in a remote timestamp (Lamport receive rule). */ - observe(remote: LamportTimestamp): void { - if (remote.counter > this.counter) this.counter = remote.counter; - } - - /** - * Observe a remote timestamp and immediately stamp a derived local event - * (receive-then-send): `counter = max(local, remote) + 1`. - */ - tickAfter(remote: LamportTimestamp): LamportTimestamp { - this.observe(remote); - return this.tick(); - } - - peek(): LamportTimestamp { - return { chainId: this.chainId, counter: this.counter }; - } -} diff --git a/src/utils/lruEviction.ts b/src/utils/lruEviction.ts deleted file mode 100644 index 5b47aec..0000000 --- a/src/utils/lruEviction.ts +++ /dev/null @@ -1,160 +0,0 @@ -/** - * LRU cache index for tiles. - * - * A doubly-linked list keeps entries in recency order (most-recently-used at the - * head). Eviction, however, is value-aware: it prefers stale tiles, then the - * lowest `access_count / age` ratio — a tile that has been hit rarely relative - * to how long it has sat in the cache is the cheapest to drop. - */ - -import { - EVICTION_BATCH, - EVICTION_THRESHOLD, - type TileMeta, -} from "@/types/tile"; -import { isStale } from "@/utils/tileMath"; - -interface Node { - meta: TileMeta; - prev: Node | null; - next: Node | null; -} - -export interface EvictionCandidate { - key: string; - score: number; - stale: boolean; -} - -export class LRUList { - private readonly map = new Map(); - private head: Node | null = null; // most-recently-used - private tail: Node | null = null; // least-recently-used - private bytes = 0; - - get size(): number { - return this.map.size; - } - get byteSize(): number { - return this.bytes; - } - has(key: string): boolean { - return this.map.has(key); - } - keys(): string[] { - return [...this.map.keys()]; - } - - /** Insert (or replace) an entry at the head. */ - add(meta: TileMeta): void { - const existing = this.map.get(meta.key); - if (existing) { - this.bytes += meta.size - existing.meta.size; - existing.meta = meta; - this.moveToHead(existing); - return; - } - const node: Node = { meta, prev: null, next: null }; - this.map.set(meta.key, node); - this.bytes += meta.size; - this.attachHead(node); - } - - /** Record a cache hit: bump access stats and promote to MRU. Returns meta. */ - touch(key: string, now: number): TileMeta | null { - const node = this.map.get(key); - if (!node) return null; - node.meta = { - ...node.meta, - accessCount: node.meta.accessCount + 1, - lastAccess: now, - }; - this.moveToHead(node); - return node.meta; - } - - get(key: string): TileMeta | null { - return this.map.get(key)?.meta ?? null; - } - - remove(key: string): boolean { - const node = this.map.get(key); - if (!node) return false; - this.detach(node); - this.map.delete(key); - this.bytes -= node.meta.size; - return true; - } - - /** True once the cache has grown to the eviction threshold. */ - shouldEvict(threshold: number = EVICTION_THRESHOLD): boolean { - return this.map.size >= threshold; - } - - /** value ratio: hits per ms of age (lower → better eviction candidate). */ - private static score(meta: TileMeta, now: number): number { - const age = Math.max(1, now - meta.fetchedAt); - return meta.accessCount / age; - } - - /** - * Choose up to `count` keys to evict: stale tiles first, then ascending - * `access_count / age`, with least-recently-used as the final tie-breaker. - */ - evictionCandidates(count: number, now: number): EvictionCandidate[] { - const all: (EvictionCandidate & { lastAccess: number })[] = []; - for (const node of this.map.values()) { - all.push({ - key: node.meta.key, - score: LRUList.score(node.meta, now), - stale: isStale(node.meta, now), - lastAccess: node.meta.lastAccess, - }); - } - all.sort((a, b) => { - if (a.stale !== b.stale) return a.stale ? -1 : 1; // stale first - if (a.score !== b.score) return a.score - b.score; // lowest ratio first - return a.lastAccess - b.lastAccess; // then LRU - }); - return all.slice(0, count).map(({ key, score, stale }) => ({ key, score, stale })); - } - - /** Evict up to `count` entries and return the removed keys. */ - evict(now: number, count: number = EVICTION_BATCH): string[] { - const victims = this.evictionCandidates(count, now).map((c) => c.key); - for (const key of victims) this.remove(key); - return victims; - } - - // --- doubly-linked list internals ---------------------------------------- - - private attachHead(node: Node): void { - node.prev = null; - node.next = this.head; - if (this.head) this.head.prev = node; - this.head = node; - if (!this.tail) this.tail = node; - } - - private detach(node: Node): void { - if (node.prev) node.prev.next = node.next; - else this.head = node.next; - if (node.next) node.next.prev = node.prev; - else this.tail = node.prev; - node.prev = null; - node.next = null; - } - - private moveToHead(node: Node): void { - if (this.head === node) return; - this.detach(node); - this.attachHead(node); - } - - /** Keys ordered MRU → LRU (for tests / inspection). */ - orderedKeys(): string[] { - const out: string[] = []; - for (let n = this.head; n; n = n.next) out.push(n.meta.key); - return out; - } -} diff --git a/src/utils/math.ts b/src/utils/math.ts deleted file mode 100644 index d4bc074..0000000 --- a/src/utils/math.ts +++ /dev/null @@ -1,258 +0,0 @@ -import BigNumber from "bignumber.js"; - -BigNumber.config({ - DECIMAL_PLACES: 20, - ROUNDING_MODE: BigNumber.ROUND_HALF_UP, - EXPONENTIAL_AT: [-30, 30], -}); - -/** Soroban contract states are 7-decimal fixed-point integers (×10^7). */ -export const SCALE = 7; -export const SCALE_FACTOR = new BigNumber(10).pow(SCALE); - -/** Signed 128-bit integer bounds (Soroban i128). */ -export const I128_MAX = new BigNumber( - "170141183460469231731687303715884105727" -); -export const I128_MIN = new BigNumber( - "-170141183460469231731687303715884105728" -); - -/** Safe upper bound for the integer part of an authored value (10^12). */ -export const MAX_INTEGER_PART = new BigNumber(10).pow(12); - -/** Decimal value (the un-scaled, human-facing number) inputs accept. */ -export type DecimalInput = string | number | BigNumber; - -export class OverflowError extends Error { - constructor(message: string) { - super(message); - this.name = "OverflowError"; - } -} - -export class ScaleValidationError extends Error { - constructor(message: string) { - super(message); - this.name = "ScaleValidationError"; - } -} - -// --------------------------------------------------------------------------- -// Validation -// --------------------------------------------------------------------------- - -export interface ScaleValidation { - valid: boolean; - reason?: string; -} - -/** Plain decimal (no exponent): optional sign, digits, optional fraction. */ -const DECIMAL_RE = /^[+-]?(\d+)(\.(\d+))?$/; -/** Exponential notation, e.g. 1e7, -2.5E-3. */ -const EXPONENTIAL_RE = /^[+-]?(\d+)(\.\d+)?[eE][+-]?\d+$/; - -/** - * Validate that a value is safe to convert to contract units: - * - at most {@link SCALE} (7) digits after the decimal point, - * - no exponential notation unless it represents a safe integer, - * - integer part not exceeding {@link MAX_INTEGER_PART} (10^12). - */ -export function validateScale(value: string): ScaleValidation { - if (typeof value !== "string" || value.trim() === "") { - return { valid: false, reason: "value must be a non-empty string" }; - } - const trimmed = value.trim(); - - const isExponential = EXPONENTIAL_RE.test(trimmed); - if (!isExponential && !DECIMAL_RE.test(trimmed)) { - return { valid: false, reason: "not a valid decimal number" }; - } - - const bn = new BigNumber(trimmed); - if (bn.isNaN() || !bn.isFinite()) { - return { valid: false, reason: "not a finite number" }; - } - - if (isExponential) { - // Exponential notation is only accepted when it denotes a safe integer, - // so authored fractions can't smuggle in hidden sub-1e-7 precision. - if (!bn.isInteger()) { - return { - valid: false, - reason: "exponential notation is only allowed for integers", - }; - } - if (!Number.isSafeInteger(bn.toNumber())) { - return { - valid: false, - reason: "exponential value is not a safe integer", - }; - } - } - - const decimals = bn.decimalPlaces() ?? 0; - if (decimals > SCALE) { - return { - valid: false, - reason: `more than ${SCALE} decimal places (${decimals})`, - }; - } - - const integerPart = bn.abs().integerValue(BigNumber.ROUND_DOWN); - if (integerPart.isGreaterThan(MAX_INTEGER_PART)) { - return { - valid: false, - reason: `integer part exceeds ${MAX_INTEGER_PART.toFixed(0)}`, - }; - } - - return { valid: true }; -} - -// --------------------------------------------------------------------------- -// Scaling (pure) -// --------------------------------------------------------------------------- - -export interface ScaleOptions { - /** Reject values that fail {@link validateScale} instead of truncating. */ - strict?: boolean; -} - -function toBigNumber(value: DecimalInput): BigNumber { - let bn: BigNumber; - try { - bn = new BigNumber(value); - } catch { - throw new ScaleValidationError(`invalid numeric input: ${String(value)}`); - } - if (bn.isNaN() || !bn.isFinite()) { - throw new ScaleValidationError(`invalid numeric input: ${String(value)}`); - } - return bn; -} - -/** - * Convert a human decimal to its 7-decimal contract-unit integer string. - * Extra precision is explicitly truncated (ROUND_DOWN); in `strict` mode a - * value with more than 7 decimals is rejected instead. Throws on i128 overflow. - */ -export function toContractUnits( - value: DecimalInput, - options: ScaleOptions = {} -): string { - if (options.strict && typeof value === "string") { - const result = validateScale(value); - if (!result.valid) { - throw new ScaleValidationError(result.reason ?? "invalid scale"); - } - } - - const bn = toBigNumber(value); - // Explicitly truncate beyond 7 decimals rather than silently rounding. - const truncated = bn.decimalPlaces(SCALE, BigNumber.ROUND_DOWN); - const scaled = truncated.times(SCALE_FACTOR).integerValue(BigNumber.ROUND_DOWN); - assertI128(scaled); - return scaled.toFixed(0); -} - -/** Convert a contract-unit integer back to a 7-decimal human string. */ -export function fromContractUnits(value: DecimalInput): string { - const bn = toBigNumber(value); - return bn.div(SCALE_FACTOR).toFixed(SCALE, BigNumber.ROUND_HALF_UP); -} - -/** Throw {@link OverflowError} if a contract-unit value is outside i128. */ -export function assertI128(scaled: BigNumber): void { - if (scaled.isGreaterThan(I128_MAX) || scaled.isLessThan(I128_MIN)) { - throw new OverflowError( - `value ${scaled.toFixed(0)} exceeds the i128 range` - ); - } -} - -// --------------------------------------------------------------------------- -// Safe arithmetic (throws on overflow) -// --------------------------------------------------------------------------- - -/** Assert a decimal result fits i128 once scaled to contract units. */ -function assertResultInRange(result: BigNumber): void { - assertI128(result.times(SCALE_FACTOR).integerValue(BigNumber.ROUND_DOWN)); -} - -export function addSafe(a: DecimalInput, b: DecimalInput): string { - const result = toBigNumber(a).plus(toBigNumber(b)); - assertResultInRange(result); - return result.toFixed(SCALE); -} - -export function subtractSafe(a: DecimalInput, b: DecimalInput): string { - const result = toBigNumber(a).minus(toBigNumber(b)); - assertResultInRange(result); - return result.toFixed(SCALE); -} - -export function multiplySafe(a: DecimalInput, b: DecimalInput): string { - const result = toBigNumber(a).times(toBigNumber(b)); - assertResultInRange(result); - return result.toFixed(SCALE); -} - -export function divideSafe(a: DecimalInput, b: DecimalInput): string { - const divisor = toBigNumber(b); - if (divisor.isZero()) { - throw new OverflowError("division by zero"); - } - const result = toBigNumber(a).div(divisor); - assertResultInRange(result); - return result.toFixed(SCALE); -} - -// --------------------------------------------------------------------------- -// General-purpose helpers (unchanged public surface) -// --------------------------------------------------------------------------- - -export function add(a: DecimalInput, b: DecimalInput): string { - return new BigNumber(a).plus(b).toFixed(SCALE); -} - -export function subtract(a: DecimalInput, b: DecimalInput): string { - return new BigNumber(a).minus(b).toFixed(SCALE); -} - -export function multiply(a: DecimalInput, b: DecimalInput): string { - return new BigNumber(a).times(b).toFixed(SCALE); -} - -export function divide(a: DecimalInput, b: DecimalInput): string { - return new BigNumber(a).div(b).toFixed(SCALE); -} - -export function compare(a: DecimalInput, b: DecimalInput): -1 | 0 | 1 { - const diff = new BigNumber(a).minus(b); - if (diff.isZero()) return 0; - return diff.isPositive() ? 1 : -1; -} - -export function formatDisplay(value: DecimalInput, decimals = 7): string { - return new BigNumber(value).toFormat(decimals); -} - -export function formatCompact(value: DecimalInput): string { - const bn = new BigNumber(value); - if (bn.isGreaterThanOrEqualTo(1_000_000)) return `${bn.div(1_000_000).toFixed(2)}M`; - if (bn.isGreaterThanOrEqualTo(1_000)) return `${bn.div(1_000).toFixed(2)}K`; - return bn.toFixed(2); -} - -export function isZero(value: DecimalInput): boolean { - return new BigNumber(value).isZero(); -} - -export function isGreaterThan(a: DecimalInput, b: DecimalInput): boolean { - return new BigNumber(a).isGreaterThan(b); -} - -export function toNumber(value: DecimalInput): number { - return new BigNumber(value).toNumber(); -} diff --git a/src/utils/merkleTree.ts b/src/utils/merkleTree.ts deleted file mode 100644 index ccbf6e5..0000000 --- a/src/utils/merkleTree.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { MerkleWasm } from './merkleWasm'; - -// Fallback JS implementation (stubbed for brevity as per instructions, only replacing actual usage) -function jsBuildTree(_leaves: Uint8Array): { root: Uint8Array, rootPtr: number } { - console.warn('Using JS fallback for Merkle tree construction'); - // Implement standard JS Merkle Tree if WASM completely fails - return { root: new Uint8Array(32), rootPtr: 0 }; -} - -function jsGenerateProof(_leafIndex: number): Uint8Array { - return new Uint8Array(0); -} - -function jsVerifyProof(_root: Uint8Array, _proof: Uint8Array, _leafData: Uint8Array): boolean { - return false; -} - -let wasmInstance: MerkleWasm | null = null; -let initPromise: Promise | null = null; - -export async function initMerkleTree() { - if (!initPromise) { - wasmInstance = new MerkleWasm(); - initPromise = wasmInstance.init().catch(err => { - console.error('Failed to initialize Merkle WASM:', err); - wasmInstance = null; - }); - } - return initPromise; -} - -export function buildTree(leaves: Uint8Array): { root: Uint8Array, rootPtr: number } { - if (wasmInstance) { - return wasmInstance.buildTree(leaves); - } - return jsBuildTree(leaves); -} - -export function generateProof(treePtr: number, leafIndex: number): Uint8Array { - if (wasmInstance) { - return wasmInstance.generateProof(treePtr, leafIndex); - } - return jsGenerateProof(leafIndex); -} - -export function verifyProof(root: Uint8Array, proof: Uint8Array, leafData: Uint8Array): boolean { - if (wasmInstance) { - return wasmInstance.verifyProof(root, proof, leafData); - } - return jsVerifyProof(root, proof, leafData); -} diff --git a/src/utils/merkleWasm.ts b/src/utils/merkleWasm.ts deleted file mode 100644 index d231689..0000000 --- a/src/utils/merkleWasm.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { loadMerkleWasm } from './wasmLoader'; - -export class MerkleWasm { - private instance: WebAssembly.Instance | null = null; - private memory: WebAssembly.Memory | null = null; - - async init() { - const result = await loadMerkleWasm(); - this.instance = result.instance; - this.memory = result.memory; - } - - private get exports() { - if (!this.instance) throw new Error('WASM not initialized'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return this.instance.exports as any; - } - - allocUint8Array(data: Uint8Array): number { - const ptr = this.exports.alloc(data.length); - const memArray = new Uint8Array(this.memory!.buffer, ptr, data.length); - memArray.set(data); - return ptr; - } - - dealloc(ptr: number, size: number) { - this.exports.dealloc(ptr, size); - } - - buildTree(leaves: Uint8Array): { root: Uint8Array, rootPtr: number } { - if (leaves.length % 64 !== 0) { - throw new Error('Leaves buffer must be a multiple of 64 bytes'); - } - const numLeaves = leaves.length / 64; - const leavesPtr = this.allocUint8Array(leaves); - - const rootPtr = this.exports.merkle_build_tree(leavesPtr, numLeaves); - - let root = new Uint8Array(0); - if (rootPtr !== 0) { - root = new Uint8Array(this.memory!.buffer, rootPtr, 32).slice(); - } - - this.dealloc(leavesPtr, leaves.length); - return { root, rootPtr }; - } - - generateProof(treePtr: number, leafIndex: number): Uint8Array { - // treePtr is ignored because WASM keeps tree globally to avoid passing pointers back and forth - const proofPtr = this.exports.merkle_generate_proof(leafIndex); - - const memArray = new Uint8Array(this.memory!.buffer, proofPtr, 4); - const dataView = new DataView(memArray.buffer, memArray.byteOffset, memArray.byteLength); - const proofLen = dataView.getUint32(0, true); - - const hashesArray = new Uint8Array(this.memory!.buffer, proofPtr + 4, proofLen * 32); - return hashesArray.slice(); - } - - verifyProof(root: Uint8Array, proof: Uint8Array, leafData: Uint8Array): boolean { - const proofLen = proof.length / 32; - const proofBuffer = new Uint8Array(4 + proof.length); - const dv = new DataView(proofBuffer.buffer, proofBuffer.byteOffset, proofBuffer.byteLength); - dv.setUint32(0, proofLen, true); - proofBuffer.set(proof, 4); - - const rootPtr = this.allocUint8Array(root); - const proofPtr = this.allocUint8Array(proofBuffer); - const leafPtr = this.allocUint8Array(leafData); - - const result = this.exports.merkle_verify_proof(rootPtr, proofPtr, leafPtr) === 1; - - this.dealloc(rootPtr, root.length); - this.dealloc(proofPtr, proofBuffer.length); - this.dealloc(leafPtr, leafData.length); - - return result; - } -} diff --git a/src/utils/porMerkle.ts b/src/utils/porMerkle.ts deleted file mode 100644 index c863a98..0000000 --- a/src/utils/porMerkle.ts +++ /dev/null @@ -1,160 +0,0 @@ -/** - * SHA-256 Merkle tree for Proof-of-Reserve commitments. - * - * Each leaf is `SHA-256( meterId || consumption(8-byte LE) || salt )`. The tree - * pads odd layers by duplicating the last node and supports up to 2^20 leaves. - * Hashing uses SubtleCrypto so large trees do not block the main thread. - * - * This is a dedicated module (the existing `merkleTree.ts` is a WASM-pointer - * wrapper used by `merkleWorker.worker.ts`); the two are intentionally separate. - */ - -const encoder = new TextEncoder(); - -/** SHA-256 of the given bytes. */ -export async function sha256(data: Uint8Array): Promise { - const digest = await crypto.subtle.digest("SHA-256", data as unknown as BufferSource); - return new Uint8Array(digest); -} - -export function bytesToHex(bytes: Uint8Array): string { - let out = ""; - for (const b of bytes) out += b.toString(16).padStart(2, "0"); - return out; -} - -export function hexToBytes(hex: string): Uint8Array { - const clean = hex.startsWith("0x") || hex.startsWith("0X") ? hex.slice(2) : hex; - if (clean.length % 2 !== 0) throw new Error(`Odd-length hex: ${hex}`); - const out = new Uint8Array(clean.length / 2); - for (let i = 0; i < out.length; i++) { - out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16); - } - return out; -} - -function concat(parts: Uint8Array[]): Uint8Array { - const total = parts.reduce((n, p) => n + p.length, 0); - const out = new Uint8Array(total); - let offset = 0; - for (const p of parts) { - out.set(p, offset); - offset += p.length; - } - return out; -} - -/** Encode a non-negative integer as 8 little-endian bytes. */ -export function u64le(value: bigint | number): Uint8Array { - let v = BigInt(value); - if (v < BigInt(0)) throw new Error("consumption must be non-negative"); - const out = new Uint8Array(8); - const mask = BigInt(0xff); - const shift = BigInt(8); - for (let i = 0; i < 8; i++) { - out[i] = Number(v & mask); - v >>= shift; - } - return out; -} - -export interface MeterLeaf { - meterId: string; - consumption: bigint | number; - /** Salt as hex (0x-optional). */ - salt: string; -} - -/** Hash a single meter leaf: SHA-256(meterId || consumption || salt). */ -export function leafHash(leaf: MeterLeaf): Promise { - return sha256( - concat([ - encoder.encode(leaf.meterId), - u64le(leaf.consumption), - hexToBytes(leaf.salt), - ]) - ); -} - -/** One step of a Merkle inclusion path. */ -export interface MerkleProofStep { - sibling: Uint8Array; - /** True when the sibling sits to the left of the running hash. */ - siblingIsLeft: boolean; -} - -export interface MerkleTree { - root: Uint8Array; - /** layers[0] = leaves, last layer = [root]. */ - layers: Uint8Array[][]; -} - -/** Hash a parent from its two children. */ -async function hashPair(left: Uint8Array, right: Uint8Array): Promise { - return sha256(concat([left, right])); -} - -/** - * Build the full tree from leaf hashes. An empty input yields a zero root. - * Odd layers duplicate their final node so every parent has two children. - */ -export async function buildTree(leaves: Uint8Array[]): Promise { - if (leaves.length === 0) { - return { root: new Uint8Array(32), layers: [[]] }; - } - - const layers: Uint8Array[][] = [leaves.slice()]; - let current = leaves; - while (current.length > 1) { - const next: Uint8Array[] = []; - for (let i = 0; i < current.length; i += 2) { - const left = current[i]; - const right = i + 1 < current.length ? current[i + 1] : current[i]; - next.push(await hashPair(left, right)); - } - layers.push(next); - current = next; - } - return { root: current[0], layers }; -} - -/** Convenience: just the root hex of a set of leaves. */ -export async function computeRoot(leaves: Uint8Array[]): Promise { - const { root } = await buildTree(leaves); - return bytesToHex(root); -} - -/** Generate the inclusion path for the leaf at `index`. */ -export function generateProof(tree: MerkleTree, index: number): MerkleProofStep[] { - const leafCount = tree.layers[0].length; - if (index < 0 || index >= leafCount) { - throw new Error(`Leaf index ${index} out of range (0..${leafCount - 1})`); - } - const proof: MerkleProofStep[] = []; - let idx = index; - for (let level = 0; level < tree.layers.length - 1; level++) { - const layer = tree.layers[level]; - const isRightNode = idx % 2 === 1; - const siblingIndex = isRightNode ? idx - 1 : idx + 1; - // Odd layer: the lone last node is paired with itself. - const sibling = layer[siblingIndex] ?? layer[idx]; - proof.push({ sibling, siblingIsLeft: isRightNode }); - idx = Math.floor(idx / 2); - } - return proof; -} - -/** Verify an inclusion path folds up to `root`. */ -export async function verifyProof( - root: Uint8Array, - leaf: Uint8Array, - proof: MerkleProofStep[] -): Promise { - let hash = leaf; - for (const step of proof) { - hash = step.siblingIsLeft - ? await hashPair(step.sibling, hash) - : await hashPair(hash, step.sibling); - } - return bytesToHex(hash) === bytesToHex(root); -} diff --git a/src/utils/sanitize.ts b/src/utils/sanitize.ts deleted file mode 100644 index c68bace..0000000 --- a/src/utils/sanitize.ts +++ /dev/null @@ -1,12 +0,0 @@ -export function sanitize(input: string): string { - return input - .replace(/[<>]/g, "") - .replace(/javascript:/gi, "") - .replace(/on\w+=/gi, "") - .trim(); -} - -export function validateLabel(raw: string): string { - const cleaned = sanitize(raw); - return cleaned.length > 64 ? cleaned.slice(0, 64) : cleaned; -} diff --git a/src/utils/sharedBuffer.ts b/src/utils/sharedBuffer.ts deleted file mode 100644 index 9233a67..0000000 --- a/src/utils/sharedBuffer.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * SharedArrayBuffer creation and atomic-access helpers for the timer wheel's - * cross-worker timekeeping (heartbeat, drift correction, command signalling). - * - * SharedArrayBuffer requires cross-origin isolation (COOP/COEP). When it is - * unavailable the buffer degrades to a plain ArrayBuffer: atomic loads/stores - * still work, but {@link futexWait} cannot block, so the worker falls back to a - * timed loop and the main thread to polling. - */ - -import { SAB_INDEX, SAB_INT32_LENGTH } from "@/types/scheduler"; - -export interface TimerBuffer { - buffer: SharedArrayBuffer | ArrayBuffer; - view: Int32Array; - /** True when backed by a real SharedArrayBuffer (atomic blocking works). */ - shared: boolean; -} - -/** Whether real cross-thread shared memory is available. */ -export function isSharedArrayBufferAvailable(): boolean { - return ( - typeof SharedArrayBuffer !== "undefined" && - // crossOriginIsolated is undefined in workers/node; treat absence as "ok". - (typeof crossOriginIsolated === "undefined" || crossOriginIsolated === true) - ); -} - -/** Allocate the 4 KiB control buffer (shared when possible). */ -export function createTimerBuffer(): TimerBuffer { - const bytes = SAB_INT32_LENGTH * 4; - if (isSharedArrayBufferAvailable()) { - const buffer = new SharedArrayBuffer(bytes); - return { buffer, view: new Int32Array(buffer), shared: true }; - } - const buffer = new ArrayBuffer(bytes); - return { buffer, view: new Int32Array(buffer), shared: false }; -} - -/** Wrap an existing (transferred) buffer in a typed view. */ -export function viewOf(buffer: SharedArrayBuffer | ArrayBuffer): TimerBuffer { - return { - buffer, - view: new Int32Array(buffer), - shared: typeof SharedArrayBuffer !== "undefined" && buffer instanceof SharedArrayBuffer, - }; -} - -// --- Field accessors (atomic) ---------------------------------------------- - -export function writeDrift(view: Int32Array, driftMs: number): void { - Atomics.store(view, SAB_INDEX.DRIFT, Math.trunc(driftMs)); -} -export function readDrift(view: Int32Array): number { - return Atomics.load(view, SAB_INDEX.DRIFT); -} - -export function setRunning(view: Int32Array, running: boolean): void { - Atomics.store(view, SAB_INDEX.RUNNING, running ? 1 : 0); -} -export function isRunning(view: Int32Array): boolean { - return Atomics.load(view, SAB_INDEX.RUNNING) === 1; -} - -export function writeLastNow(view: Int32Array, now: number): void { - Atomics.store(view, SAB_INDEX.LAST_NOW, Math.trunc(now) | 0); -} -export function readLastNow(view: Int32Array): number { - return Atomics.load(view, SAB_INDEX.LAST_NOW); -} - -/** Bump the command sequence and wake the worker. Returns the new sequence. */ -export function bumpCommandSeq(view: Int32Array): number { - const next = Atomics.add(view, SAB_INDEX.COMMAND_SEQ, 1) + 1; - futexNotify(view, SAB_INDEX.HEARTBEAT); - return next; -} -export function readCommandSeq(view: Int32Array): number { - return Atomics.load(view, SAB_INDEX.COMMAND_SEQ); -} - -export function incrementHeartbeat(view: Int32Array): number { - return Atomics.add(view, SAB_INDEX.HEARTBEAT, 1) + 1; -} - -/** Wake any threads blocked on `index`. No-op semantics on non-shared buffers. */ -export function futexNotify(view: Int32Array, index: number, count = 1): number { - try { - return Atomics.notify(view, index, count); - } catch { - return 0; // non-shared buffer - } -} - -/** - * Block until `index` changes from `expected` or `timeoutMs` elapses. On a - * non-shared buffer Atomics.wait throws; we report "not-equal" so the caller - * falls back to a timed loop. - */ -export function futexWait( - view: Int32Array, - index: number, - expected: number, - timeoutMs: number -): "ok" | "timed-out" | "not-equal" { - try { - return Atomics.wait(view, index, expected, timeoutMs); - } catch { - return "not-equal"; - } -} diff --git a/src/utils/suspenseResource.ts b/src/utils/suspenseResource.ts deleted file mode 100644 index e5ffd93..0000000 --- a/src/utils/suspenseResource.ts +++ /dev/null @@ -1,108 +0,0 @@ -"use client"; - -import { cacheStore, isFresh } from "@/store/slices/cacheSlice"; -import { type SuspenseResource } from "@/types/suspense"; - -/** - * Factory for Suspense-compatible resources. - * - * `read()` implements the throw-promise pattern with stale-while-revalidate: - * - fresh cache → return data; - * - stale cache → return data *and* revalidate in the background (no suspend); - * - no usable data → throw the in-flight fetch promise (suspend); - * - rejected → throw the error (for an ErrorBoundary), until invalidated. - * - * In-flight fetches are de-duplicated per cache key. - */ - -interface CacheStoreLike { - getEntry(key: string): import("@/types/suspense").CacheEntry | undefined; - setPending(key: string, ttlMs: number): void; - setResolved(key: string, data: unknown, ttlMs: number, now: number): void; - setRejected(key: string, error: string, now: number): void; - invalidateKey(key: string): void; -} - -export interface CreateResourceOptions { - cacheKey: string; - ttlMs: number; - now?: () => number; - store?: CacheStoreLike; -} - -/** In-flight fetch promises, keyed by cache key (module-global de-dup). */ -const inflight = new Map>(); - -export function createResource( - fetchFn: () => Promise, - options: CreateResourceOptions -): SuspenseResource { - const { cacheKey, ttlMs } = options; - const now = options.now ?? Date.now; - const store = options.store ?? cacheStore; - - function runFetch(foreground: boolean): Promise { - const existing = inflight.get(cacheKey); - if (existing) return existing; - - if (foreground) store.setPending(cacheKey, ttlMs); - - const promise = fetchFn() - .then((data) => { - store.setResolved(cacheKey, data, ttlMs, now()); - }) - .catch((err: unknown) => { - store.setRejected( - cacheKey, - err instanceof Error ? err.message : String(err), - now() - ); - }) - .finally(() => { - inflight.delete(cacheKey); - }); - - inflight.set(cacheKey, promise); - return promise; - } - - return { - read(): T { - const entry = store.getEntry(cacheKey); - const t = now(); - - if (entry?.status === "rejected" && entry.fetchedAt !== 0) { - throw new Error(entry.error ?? `resource ${cacheKey} failed`); - } - - if (entry && entry.data !== undefined && entry.status !== "rejected") { - if (isFresh(entry, t)) return entry.data as T; - // Stale: serve cached data and revalidate in the background. - void runFetch(false); - return entry.data as T; - } - - // No usable data → suspend on the fetch promise. - throw runFetch(true); - }, - - prefetch(): void { - const entry = store.getEntry(cacheKey); - if (isFresh(entry, now())) return; - void runFetch(false); - }, - - invalidate(): void { - store.invalidateKey(cacheKey); - }, - - peek(): T | undefined { - return store.getEntry(cacheKey)?.data as T | undefined; - }, - }; -} - -/** Test/teardown helper: drop any in-flight promise tracking. */ -export function _clearInflight(): void { - inflight.clear(); -} diff --git a/src/utils/telemetry/binaryFraming.ts b/src/utils/telemetry/binaryFraming.ts deleted file mode 100644 index defd11c..0000000 --- a/src/utils/telemetry/binaryFraming.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { TelemetryFrame } from "./types"; - -/** - * Encode/decode for the custom binary framing protocol. - * - * Wire layout (little-endian): - * bytes [0..2) -> u16 sequence number - * bytes [2..2+4N)-> N x f32 series values - * - * Keeping this in a dedicated module makes the protocol trivially testable and - * reusable by the streaming hook, unit tests and the reconnect benchmark. - */ - -export interface EncodedFrame { - sequence: number; - values: number[]; -} - -/** Serialize a frame to an ArrayBuffer (what the WebSocket sends/receives). */ -export function encodeFrame(sequence: number, values: number[]): ArrayBuffer { - const byteLength = 2 + values.length * 4; - const buffer = new ArrayBuffer(byteLength); - const view = new DataView(buffer); - view.setUint16(0, sequence & 0xffff, true); - for (let i = 0; i < values.length; i++) { - view.setFloat32(2 + i * 4, values[i], true); - } - return buffer; -} - -/** - * Parse a binary message into a {@link TelemetryFrame}. - * - * @param buffer raw WebSocket payload (ArrayBuffer). - * @param seriesCount when provided, reads exactly this many series values - * (clamped to what the buffer actually contains). When omitted, every f32 - * present in the payload is read. - */ -export function parseFrame( - buffer: ArrayBuffer, - seriesCount?: number -): TelemetryFrame { - if (!buffer || buffer.byteLength < 2) { - throw new Error(`Invalid telemetry frame: need >=2 bytes, got ${buffer?.byteLength ?? 0}`); - } - const view = new DataView(buffer); - const sequence = view.getUint16(0, true); - const available = Math.floor((buffer.byteLength - 2) / 4); - const count = - seriesCount != null ? Math.max(0, Math.min(seriesCount, available)) : available; - const values: number[] = new Array(count); - for (let i = 0; i < count; i++) { - values[i] = view.getFloat32(2 + i * 4, true); - } - return { sequence, values, receivedAt: Date.now() }; -} diff --git a/src/utils/telemetry/dataStore.ts b/src/utils/telemetry/dataStore.ts deleted file mode 100644 index ce6f3aa..0000000 --- a/src/utils/telemetry/dataStore.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { FrameValidator } from "./frameValidator"; -import { MAX_CHART_POINTS } from "./types"; -import type { ChartPoint, TelemetryFrame, ValidationReason } from "./types"; - -/** - * In-memory chart data store (the "Chart.js reactive array" equivalent). - * - * Holds one ring-buffer per series, hard-capped at `maxPoints` (default 10 000) - * via FIFO eviction. The store is mutated in place; consumers (the canvas - * renderer) read `datasets` directly inside their animation-frame loop so we - * avoid 60fps React re-renders. - */ -export class TelemetryDataStore { - readonly datasets: ChartPoint[][]; - private version = 0; - - constructor( - seriesCount: number, - private readonly maxPoints: number = MAX_CHART_POINTS - ) { - const count = Math.max(1, seriesCount); - this.datasets = Array.from({ length: count }, () => []); - } - - get seriesCount(): number { - return this.datasets.length; - } - - /** Monotonic counter bumped whenever the data mutates. */ - get revision(): number { - return this.version; - } - - append(series: number, point: ChartPoint): void { - const ds = this.datasets[series]; - if (!ds) return; - ds.push(point); - while (ds.length > this.maxPoints) ds.shift(); - } - - appendMany(series: number, points: ChartPoint[]): void { - const ds = this.datasets[series]; - if (!ds) return; - for (const p of points) ds.push(p); - while (ds.length > this.maxPoints) ds.shift(); - } - - clear(): void { - for (const ds of this.datasets) ds.length = 0; - this.version += 1; - } - - /** Signal a mutation occurred (used when callers mutate datasets directly). */ - bump(): void { - this.version += 1; - } - - /* ---- diagnostics ---- */ - - lastPoint(series: number): ChartPoint | undefined { - return this.datasets[series]?.[this.datasets[series].length - 1]; - } - - totalPoints(): number { - return this.datasets.reduce((acc, ds) => acc + ds.length, 0); - } - - /** Count of synthetic (interpolated / sentinel) points across all series. */ - syntheticPointCount(): number { - let n = 0; - for (const ds of this.datasets) { - for (const p of ds) if (p.synthetic) n += 1; - } - return n; - } - - /** Count of genuine, non-synthetic, non-NaN points (the "live" data). */ - realPointCount(): number { - let n = 0; - for (const ds of this.datasets) { - for (const p of ds) { - if (!p.synthetic && !Number.isNaN(p.value)) n += 1; - } - } - return n; - } -} - -export function makePoint( - value: number, - synthetic: boolean, - timestamp: number, - sequence: number -): ChartPoint { - return { value, synthetic, timestamp, sequence }; -} - -/** - * Shared ingestion path: run a frame through the validator and append both the - * synthetic fills and the real values to the store. Keeps the streaming hook - * and the reconnect benchmark on exactly the same code path. - */ -export function ingestFrame( - validator: FrameValidator, - store: TelemetryDataStore, - prevValues: number[], - frame: TelemetryFrame -): { accepted: boolean; reason: ValidationReason } { - const result = validator.validate(frame, prevValues); - if (!result.accepted) { - return { accepted: false, reason: result.reason }; - } - for (const fill of result.fills) { - store.appendMany(fill.series, fill.points); - } - const now = frame.receivedAt; - for (let s = 0; s < frame.values.length; s++) { - const value = frame.values[s]; - store.append(s, makePoint(value, false, now, frame.sequence)); - prevValues[s] = value; - } - store.bump(); - return { accepted: true, reason: result.reason }; -} diff --git a/src/utils/telemetry/frameValidator.ts b/src/utils/telemetry/frameValidator.ts deleted file mode 100644 index e23240a..0000000 --- a/src/utils/telemetry/frameValidator.ts +++ /dev/null @@ -1,382 +0,0 @@ -import { - GAP_FILL_CAP, - HALF_RANGE, - MAX_GAP_BEFORE_RESET, - SEQUENCE_MODULUS, - TRACKER_WINDOW_SIZE, -} from "./types"; -import type { - ChartPoint, - GapFillPoint, - TelemetryFrame, - ValidationReason, - ValidationResult, -} from "./types"; - -/* ------------------------------------------------------------------ * - * Pure sequence helpers - * ------------------------------------------------------------------ */ - -/** - * Forward (modular) distance from `prev` to `next` across the u16 wrap. - * Always returns a value in [0, 65535]. Values larger than {@link HALF_RANGE} - * mean `next` is effectively *behind* `prev` (a late / reordered frame). - */ -export function forwardDelta(prev: number, next: number): number { - return (((next - prev) % SEQUENCE_MODULUS) + SEQUENCE_MODULUS) % SEQUENCE_MODULUS; -} - -/** Median of a numeric sample (0 for empty input). */ -export function median(values: number[]): number { - if (values.length === 0) return 0; - const sorted = [...values].sort((a, b) => a - b); - const mid = Math.floor(sorted.length / 2); - return sorted.length % 2 === 0 - ? (sorted[mid - 1] + sorted[mid]) / 2 - : sorted[mid]; -} - -/** Median Absolute Deviation of `values` around their median. */ -export function medianAbsoluteDeviation(values: number[]): number { - if (values.length === 0) return 0; - const med = median(values); - return median(values.map((v) => Math.abs(v - med))); -} - -/** RFC4122-ish UUID, with a safe fallback when `crypto.randomUUID` is absent. */ -export function safeUUID(): string { - try { - const c = globalThis.crypto as Crypto | undefined; - if (c && typeof c.randomUUID === "function") return c.randomUUID(); - } catch { - /* fall through to manual generation */ - } - return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (ch) => { - const r = (Math.random() * 16) | 0; - const v = ch === "x" ? r : (r & 0x3) | 0x8; - return v.toString(16); - }); -} - -/* ------------------------------------------------------------------ * - * SequenceTracker — robust, adaptive gap detection - * ------------------------------------------------------------------ */ - -/** - * Maintains a sliding window of the last `capacity` sequence numbers and uses - * median-absolute-deviation (MAD) statistics to reason about gaps. - * - * Because the window is rebuilt after every reconnect, the tracker *learns the - * new baseline within 100 frames* and adapts, instead of relying on a single - * raw `lastSequence` subtraction (which is what caused the original bug). - */ -export class SequenceTracker { - private window: number[] = []; - - constructor(private readonly capacity: number = TRACKER_WINDOW_SIZE) {} - - reset(): void { - this.window = []; - } - - push(sequence: number): void { - this.window.push(sequence); - while (this.window.length > this.capacity) this.window.shift(); - } - - get size(): number { - return this.window.length; - } - - /** True when `sequence` is already present in the recent window (reorder/dup). */ - contains(sequence: number): boolean { - return this.window.includes(sequence); - } - - /** Median forward step between consecutive observed sequences. */ - medianStep(): number { - const deltas = this.deltas(); - return deltas.length === 0 ? 1 : median(deltas); - } - - /** MAD of the per-frame forward deltas — robust spread of the stream cadence. */ - mad(): number { - const deltas = this.deltas(); - return deltas.length === 0 ? 0 : medianAbsoluteDeviation(deltas); - } - - /** - * Largest forward step still considered "in stream noise". Built from the - * learned cadence (median step) plus a robust 3*MAD-sigma band. This is what - * downstream code uses instead of a raw subtraction to flag anomalies. - */ - expectedMaxStep(): number { - const step = this.medianStep(); - const variability = this.mad(); - const sigmaBand = 3 * 1.4826 * variability; - return Math.max(step, Math.round(step + (Number.isFinite(sigmaBand) ? sigmaBand : 0))); - } - - private deltas(): number[] { - const out: number[] = []; - for (let i = 1; i < this.window.length; i++) { - out.push(forwardDelta(this.window[i - 1], this.window[i])); - } - return out; - } -} - -/* ------------------------------------------------------------------ * - * FrameValidator — epoch detection + decimated gap-fill - * ------------------------------------------------------------------ */ - -export interface FrameValidatorOptions { - /** Max synthetic points inserted per gap before a NaN sentinel is used. */ - gapFillCap?: number; - /** Gap above which a mid-stream jump is treated as a reset/re-baseline. */ - maxGapBeforeReset?: number; - /** Sliding-window size for the internal {@link SequenceTracker}. */ - windowSize?: number; -} - -/** - * Validates incoming telemetry frames and decides what (if anything) to insert - * into the chart data store before the frame's own values. - * - * Key behaviours that resolve the reconnect-freeze issue: - * 1. Stream epoch detection — on every `onopen` a new monotonic `connectionId` - * is minted. The first frame of a fresh epoch accepts ANY sequence number - * and performs NO gap math, so `lastSequence=5201 -> newSequence=3` is no - * longer read as a 5198-frame drop. - * 2. Decimated interpolation — small in-stream gaps (<= gapFillCap) are filled - * with `(prevValue + nextValue) / 2`, each tagged `synthetic: true`. - * 3. Hard cap + NaN sentinel — gaps beyond the cap emit a single NaN sentinel - * per series (rendered as a disconnected segment) instead of thousands of - * synthetic points. - * 4. Reorder / duplicate awareness via {@link SequenceTracker}. - */ -export class FrameValidator { - private readonly tracker: SequenceTracker; - private lastSequence: number | null = null; - private newConnection = true; - private hadPriorEpoch = false; - private connectionId: string; - private connectionCounter = 0; - private epochCount = 0; - - private readonly gapFillCap: number; - private readonly maxGapBeforeReset: number; - - constructor(options: FrameValidatorOptions = {}) { - this.gapFillCap = options.gapFillCap ?? GAP_FILL_CAP; - this.maxGapBeforeReset = options.maxGapBeforeReset ?? MAX_GAP_BEFORE_RESET; - this.tracker = new SequenceTracker(options.windowSize ?? TRACKER_WINDOW_SIZE); - this.connectionId = this.generateConnectionId(); - } - - /** Current connection epoch identifier (changes on every `beginEpoch`). */ - get connectionEpochId(): string { - return this.connectionId; - } - - /** Monotonically increasing epoch ordinal (0 before first frame). */ - get epoch(): number { - return this.epochCount; - } - - get lastSequenceNumber(): number | null { - return this.lastSequence; - } - - /** True until the first frame of the current epoch is observed. */ - get awaitingFirstFrame(): boolean { - return this.newConnection; - } - - /** Exposed for diagnostics/tests. */ - get stats(): { - medianStep: number; - mad: number; - expectedMaxStep: number; - windowSize: number; - } { - return { - medianStep: this.tracker.medianStep(), - mad: this.tracker.mad(), - expectedMaxStep: this.tracker.expectedMaxStep(), - windowSize: this.tracker.size, - }; - } - - /** - * Called from the WebSocket `onopen` handler. Starts a new connection epoch: - * mints a fresh monotonic `connectionId`, clears the tracker so it re-learns - * the baseline, and arms the "first frame accepts any sequence" rule. - */ - beginEpoch(): string { - this.hadPriorEpoch = this.lastSequence !== null; - this.newConnection = true; - this.tracker.reset(); - this.connectionId = this.generateConnectionId(); - this.epochCount += 1; - return this.connectionId; - } - - /** - * Validate a frame. - * - * @param frame parsed telemetry frame. - * @param prevValues last *real* value per series (NaN when unknown). Used to - * compute interpolation values; the streaming hook maintains this array. - */ - validate(frame: TelemetryFrame, prevValues: number[]): ValidationResult { - const seq = frame.sequence; - const seriesCount = Math.max(frame.values.length, prevValues.length, 1); - - /* (1) First frame of a fresh connection epoch: accept ANY sequence. */ - if (this.newConnection) { - this.newConnection = false; - const fills: GapFillPoint[] = this.hadPriorEpoch - ? this.buildSentinelFills(seriesCount) // one NaN per series => visual break - : []; - this.hadPriorEpoch = false; - this.lastSequence = seq; - this.tracker.reset(); - this.tracker.push(seq); - return { - accepted: true, - reason: "epoch-reset", - connectionId: this.connectionId, - gap: 0, - fills, - }; - } - - const last = this.lastSequence ?? seq; - const delta = forwardDelta(last, seq); - - /* (2) Exact duplicate: drop. */ - if (delta === 0) { - return { - accepted: false, - reason: "duplicate", - connectionId: this.connectionId, - gap: 0, - fills: [], - }; - } - - /* (3) Late / reordered frame: behind the high-water mark (or already seen). - * Accept the value but do NOT advance the high-water mark or gap-fill. */ - if (delta > HALF_RANGE || this.tracker.contains(seq)) { - this.tracker.push(seq); - return { - accepted: true, - reason: "reordered", - connectionId: this.connectionId, - gap: 0, - fills: [], - }; - } - - const missing = delta - 1; - - /* (4) No gap: nominal next frame. */ - if (missing === 0) { - this.advance(seq); - return { - accepted: true, - reason: "accepted", - connectionId: this.connectionId, - gap: 0, - fills: [], - }; - } - - /* (5) Small in-stream gap: decimated interpolation. */ - if (delta <= this.gapFillCap) { - const fills = this.buildInterpolatedFills(seriesCount, missing, prevValues, frame); - this.advance(seq); - return { - accepted: true, - reason: "interpolated", - connectionId: this.connectionId, - gap: missing, - fills, - }; - } - - /* (6) Gap too large to interpolate: single NaN sentinel per series and - * re-baseline. A delta beyond `maxGapBeforeReset` is reported as an - * epoch-reset (effective counter reset detected mid-stream). */ - const reason: ValidationReason = - delta > this.maxGapBeforeReset ? "epoch-reset" : "gap-separator"; - const fills = this.buildSentinelFills(seriesCount); - this.advance(seq); - return { - accepted: true, - reason, - connectionId: this.connectionId, - gap: missing, - fills, - }; - } - - private advance(seq: number): void { - this.lastSequence = seq; - this.tracker.push(seq); - } - - private generateConnectionId(): string { - this.connectionCounter += 1; - return `conn-${this.connectionCounter}-${safeUUID()}`; - } - - private buildInterpolatedFills( - seriesCount: number, - missing: number, - prevValues: number[], - frame: TelemetryFrame - ): GapFillPoint[] { - const fills: GapFillPoint[] = []; - const baseTs = frame.receivedAt; - const count = Math.min(missing, this.gapFillCap); - for (let s = 0; s < seriesCount; s++) { - const prev = prevValues[s]; - const next = frame.values[s] ?? prev; - const interpolated = interpolate(prev, next); - const points: ChartPoint[] = new Array(count); - for (let i = 0; i < count; i++) { - points[i] = { - value: interpolated, - synthetic: true, - timestamp: baseTs, - sequence: -1, - }; - } - fills.push({ series: s, points }); - } - return fills; - } - - private buildSentinelFills(seriesCount: number): GapFillPoint[] { - const fills: GapFillPoint[] = []; - for (let s = 0; s < seriesCount; s++) { - fills.push({ - series: s, - points: [{ value: NaN, synthetic: true, timestamp: 0, sequence: -1 }], - }); - } - return fills; - } -} - -/** (prevValue + nextValue) / 2, falling back gracefully to whichever is known. */ -export function interpolate(prev: number, next: number): number { - const hasPrev = Number.isFinite(prev); - const hasNext = Number.isFinite(next); - if (hasPrev && hasNext) return (prev + next) / 2; - if (hasNext) return next; - if (hasPrev) return prev; - return 0; -} diff --git a/src/utils/telemetry/types.ts b/src/utils/telemetry/types.ts deleted file mode 100644 index 688062f..0000000 --- a/src/utils/telemetry/types.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Shared types & invariants for the real-time telemetry streaming subsystem. - * - * Protocol recap (custom binary framing): - * [u16 sequence][payload: N x f32 series values] - * - * The sequence number is a `u16` and therefore wraps at 65535. On every fresh - * WebSocket connection the remote counter resets to 0. The validator must be - * able to distinguish a genuine dropped-frame gap from a connection epoch - * reset, otherwise a single reconnect floods the chart with thousands of - * synthetic points (the original 86s-freeze bug). - */ - -/** u16 sequence space: values live in [0, 65535] and wrap modulo 65536. */ -export const SEQUENCE_MODULUS = 0x10000; -export const SEQUENCE_MAX = 0xffff; - -/** Half of the sequence space; a forward delta larger than this means the - * frame actually arrived *behind* the high-water mark (a reorder / late frame). */ -export const HALF_RANGE = SEQUENCE_MODULUS / 2; - -/** - * Max sequence gap that is still treated as a plausible in-stream drop before - * we suspect a counter reset / re-baseline. (State invariant parameter.) - */ -export const MAX_GAP_BEFORE_RESET = 1000; - -/** - * Hard cap on the number of synthetic points we will ever insert for a single - * gap. Beyond this we emit a single NaN sentinel that the renderer draws as a - * disconnected line segment. 300 frames == 5s at 60fps, which keeps the chart - * responsive instead of freezing for ~86s on a large reconnect gap. - */ -export const GAP_FILL_CAP = 300; - -/** Maximum number of points retained per series in the chart data store. */ -export const MAX_CHART_POINTS = 10_000; - -/** Expected frame rate (updates per second) used for sizing/time math. */ -export const FRAME_RATE_HZ = 60; - -/** Sliding window size used by {@link SequenceTracker}. */ -export const TRACKER_WINDOW_SIZE = 100; - -/** A parsed telemetry frame. */ -export interface TelemetryFrame { - /** u16 sequence number from the wire. */ - sequence: number; - /** One reading per series (CPU, memory, bandwidth, ...). */ - values: number[]; - /** Wall-clock ms when the frame was received. */ - receivedAt: number; -} - -/** - * A single point in a chart series. `NaN` is a sentinel meaning "gap - * separator" (the renderer breaks the line). `synthetic` marks interpolated - * points so they can be drawn with lower opacity / dashed strokes. - */ -export interface ChartPoint { - value: number; - synthetic: boolean; - timestamp: number; - /** u16 sequence this point corresponds to, or -1 for a sentinel/synthetic. */ - sequence: number; -} - -/** Why a frame was classified the way it was by the validator. */ -export type ValidationReason = - | "accepted" - | "duplicate" - | "reordered" - | "epoch-reset" - | "interpolated" - | "gap-separator"; - -/** Synthetic points the data store should insert before the accepted frame. */ -export interface GapFillPoint { - series: number; - points: ChartPoint[]; -} - -/** Result of validating a single incoming frame. */ -export interface ValidationResult { - accepted: boolean; - reason: ValidationReason; - connectionId: string; - /** Number of missing frames detected (0 when none). */ - gap: number; - /** Per-series synthetic points to insert before this frame. */ - fills: GapFillPoint[]; -} diff --git a/src/utils/tileMath.ts b/src/utils/tileMath.ts deleted file mode 100644 index b60f617..0000000 --- a/src/utils/tileMath.ts +++ /dev/null @@ -1,189 +0,0 @@ -/** - * Pure slippy-map tile math + viewport trajectory prediction for the prefetch - * scheduler. No DOM, IndexedDB or Mapbox dependencies, so it is fully testable. - */ - -import { - GRID_RADIUS, - LOOKAHEAD_SECONDS, - MAX_ZOOM, - MIN_ZOOM, - STALE_HEADING_DEG, - TTL_MS, - VELOCITY_THRESHOLD, - ZOOM_LOOKAHEAD, - type BBox, - type GeoSample, - type TileId, - type TileMeta, - type Viewport, -} from "@/types/tile"; - -const DEG2RAD = Math.PI / 180; -const M_PER_DEG_LAT = 111_320; - -export function tileKey(z: number, x: number, y: number): string { - return `${z}/${x}/${y}`; -} - -export function tileIdKey(t: TileId): string { - return tileKey(t.z, t.x, t.y); -} - -export function parseTileKey(key: string): TileId { - const [z, x, y] = key.split("/").map(Number); - return { z, x, y }; -} - -const clampLat = (lat: number) => Math.min(85.05112878, Math.max(-85.05112878, lat)); -const clampZoom = (z: number) => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, Math.round(z))); - -/** Convert lng/lat to the slippy-map tile containing it at zoom `z`. */ -export function lngLatToTile(lng: number, lat: number, z: number): TileId { - const zoom = clampZoom(z); - const n = 2 ** zoom; - const latRad = clampLat(lat) * DEG2RAD; - const x = Math.floor(((lng + 180) / 360) * n); - const y = Math.floor( - ((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * n - ); - const max = n - 1; - return { - z: zoom, - x: Math.min(max, Math.max(0, x)), - y: Math.min(max, Math.max(0, y)), - }; -} - -/** Geographic bounds of a tile. */ -export function tileBounds(t: TileId): BBox { - const n = 2 ** t.z; - const lngOf = (x: number) => (x / n) * 360 - 180; - const latOf = (y: number) => { - const r = Math.PI * (1 - (2 * y) / n); - return (Math.atan(Math.sinh(r)) * 180) / Math.PI; - }; - return { - west: lngOf(t.x), - east: lngOf(t.x + 1), - north: latOf(t.y), - south: latOf(t.y + 1), - }; -} - -/** Smallest angular difference between two headings (degrees, 0–180). */ -export function headingDelta(a: number, b: number): number { - const d = Math.abs(a - b) % 360; - return d > 180 ? 360 - d : d; -} - -/** Whether a heading change is large enough to cancel pending prefetches. */ -export function isStaleHeading(prev: number, next: number): boolean { - return headingDelta(prev, next) > STALE_HEADING_DEG; -} - -/** Whether the operator is moving fast enough to warrant prefetching. */ -export function shouldPrefetch(sample: GeoSample): boolean { - return sample.speed !== null && sample.speed > VELOCITY_THRESHOLD; -} - -/** - * Project the operator's position forward along their heading at current speed. - * Falls back to the current position when heading/speed are unknown. - */ -export function predictCenter( - sample: GeoSample, - lookaheadSeconds = LOOKAHEAD_SECONDS -): { lng: number; lat: number } { - if (sample.heading === null || sample.speed === null || sample.speed <= 0) { - return { lng: sample.lng, lat: sample.lat }; - } - const distance = sample.speed * lookaheadSeconds; // meters - const headingRad = sample.heading * DEG2RAD; - const dLat = (distance * Math.cos(headingRad)) / M_PER_DEG_LAT; - const cosLat = Math.cos(clampLat(sample.lat) * DEG2RAD) || 1e-9; - const dLng = (distance * Math.sin(headingRad)) / (M_PER_DEG_LAT * cosLat); - return { lng: sample.lng + dLng, lat: sample.lat + dLat }; -} - -/** Tile pyramid burst: 3×3 grid at current ± lookahead zoom levels. */ -export function burstTiles( - center: { lng: number; lat: number }, - zoom: number, - gridRadius = GRID_RADIUS, - zoomLookahead = ZOOM_LOOKAHEAD -): TileId[] { - const tiles: TileId[] = []; - const baseZoom = clampZoom(zoom); - for (let z = baseZoom - zoomLookahead; z <= baseZoom + zoomLookahead; z++) { - if (z < MIN_ZOOM || z > MAX_ZOOM) continue; - const c = lngLatToTile(center.lng, center.lat, z); - const max = 2 ** z - 1; - for (let dx = -gridRadius; dx <= gridRadius; dx++) { - for (let dy = -gridRadius; dy <= gridRadius; dy++) { - const x = c.x + dx; - const y = c.y + dy; - if (x < 0 || y < 0 || x > max || y > max) continue; - tiles.push({ z, x, y }); - } - } - } - return tiles; -} - -/** Predicted bounding box covering the burst grid around the projected center. */ -export function predictBBox(viewport: Viewport, sample: GeoSample): BBox { - const center = predictCenter(sample); - const tiles = burstTiles(center, viewport.zoom); - // Use the base-zoom tiles for the bbox (the densest LOD). - const baseZoom = clampZoom(viewport.zoom); - const baseTiles = tiles.filter((t) => t.z === baseZoom); - const source = baseTiles.length ? baseTiles : tiles; - let west = Infinity; - let south = Infinity; - let east = -Infinity; - let north = -Infinity; - for (const t of source) { - const b = tileBounds(t); - west = Math.min(west, b.west); - east = Math.max(east, b.east); - south = Math.min(south, b.south); - north = Math.max(north, b.north); - } - return { west, south, east, north }; -} - -/** Zoom levels to burst (current ± lookahead, clamped). */ -export function zoomLevelsFor(zoom: number, zoomLookahead = ZOOM_LOOKAHEAD): number[] { - const base = clampZoom(zoom); - const levels: number[] = []; - for (let z = base - zoomLookahead; z <= base + zoomLookahead; z++) { - if (z >= MIN_ZOOM && z <= MAX_ZOOM) levels.push(z); - } - return levels; -} - -/** Every tile covering `bbox` at zoom `z`. */ -export function tilesInBBox(bbox: BBox, z: number): TileId[] { - const topLeft = lngLatToTile(bbox.west, bbox.north, z); - const bottomRight = lngLatToTile(bbox.east, bbox.south, z); - const tiles: TileId[] = []; - const minX = Math.min(topLeft.x, bottomRight.x); - const maxX = Math.max(topLeft.x, bottomRight.x); - const minY = Math.min(topLeft.y, bottomRight.y); - const maxY = Math.max(topLeft.y, bottomRight.y); - for (let x = minX; x <= maxX; x++) { - for (let y = minY; y <= maxY; y++) tiles.push({ z, x, y }); - } - return tiles; -} - -/** TTL (ms) for a tile by its zoom. */ -export function ttlForZoom(z: number): number { - return z <= 14 ? TTL_MS.lowZoom : TTL_MS.highZoom; -} - -/** Whether a cached tile has exceeded its zoom-dependent TTL. */ -export function isStale(meta: TileMeta, now: number): boolean { - return now - meta.fetchedAt > ttlForZoom(meta.z); -} diff --git a/src/utils/timerWheelCore.ts b/src/utils/timerWheelCore.ts deleted file mode 100644 index 3337f29..0000000 --- a/src/utils/timerWheelCore.ts +++ /dev/null @@ -1,166 +0,0 @@ -/** - * Hashed timer wheel (Netty/Varghese & Lauck style) — the pure data structure - * shared by the worker and the polling fallback, and the thing under test. - * - * 1,024 slots × 100 ms (≈102.4 s span). A job is hashed into a slot by its - * fire-tick; jobs further out than one rotation carry a `rounds` counter that is - * decremented each time the cursor passes their slot. Once a job's rotation is - * complete it moves to a `pending` set and fires as soon as `now ≥ fireAt`, - * which guarantees sub-slot jobs are never skipped. - */ - -import { - MAX_JOBS, - PRECISION_TOLERANCE_MS, - SLOT_MS, - WHEEL_SLOTS, - type FiredJob, - type TimerJob, - type WheelJob, -} from "@/types/scheduler"; - -export class TimerWheel { - private readonly slots: Set[]; - private readonly jobs = new Map(); - /** Jobs whose rotation completed, awaiting their exact fire-at. */ - private readonly pending = new Set(); - private currentTick: number; - private drift = 0; - - constructor(startNow = 0) { - this.slots = Array.from({ length: WHEEL_SLOTS }, () => new Set()); - this.currentTick = Math.floor(startNow / SLOT_MS); - } - - get size(): number { - return this.jobs.size; - } - - getJob(id: string): WheelJob | undefined { - return this.jobs.get(id); - } - - list(): WheelJob[] { - return [...this.jobs.values()]; - } - - /** Apply a drift correction (ms) added to `now` on each advance. */ - setDrift(ms: number): void { - this.drift = ms; - } - - /** Schedule (or replace) a job. Throws past {@link MAX_JOBS}. */ - schedule(job: TimerJob): WheelJob { - if (!this.jobs.has(job.id) && this.jobs.size >= MAX_JOBS) { - throw new Error(`Timer wheel is full (max ${MAX_JOBS} jobs)`); - } - return this.place(job); - } - - /** Cancel a job. Returns true if it existed. */ - cancel(id: string): boolean { - const wj = this.jobs.get(id); - if (!wj) return false; - this.slots[wj.slot].delete(id); - this.pending.delete(id); - this.jobs.delete(id); - return true; - } - - private place(job: TimerJob): WheelJob { - const prev = this.jobs.get(job.id); - if (prev) { - this.slots[prev.slot].delete(job.id); - this.pending.delete(job.id); - } - - const targetTick = Math.floor(job.fireAt / SLOT_MS); - const slot = ((targetTick % WHEEL_SLOTS) + WHEEL_SLOTS) % WHEEL_SLOTS; - const ticksAway = targetTick - this.currentTick; - const rounds = ticksAway <= 0 ? 0 : Math.floor(ticksAway / WHEEL_SLOTS); - - const wj: WheelJob = { - ...job, - slot, - rounds, - missedCount: prev?.missedCount ?? 0, - lastFiredAt: prev?.lastFiredAt ?? null, - }; - this.jobs.set(job.id, wj); - - if (ticksAway <= 0) { - this.pending.add(job.id); - } else { - this.slots[slot].add(job.id); - } - return wj; - } - - /** - * Advance the wheel to `now` (wall-clock ms) and return the jobs that fired. - * Recurring jobs are rescheduled by `fireAt += intervalMs` (at most one fire - * per advance, so being behind never triggers a catch-up storm). - */ - advance(now: number): FiredJob[] { - const clockNow = now + this.drift; - const targetTick = Math.floor(clockNow / SLOT_MS); - - // Step the cursor; jobs whose rotation completes move to `pending`. - while (this.currentTick < targetTick) { - this.currentTick++; - const slot = this.currentTick % WHEEL_SLOTS; - for (const id of [...this.slots[slot]]) { - const wj = this.jobs.get(id); - if (!wj) { - this.slots[slot].delete(id); - continue; - } - if (wj.rounds > 0) { - wj.rounds -= 1; - continue; - } - this.slots[slot].delete(id); - this.pending.add(id); - } - } - - // Fire pending jobs that have reached their fire-at. - const fired: FiredJob[] = []; - for (const id of [...this.pending]) { - const wj = this.jobs.get(id); - if (!wj) { - this.pending.delete(id); - continue; - } - if (wj.fireAt > clockNow) continue; - - this.pending.delete(id); - const lateness = clockNow - wj.fireAt; - const missed = Math.abs(lateness) > PRECISION_TOLERANCE_MS; - if (missed) wj.missedCount += 1; - wj.lastFiredAt = clockNow; - - fired.push({ - id: wj.id, - handlerKey: wj.handlerKey, - scheduledFor: wj.fireAt, - firedAt: clockNow, - lateness, - missed, - }); - - if (wj.intervalMs && wj.intervalMs > 0) { - // Reschedule for the next cadence tick (carries missed/lastFired over). - this.place({ - id: wj.id, - handlerKey: wj.handlerKey, - fireAt: wj.fireAt + wj.intervalMs, - intervalMs: wj.intervalMs, - }); - } else { - this.jobs.delete(id); - } - } - return fired; - } -} diff --git a/src/utils/topicRouter.ts b/src/utils/topicRouter.ts deleted file mode 100644 index a5cbdb7..0000000 --- a/src/utils/topicRouter.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * Topic trie for routing push events to registered handlers. - * - * Subscriptions are keyed by a topic pattern: an exact topic - * (`meter.water.breach`) or a trailing-wildcard ancestor (`meter.water.*`, - * `meter.*`, `*`). A trailing `*` matches one or more remaining segments, so - * `meter.*` matches `meter.water.breach`. Matching collects handlers from the - * exact path and every wildcard ancestor. - */ - -import { - COALESCENCE_BODY_PREFIX, - MAX_HANDLERS_PER_TOPIC, - MAX_SUBSCRIPTIONS, - TOPIC_MAX_DEPTH, - type PushPayload, - type PushTopicHandler, -} from "@/types/notification"; - -const SEGMENT_RE = /^[a-z0-9_-]+$/; - -/** Validate a subscription pattern (segments + optional trailing wildcard). */ -export function isValidPattern(pattern: string): boolean { - if (!pattern) return false; - const segments = pattern.split("."); - if (segments.length < 1 || segments.length > TOPIC_MAX_DEPTH) return false; - for (let i = 0; i < segments.length; i++) { - const seg = segments[i]; - if (seg === "*") { - // A wildcard is only valid as the final segment. - if (i !== segments.length - 1) return false; - continue; - } - if (!SEGMENT_RE.test(seg)) return false; - } - return true; -} - -/** Validate a concrete (non-wildcard) topic. */ -export function isValidTopic(topic: string): boolean { - if (!topic) return false; - const segments = topic.split("."); - if (segments.length < 1 || segments.length > TOPIC_MAX_DEPTH) return false; - return segments.every((s) => SEGMENT_RE.test(s)); -} - -/** - * Coalescence key: identical events within the window share `topic` plus the - * first {@link COALESCENCE_BODY_PREFIX} chars of the body. Mirrored inline in - * `public/sw.js` (the SW cannot import this module). - */ -export function coalescenceKey(topic: string, body: string): string { - return `${topic} ${(body ?? "").slice(0, COALESCENCE_BODY_PREFIX)}`; -} - -interface TrieNode { - children: Map; - handlers: Set; -} - -function createNode(): TrieNode { - return { children: new Map(), handlers: new Set() }; -} - -export class TopicRouter { - private readonly root = createNode(); - /** Number of distinct patterns currently holding at least one handler. */ - private patternCount = 0; - - /** Distinct subscribed patterns. */ - get size(): number { - return this.patternCount; - } - - /** - * Register `handler` for `pattern`. Returns an unsubscribe function. Throws if - * the subscription or per-topic handler limits would be exceeded. - */ - insert(pattern: string, handler: PushTopicHandler): () => void { - if (!isValidPattern(pattern)) { - throw new Error(`Invalid topic pattern: "${pattern}"`); - } - - const segments = pattern.split("."); - let node = this.root; - for (const seg of segments) { - let child = node.children.get(seg); - if (!child) { - child = createNode(); - node.children.set(seg, child); - } - node = child; - } - - const isNewPattern = node.handlers.size === 0; - if (isNewPattern && this.patternCount >= MAX_SUBSCRIPTIONS) { - throw new Error( - `Subscription limit reached (${MAX_SUBSCRIPTIONS} topics)` - ); - } - if (node.handlers.size >= MAX_HANDLERS_PER_TOPIC) { - throw new Error( - `Handler limit reached for "${pattern}" (${MAX_HANDLERS_PER_TOPIC})` - ); - } - - node.handlers.add(handler); - if (isNewPattern) this.patternCount += 1; - - let active = true; - return () => { - if (!active) return; - active = false; - if (node.handlers.delete(handler) && node.handlers.size === 0) { - this.patternCount -= 1; - } - }; - } - - /** All handlers matching `topic` (exact path + wildcard ancestors). */ - match(topic: string): PushTopicHandler[] { - if (!isValidTopic(topic)) return []; - const segments = topic.split("."); - const out: PushTopicHandler[] = []; - this.collect(this.root, segments, 0, out); - return out; - } - - private collect( - node: TrieNode, - segments: string[], - index: number, - out: PushTopicHandler[] - ): void { - if (index === segments.length) { - // Exact match terminates here. - for (const h of node.handlers) out.push(h); - return; - } - - // A trailing wildcard child matches the remaining (≥1) segments. - const star = node.children.get("*"); - if (star) { - for (const h of star.handlers) out.push(h); - } - - // Descend the exact segment path. - const exact = node.children.get(segments[index]); - if (exact) { - this.collect(exact, segments, index + 1, out); - } - } - - /** - * Dispatch `payload` to every matching handler. Handler exceptions are - * isolated so one bad subscriber cannot break delivery to the others. - * Returns the number of handlers invoked. - */ - emit(payload: PushPayload): number { - const handlers = this.match(payload.topic); - for (const handler of handlers) { - try { - handler(payload); - } catch { - // isolate handler failures - } - } - return handlers.length; - } - - /** Remove all subscriptions. */ - clear(): void { - this.root.children.clear(); - this.root.handlers.clear(); - this.patternCount = 0; - } -} - -/** Shared singleton router for the app. */ -export const topicRouter = new TopicRouter(); diff --git a/src/utils/vectorClock.ts b/src/utils/vectorClock.ts deleted file mode 100644 index f2b3e8f..0000000 --- a/src/utils/vectorClock.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Lamport Vector Clock for CRDT-based conflict resolution. - * - * Each peer maintains a map of peerId → logical counter. The clock - * is used to determine the causal relationship between two operations - * and to merge incoming state from other peers. - * - * Comparison results: - * Before — this clock is strictly behind `other` - * After — this clock is strictly ahead of `other` - * Concurrent — neither dominates (conflicting edits) - */ - -export type ClockMap = Map; - -export type Ordering = "Before" | "After" | "Concurrent"; - -export class VectorClock { - private entries: ClockMap; - - constructor(initial?: ClockMap) { - this.entries = initial ? new Map(initial) : new Map(); - } - - /** Increment this peer's counter and return the new value. */ - tick(peerId: string): number { - const current = this.entries.get(peerId) ?? 0; - const next = current + 1; - this.entries.set(peerId, next); - return next; - } - - /** Get the current counter for a peer (0 if unknown). */ - get(peerId: string): number { - return this.entries.get(peerId) ?? 0; - } - - /** - * Merge another vector clock into this one by taking the per-peer - * maximums. Returns a new VectorClock (immutable-style). - */ - merge(other: VectorClock): VectorClock { - const merged = new Map(this.entries); - for (const [peerId, counter] of other.entries) { - const existing = merged.get(peerId) ?? 0; - if (counter > existing) { - merged.set(peerId, counter); - } - } - return new VectorClock(merged); - } - - /** - * Compare this clock against `other`. - * - * - Before: every entry in this is ≤ the corresponding entry in other, - * AND at least one is strictly less. - * - After: every entry in other is ≤ the corresponding entry in this, - * AND at least one is strictly less. - * - Concurrent: neither dominates. - */ - compare(other: VectorClock): Ordering { - let hasLess = false; - let hasGreater = false; - - const allPeerIds = new Set(); - for (const id of this.entries.keys()) allPeerIds.add(id); - for (const id of other.entries.keys()) allPeerIds.add(id); - - for (const peerId of allPeerIds) { - const self = this.get(peerId); - const oth = other.get(peerId); - if (self < oth) hasGreater = true; // other is ahead - if (self > oth) hasLess = true; // this is ahead - } - - if (!hasLess && !hasGreater) return "Concurrent"; - if (hasLess && !hasGreater) return "After"; - if (!hasLess && hasGreater) return "Before"; - return "Concurrent"; - } - - /** Serialise to a plain object for transmission / storage. */ - toJSON(): Record { - return Object.fromEntries(this.entries); - } - - /** Deserialise from a plain object. */ - static fromJSON(json: Record): VectorClock { - return new VectorClock(new Map(Object.entries(json))); - } - - /** Shallow clone. */ - clone(): VectorClock { - return new VectorClock(new Map(this.entries)); - } -} diff --git a/src/utils/wasmLoader.ts b/src/utils/wasmLoader.ts deleted file mode 100644 index 99b8b81..0000000 --- a/src/utils/wasmLoader.ts +++ /dev/null @@ -1,95 +0,0 @@ -let cachedInstance: WebAssembly.Instance | null = null; -let cachedMemory: WebAssembly.Memory | null = null; - -export async function loadMerkleWasm(): Promise<{ instance: WebAssembly.Instance; memory: WebAssembly.Memory }> { - if (cachedInstance && cachedMemory) { - return { instance: cachedInstance, memory: cachedMemory }; - } - - const response = await fetch('/wasm/merkle_wasm.wasm'); - const wasmModule = await WebAssembly.instantiateStreaming(response, {}); - - cachedInstance = wasmModule.instance; - cachedMemory = wasmModule.instance.exports.memory as WebAssembly.Memory; - - return { instance: cachedInstance, memory: cachedMemory }; -} - -// --------------------------------------------------------------------------- -// Generic WASM loader (used by the Proof-of-Reserve prover worker). -// --------------------------------------------------------------------------- - -const moduleCache = new Map>(); - -export interface LoadWasmOptions { - /** Import object passed to instantiation. */ - imports?: WebAssembly.Imports; - /** Expected lowercase-hex SHA-256 of the binary; verified before use. */ - integrity?: string; - /** Bypass the in-memory cache. */ - noCache?: boolean; -} - -export class WasmIntegrityError extends Error { - constructor(readonly url: string, readonly expected: string, readonly actual: string) { - super(`WASM integrity check failed for ${url}: expected ${expected}, got ${actual}`); - this.name = 'WasmIntegrityError'; - } -} - -async function sha256Hex(bytes: ArrayBuffer): Promise { - const digest = await crypto.subtle.digest('SHA-256', bytes); - return Array.from(new Uint8Array(digest)) - .map((b) => b.toString(16).padStart(2, '0')) - .join(''); -} - -/** - * Fetch, optionally verify (SHA-256), instantiate and cache a WASM module by - * URL. When `integrity` is provided the module is rejected on mismatch. Uses - * buffer instantiation (not streaming) so the bytes can be hashed first. - */ -export async function loadWasmModule( - url: string, - options: LoadWasmOptions = {}, -): Promise { - if (!options.noCache) { - const cached = moduleCache.get(url); - if (cached) return cached; - } - - const promise = (async () => { - const response = await fetch(url); - if (!response.ok) { - throw new Error(`Failed to fetch WASM ${url}: HTTP ${response.status}`); - } - const bytes = await response.arrayBuffer(); - - if (options.integrity) { - const actual = await sha256Hex(bytes); - if (actual !== options.integrity.toLowerCase()) { - throw new WasmIntegrityError(url, options.integrity.toLowerCase(), actual); - } - } - - const { instance } = await WebAssembly.instantiate(bytes, options.imports ?? {}); - return instance; - })(); - - if (!options.noCache) { - // Drop the cache entry if loading fails so a retry can succeed. - moduleCache.set( - url, - promise.catch((err) => { - moduleCache.delete(url); - throw err; - }), - ); - } - return promise; -} - -/** Clear the generic WASM module cache. */ -export function clearWasmCache(): void { - moduleCache.clear(); -} diff --git a/tests/components/AssetHeatmapLayer.test.tsx b/tests/components/AssetHeatmapLayer.test.tsx deleted file mode 100644 index 2ddbd8e..0000000 --- a/tests/components/AssetHeatmapLayer.test.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { clearStore, getTile, acquireTileLock, writeTile, atomicSwapTile, getTileGeneration } from '../../src/components/map/heatmapStore'; -import { getAffectedTiles } from '../../src/components/map/boundaryManager'; - -describe('heatmapStore', () => { - beforeEach(() => clearStore()); - - it('writes and reads tile data', () => { - const data = new Float32Array([1, 2, 3]); - atomicSwapTile('test:0:0', data); - expect(getTile('test:0:0')).toEqual(data); - }); - - it('increments generation on each write', () => { - atomicSwapTile('a:0:0', new Float32Array(1)); - expect(getTileGeneration('a:0:0')).toBe(1); - atomicSwapTile('a:0:0', new Float32Array(1)); - expect(getTileGeneration('a:0:0')).toBe(2); - }); - - it('discards stale writes', () => { - atomicSwapTile('b:0:0', new Float32Array([1])); - const gen = getTileGeneration('b:0:0'); - const result = writeTile('b:0:0', gen - 1, new Float32Array([99])); - expect(result).toBe(false); - expect(getTile('b:0:0')![0]).toBe(1); - }); - - it('acquires and releases tile lock', async () => { - const release = await acquireTileLock('lock:0:0'); - expect(getTileGeneration('lock:0:0')).toBe(1); - release(); - }); - - it('returns undefined for missing tile', () => { - expect(getTile('nonexistent')).toBeUndefined(); - }); -}); - -describe('boundaryManager', () => { - it('generates tile keys for a region', () => { - const tiles = getAffectedTiles('flood_zone', { - minX: 0, minY: 0, maxX: 300, maxY: 300, - }); - expect(tiles).toContain('flood_zone:0:0'); - expect(tiles).toContain('flood_zone:1:0'); - expect(tiles).toContain('flood_zone:0:1'); - expect(tiles).toContain('flood_zone:1:1'); - }); - - it('handles single-tile region', () => { - const tiles = getAffectedTiles('grid_region', { - minX: 0, minY: 0, maxX: 100, maxY: 100, - }); - expect(tiles).toEqual(['grid_region:0:0']); - }); -}); diff --git a/tests/components/ResourceTelemetryChart.test.ts b/tests/components/ResourceTelemetryChart.test.ts deleted file mode 100644 index c4e7b1a..0000000 --- a/tests/components/ResourceTelemetryChart.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { drawTelemetry } from "@/components/dashboard/ResourceTelemetryChart"; -import type { ChartPoint } from "@/utils/telemetry/types"; - -/** - * A recording fake 2D context. We only need to observe the high-level - * rendering decisions (how many strokes, whether the line is dashed, opacity) - * to prove the gap-separator / synthetic styling behaviour. - */ -class FakeCtx { - strokes = 0; - lineDashCalls: number[][] = []; - alphaValues: number[] = []; - moveToCalls = 0; - lineToCalls = 0; - clearRectCount = 0; - private alpha = 1; - - strokeStyle = ""; - lineWidth = 1; - fillStyle = ""; - font = ""; - - get globalAlpha(): number { - return this.alpha; - } - set globalAlpha(v: number) { - this.alpha = v; - this.alphaValues.push(v); - } - - setLineDash(seg: number[]): void { - this.lineDashCalls.push(seg); - } - beginPath(): void { - /* recorded implicitly via moveTo/stroke */ - } - moveTo(_x: number, _y: number): void { - this.moveToCalls++; - } - lineTo(_x: number, _y: number): void { - this.lineToCalls++; - } - stroke(): void { - this.strokes++; - } - clearRect(): void { - this.clearRectCount++; - } - fillRect(): void { - /* no-op */ - } - fillText(): void { - /* no-op */ - } -} - -function point(value: number, synthetic = false): ChartPoint { - return { value, synthetic, timestamp: 0, sequence: synthetic ? -1 : 0 }; -} - -function ctx(): CanvasRenderingContext2D { - return new FakeCtx() as unknown as CanvasRenderingContext2D; -} - -describe("drawTelemetry renderer", () => { - it("draws a single solid segment for a normal real-valued series", () => { - const c = ctx(); - drawTelemetry(c, [[point(10), point(20), point(30)]], { - width: 100, - height: 100, - }); - const fake = c as unknown as FakeCtx; - expect(fake.strokes).toBe(1); - expect(fake.lineDashCalls).toContainEqual([]); - expect(fake.alphaValues).toContain(1); - }); - - it("breaks the line at a NaN sentinel into disconnected segments", () => { - const c = ctx(); - drawTelemetry( - c, - [[point(10), point(20), point(NaN, true), point(30), point(40)]], - { width: 100, height: 100 } - ); - const fake = c as unknown as FakeCtx; - // Two separate strokes around the NaN gap separator. - expect(fake.strokes).toBe(2); - }); - - it("renders synthetic (interpolated) points dashed and dimmed", () => { - const c = ctx(); - drawTelemetry( - c, - [[point(10), point(15, true), point(20)]], - { width: 100, height: 100 } - ); - const fake = c as unknown as FakeCtx; - expect(fake.lineDashCalls).toContainEqual([5, 4]); - expect(fake.alphaValues).toContain(0.5); - }); - - it("does not crash on an empty dataset", () => { - const c = ctx(); - expect(() => - drawTelemetry(c, [[]], { width: 100, height: 100 }) - ).not.toThrow(); - const fake = c as unknown as FakeCtx; - expect(fake.strokes).toBe(0); - expect(fake.clearRectCount).toBe(1); - }); -}); diff --git a/tests/components/VirtualList.test.tsx b/tests/components/VirtualList.test.tsx deleted file mode 100644 index 4c57756..0000000 --- a/tests/components/VirtualList.test.tsx +++ /dev/null @@ -1,247 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen } from "@testing-library/react"; -import { VirtualList } from "@/components/ui/VirtualList"; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function makeItems(count: number) { - return Array.from({ length: count }, (_, i) => ({ - id: i, - label: `Item ${i}`, - })); -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe("VirtualList component", () => { - beforeEach(() => { - vi.stubGlobal( - "requestAnimationFrame", - (cb: FrameRequestCallback) => { - cb(performance.now()); - return 1; - } - ); - vi.stubGlobal("cancelAnimationFrame", vi.fn()); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - // ----------------------------------------------------------------------- - // Empty state - // ----------------------------------------------------------------------- - - it("renders default empty state when items is empty", () => { - render( -

row
} - ariaLabel="test-list" - /> - ); - - expect(screen.getByText("No items to display")).toBeDefined(); - }); - - it("renders custom empty state when provided", () => { - render( -
row
} - emptyState={

Nothing here

} - ariaLabel="test-list" - /> - ); - - expect(screen.getByText("Nothing here")).toBeDefined(); - }); - - // ----------------------------------------------------------------------- - // Rendering rows - // ----------------------------------------------------------------------- - - it("renders visible items with role='listitem'", () => { - const items = makeItems(5); - render( - ( -
{item.label}
- )} - ariaLabel="meter-readings" - /> - ); - - // Should find at least some items rendered - const listItems = screen.getAllByRole("listitem"); - expect(listItems.length).toBeGreaterThan(0); - }); - - it("applies aria-label to the container", () => { - render( - ( -
{item.label}
- )} - ariaLabel="reading-list" - /> - ); - - const list = screen.getByRole("list"); - expect(list.getAttribute("aria-label")).toBe("reading-list"); - }); - - it("applies aria-rowcount to the container", () => { - const items = makeItems(100); - render( - ( -
{item.label}
- )} - ariaLabel="big-list" - /> - ); - - const list = screen.getByRole("list"); - expect(list.getAttribute("aria-rowcount")).toBe("100"); - }); - - // ----------------------------------------------------------------------- - // Loading indicator - // ----------------------------------------------------------------------- - - it("shows default loading indicator when isLoading", () => { - render( - ( -
{item.label}
- )} - ariaLabel="loading-list" - /> - ); - - expect(screen.getByText("Loading more items…")).toBeDefined(); - }); - - it("shows custom loading indicator when provided", () => { - render( - Fetching…} - renderItem={(item, _vi, measureRef) => ( -
{item.label}
- )} - ariaLabel="loading-list" - /> - ); - - expect(screen.getByText("Fetching…")).toBeDefined(); - }); - - it("hides loading indicator when not loading", () => { - render( - ( -
{item.label}
- )} - ariaLabel="idle-list" - /> - ); - - expect(screen.queryByText("Loading more items…")).toBeNull(); - }); - - // ----------------------------------------------------------------------- - // Custom className - // ----------------------------------------------------------------------- - - it("forwards className to the container", () => { - render( - ( -
{item.label}
- )} - ariaLabel="classed-list" - /> - ); - - const list = screen.getByRole("list"); - expect(list.className).toContain("my-custom-list"); - }); - - // ----------------------------------------------------------------------- - // getItemKey - // ----------------------------------------------------------------------- - - it("uses getItemKey for stable React keys", () => { - const items = makeItems(5); - const getKey = vi.fn((item: (typeof items)[0]) => `key-${item.id}`); - - render( - ( -
{item.label}
- )} - ariaLabel="keyed-list" - /> - ); - - // getItemKey should have been called for each rendered item - expect(getKey).toHaveBeenCalled(); - }); - - // ----------------------------------------------------------------------- - // Height prop - // ----------------------------------------------------------------------- - - it("accepts a number height and converts to px", () => { - render( - ( -
{item.label}
- )} - ariaLabel="height-list" - /> - ); - - const list = screen.getByRole("list"); - expect(list.style.height).toBe("600px"); - }); - - it("accepts a string height (e.g., '100vh')", () => { - render( - ( -
{item.label}
- )} - ariaLabel="vh-list" - /> - ); - - const list = screen.getByRole("list"); - expect(list.style.height).toBe("80vh"); - }); -}); diff --git a/tests/components/boundaries.test.tsx b/tests/components/boundaries.test.tsx deleted file mode 100644 index 7d037f6..0000000 --- a/tests/components/boundaries.test.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import { describe, it, expect, beforeEach } from "vitest"; -import { render, screen, waitFor, fireEvent, act } from "@testing-library/react"; -import { - DomainBoundary, - DashboardBoundary, - CascadeProvider, - CascadeGate, - useCascade, -} from "@/components/boundaries"; -import { createResource, _clearInflight } from "@/utils/suspenseResource"; -import { useSuspenseResource } from "@/hooks/useSuspenseResource"; -import { cacheStore } from "@/store/slices/cacheSlice"; -import type { SuspenseResource } from "@/types/suspense"; - -function deferred() { - let resolve!: (v: T) => void; - let reject!: (e: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} - -function Value({ resource }: { resource: SuspenseResource<{ label: string }> }) { - const data = useSuspenseResource(resource); - return
{data.label}
; -} - -let counter = 0; -const key = (domain: string) => `${domain}:rtl${counter++}`; - -beforeEach(() => { - cacheStore.dispatch({ type: "RESET" }); - _clearInflight(); -}); - -describe("DomainBoundary", () => { - it("shows the fallback while suspended, then the content once resolved", async () => { - const d = deferred<{ label: string }>(); - const resource = createResource(() => d.promise, { - cacheKey: key("metadata"), - ttlMs: 5000, - }); - - render( - skeleton
} errorTitle="err"> - - - ); - - expect(screen.getByText("skeleton")).toBeInTheDocument(); - - await act(async () => { - d.resolve({ label: "loaded!" }); - await d.promise; - }); - - await waitFor(() => expect(screen.getByText("loaded!")).toBeInTheDocument()); - }); - - it("renders an ErrorPanel on failure and recovers on Retry", async () => { - let attempt = 0; - const resource = createResource( - () => { - attempt += 1; - return attempt === 1 - ? Promise.reject(new Error("fail-once")) - : Promise.resolve({ label: "recovered" }); - }, - { cacheKey: key("metadata"), ttlMs: 5000 } - ); - - render( - skeleton
} errorTitle="Metadata unavailable"> - - - ); - - await waitFor(() => expect(screen.getByText("Metadata unavailable")).toBeInTheDocument()); - expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument(); - - fireEvent.click(screen.getByRole("button", { name: "Retry" })); - - await waitFor(() => expect(screen.getByText("recovered")).toBeInTheDocument()); - expect(attempt).toBe(2); - }); -}); - -describe("DashboardBoundary fallback cascade", () => { - it("surfaces a single dashboard error and the telemetry resource never fetches", async () => { - const blockchain = createResource(() => Promise.reject(new Error("chain down")), { - cacheKey: key("blockchain"), - ttlMs: 5000, - }); - const telemetry = createResource<{ label: string }>( - () => Promise.resolve({ label: "tele" }), - { cacheKey: key("telemetry"), ttlMs: 5000 } - ); - - render( - - - - - ); - - await waitFor(() => - expect(screen.getByText("Dashboard data unavailable")).toBeInTheDocument() - ); - - // The blockchain error cascaded to the single dashboard boundary: one error - // panel is shown and the telemetry subtree is not rendered (no own loader). - expect(screen.queryByText("tele")).not.toBeInTheDocument(); - expect(screen.queryByText("Telemetry stream unavailable")).not.toBeInTheDocument(); - }); -}); - -describe("CascadeGate", () => { - it("renders children until blocked, then the blocked view (skips the subtree)", () => { - function Harness() { - const { block } = useCascade(); - return ( - <> - - blocked-view}> -
active-view
-
- - ); - } - - render( - - - - ); - - expect(screen.getByText("active-view")).toBeInTheDocument(); - expect(screen.queryByText("blocked-view")).not.toBeInTheDocument(); - - fireEvent.click(screen.getByRole("button", { name: "block" })); - - expect(screen.getByText("blocked-view")).toBeInTheDocument(); - expect(screen.queryByText("active-view")).not.toBeInTheDocument(); - }); -}); diff --git a/tests/hooks/useContainerSize.test.ts b/tests/hooks/useContainerSize.test.ts deleted file mode 100644 index d640e39..0000000 --- a/tests/hooks/useContainerSize.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { renderHook } from "@testing-library/react"; -import { useContainerSize } from "@/hooks/useContainerSize"; - -/** - * Constructor-compatible ResizeObserver mock. - * Stores the callback and lets tests fire entries on demand. - */ -let observerCallback: ResizeObserverCallback | null = null; - -class MockResizeObserver { - constructor(callback: ResizeObserverCallback) { - observerCallback = callback; - } - observe = vi.fn(); - unobserve = vi.fn(); - disconnect = vi.fn(); -} - -// ---------------------------------------------------------------- - -describe("useContainerSize", () => { - let container: HTMLDivElement; - - beforeEach(() => { - vi.stubGlobal("ResizeObserver", MockResizeObserver); - observerCallback = null; - - container = document.createElement("div"); - container.style.width = "600px"; - container.style.height = "400px"; - document.body.appendChild(container); - - // Mock getBoundingClientRect for the initial size capture - vi.spyOn(container, "getBoundingClientRect").mockReturnValue({ - width: 600, - height: 400, - x: 0, - y: 0, - top: 0, - right: 600, - bottom: 400, - left: 0, - toJSON: () => ({}), - }); - }); - - afterEach(() => { - document.body.innerHTML = ""; - vi.restoreAllMocks(); - }); - - // ------------------------------------------------------------------ - // Initial state - // ------------------------------------------------------------------ - - it("returns initial dimensions from getBoundingClientRect", () => { - const ref = { current: container }; - const { result } = renderHook(() => useContainerSize(ref)); - - // Initial render happens before ResizeObserver fires, so - // we get the value from getBoundingClientRect - expect(result.current.width).toBe(600); - expect(result.current.height).toBe(400); - expect(result.current.containerState).toBe("medium"); // 400 <= 600 < 800 - }); - - it("classifies as compact when width < compactMax", () => { - vi.spyOn(container, "getBoundingClientRect").mockReturnValue({ - width: 200, - height: 400, - x: 0, - y: 0, - top: 0, - right: 200, - bottom: 400, - left: 0, - toJSON: () => ({}), - }); - - const ref = { current: container }; - const { result } = renderHook(() => useContainerSize(ref)); - - expect(result.current.containerState).toBe("compact"); - }); - - it("classifies as expanded when width >= expandedMin", () => { - vi.spyOn(container, "getBoundingClientRect").mockReturnValue({ - width: 1000, - height: 600, - x: 0, - y: 0, - top: 0, - right: 1000, - bottom: 600, - left: 0, - toJSON: () => ({}), - }); - - const ref = { current: container }; - const { result } = renderHook(() => useContainerSize(ref)); - - expect(result.current.containerState).toBe("expanded"); - }); - - // ------------------------------------------------------------------ - // ResizeObserver callback - // ------------------------------------------------------------------ - - it("updates size on ResizeObserver callback (debounced)", async () => { - vi.useFakeTimers({ shouldAdvanceTime: true }); - const ref = { current: container }; - const { result, rerender } = renderHook(() => useContainerSize(ref)); - - // Simulate a resize event - expect(observerCallback).not.toBeNull(); - const entry = { - contentRect: { width: 350, height: 300 }, - } as ResizeObserverEntry; - - observerCallback!([entry], {} as ResizeObserver); - - // Advance past the debounce window + React re-render - vi.advanceTimersByTime(200); - rerender(); - - expect(result.current.width).toBe(350); - expect(result.current.height).toBe(300); - expect(result.current.containerState).toBe("compact"); // 350 < 400 - vi.useRealTimers(); - }); - - // ------------------------------------------------------------------ - // Custom thresholds - // ------------------------------------------------------------------ - - it("respects custom compactMax and expandedMin", () => { - vi.spyOn(container, "getBoundingClientRect").mockReturnValue({ - width: 500, - height: 400, - x: 0, - y: 0, - top: 0, - right: 500, - bottom: 400, - left: 0, - toJSON: () => ({}), - }); - - const ref = { current: container }; - const { result } = renderHook(() => - useContainerSize(ref, { compactMax: 200, expandedMin: 600 }) - ); - - // 500 is >= 200 and < 600 → medium - expect(result.current.containerState).toBe("medium"); - }); - - // ------------------------------------------------------------------ - // Selector-based ref - // ------------------------------------------------------------------ - - it("works with a CSS selector string", () => { - container.id = "test-container"; - vi.spyOn(container, "getBoundingClientRect").mockReturnValue({ - width: 900, - height: 500, - x: 0, - y: 0, - top: 0, - right: 900, - bottom: 500, - left: 0, - toJSON: () => ({}), - }); - - const { result } = renderHook(() => useContainerSize("#test-container")); - - expect(result.current.width).toBe(900); - expect(result.current.containerState).toBe("expanded"); - }); -}); diff --git a/tests/hooks/useEscrow.test.ts b/tests/hooks/useEscrow.test.ts deleted file mode 100644 index b46cddc..0000000 --- a/tests/hooks/useEscrow.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { renderHook, act } from "@testing-library/react"; -import { useEscrow } from "../../src/hooks/useEscrow"; - -describe("useEscrow", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("should initialize with empty mutations and pendingCount of 0", () => { - const onChainSubmit = vi.fn().mockResolvedValue(undefined); - const onRollback = vi.fn().mockResolvedValue(undefined); - const { result } = renderHook(() => useEscrow(onChainSubmit, onRollback)); - - expect(result.current.mutations).toEqual([]); - expect(result.current.pendingCount).toBe(0); - }); - - it("should apply optimistic update immediately, then confirm on success", async () => { - let resolveSubmit!: () => void; - const submitPromise = new Promise((resolve) => { - resolveSubmit = resolve; - }); - const onChainSubmit = vi.fn().mockReturnValue(submitPromise); - const onRollback = vi.fn().mockResolvedValue(undefined); - - const { result } = renderHook(() => useEscrow(onChainSubmit, onRollback)); - - let promise: Promise; - act(() => { - promise = result.current.submit("data-1"); - }); - - // Check immediate optimistic state - expect(result.current.mutations).toHaveLength(1); - expect(result.current.mutations[0]).toMatchObject({ - data: "data-1", - status: "pending", - error: null, - confirmedAt: null, - }); - expect(result.current.mutations[0].submittedAt).toBeGreaterThan(0); - expect(result.current.pendingCount).toBe(1); - - // Resolve the on-chain submission - await act(async () => { - resolveSubmit(); - await promise; - }); - - // Check confirmed state - expect(result.current.mutations[0].status).toBe("confirmed"); - expect(result.current.mutations[0].confirmedAt).toBeGreaterThan(0); - expect(result.current.pendingCount).toBe(0); - expect(onRollback).not.toHaveBeenCalled(); - }); - - it("should rollback automatically on failure", async () => { - const error = new Error("On-chain error"); - const onChainSubmit = vi.fn().mockRejectedValue(error); - const onRollback = vi.fn().mockResolvedValue(undefined); - - const { result } = renderHook(() => useEscrow(onChainSubmit, onRollback)); - - await act(async () => { - await result.current.submit("data-1"); - }); - - // Check failed and rolled back state - expect(result.current.mutations[0].status).toBe("failed"); - expect(result.current.mutations[0].error).toBe("On-chain error"); - expect(result.current.pendingCount).toBe(0); - expect(onRollback).toHaveBeenCalledWith("data-1"); - }); - - it("should allow manual rollback of pending mutation", async () => { - let resolveSubmit!: () => void; - const submitPromise = new Promise((resolve) => { - resolveSubmit = resolve; - }); - const onChainSubmit = vi.fn().mockReturnValue(submitPromise); - const onRollback = vi.fn().mockResolvedValue(undefined); - - const { result } = renderHook(() => useEscrow(onChainSubmit, onRollback)); - - act(() => { - result.current.submit("data-1"); - }); - - const mutationId = result.current.mutations[0].id; - expect(result.current.pendingCount).toBe(1); - - await act(async () => { - result.current.rollback(mutationId); - }); - - // Check that manually rolled back status is idle - expect(result.current.mutations[0].status).toBe("idle"); - expect(result.current.pendingCount).toBe(0); - expect(onRollback).toHaveBeenCalledWith("data-1"); - - // Even if onChainSubmit resolves afterwards, it should not confirm or execute again - await act(async () => { - resolveSubmit(); - await submitPromise; - }); - - expect(result.current.mutations[0].status).toBe("idle"); - }); - - it("should allow retrying a failed mutation", async () => { - const error = new Error("Temp error"); - let shouldFail = true; - const onChainSubmit = vi.fn().mockImplementation(() => { - if (shouldFail) { - return Promise.reject(error); - } - return Promise.resolve(); - }); - const onRollback = vi.fn().mockResolvedValue(undefined); - - const { result } = renderHook(() => useEscrow(onChainSubmit, onRollback)); - - // First attempt fails - await act(async () => { - await result.current.submit("data-retry"); - }); - - expect(result.current.mutations[0].status).toBe("failed"); - expect(onRollback).toHaveBeenCalledTimes(1); - - // Second attempt succeeds - shouldFail = false; - await act(async () => { - await result.current.retry(result.current.mutations[0].id); - }); - - expect(result.current.mutations[0].status).toBe("confirmed"); - expect(result.current.mutations[0].confirmedAt).toBeGreaterThan(0); - expect(onRollback).toHaveBeenCalledTimes(1); // No new rollback - }); - - it("should store mutation history in state with max 50 entries", async () => { - const onChainSubmit = vi.fn().mockResolvedValue(undefined); - const onRollback = vi.fn().mockResolvedValue(undefined); - const { result } = renderHook(() => useEscrow(onChainSubmit, onRollback)); - - await act(async () => { - for (let i = 0; i < 55; i++) { - await result.current.submit(`data-${i}`); - } - }); - - expect(result.current.mutations).toHaveLength(50); - // The most recent should be at index 0 (data-54) - expect(result.current.mutations[0].data).toBe("data-54"); - // The oldest kept should be data-5 (since 54 down to 5 is 50 items) - expect(result.current.mutations[49].data).toBe("data-5"); - }); -}); diff --git a/tests/hooks/useMapCameraSync.test.ts b/tests/hooks/useMapCameraSync.test.ts deleted file mode 100644 index 0e6a6ed..0000000 --- a/tests/hooks/useMapCameraSync.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { renderHook } from "@testing-library/react"; -import { - useMapCameraSync, - readCameraState, - viewProjectionForTransform, - type MapTransformLike, - type MapLike, -} from "@/hooks/useMapCameraSync"; - -function transform(over: Partial = {}): MapTransformLike { - return { - _center: { lng: 0, lat: 0 }, - _elevation: 100, - bearing: 0, - pitch: 0, - width: 800, - height: 600, - ...over, - }; -} - -describe("readCameraState", () => { - it("maps the transform to a world-space camera state", () => { - const state = readCameraState(transform(), { lng: 0, lat: 0 }); - expect(state.position).toEqual({ x: 0, y: 0, z: 100 }); - expect(state.altitude).toBe(100); - expect(state.heading).toBe(0); - }); - - it("offsets position relative to the origin", () => { - const state = readCameraState( - transform({ _center: { lng: 0.01, lat: 0 } }), - { lng: 0, lat: 0 } - ); - expect(state.position.x).toBeGreaterThan(0); - }); - - it("falls back to non-underscore fields", () => { - const t: MapTransformLike = { - center: { lng: 1, lat: 1 }, - elevation: 50, - bearing: 30, - pitch: 45, - width: 100, - height: 100, - }; - const state = readCameraState(t, { lng: 1, lat: 1 }); - expect(state.altitude).toBe(50); - expect(state.heading).toBe(30); - expect(state.pitch).toBe(45); - }); -}); - -describe("viewProjectionForTransform", () => { - it("returns a camera state and a 16-element matrix", () => { - const { camera, viewProjection } = viewProjectionForTransform(transform(), { - lng: 0, - lat: 0, - }); - expect(camera.position.z).toBe(100); - expect(viewProjection).toHaveLength(16); - }); -}); - -describe("useMapCameraSync", () => { - it("invokes onSync each scheduled frame", () => { - const onSync = vi.fn(); - let frameCb: FrameRequestCallback | null = null; - const raf = vi.fn((cb: FrameRequestCallback) => { - frameCb = cb; - return 1; - }); - const cancelRaf = vi.fn(); - const map: MapLike = { transform: transform() }; - - renderHook(() => - useMapCameraSync({ map, onSync, raf, cancelRaf }) - ); - - expect(raf).toHaveBeenCalledTimes(1); - // Drive one frame. - frameCb!(0); - expect(onSync).toHaveBeenCalledTimes(1); - const [camera, vp] = onSync.mock.calls[0]; - expect(camera.altitude).toBe(100); - expect(vp).toHaveLength(16); - }); - - it("cancels the loop on unmount", () => { - const cancelRaf = vi.fn(); - const raf = vi.fn(() => 7); - const map: MapLike = { transform: transform() }; - const { unmount } = renderHook(() => - useMapCameraSync({ map, onSync: vi.fn(), raf, cancelRaf }) - ); - unmount(); - expect(cancelRaf).toHaveBeenCalledWith(7); - }); - - it("does nothing without a map", () => { - const raf = vi.fn(); - renderHook(() => - useMapCameraSync({ map: null, onSync: vi.fn(), raf }) - ); - expect(raf).not.toHaveBeenCalled(); - }); -}); diff --git a/tests/hooks/useRetryQueue.test.ts b/tests/hooks/useRetryQueue.test.ts deleted file mode 100644 index a1604ca..0000000 --- a/tests/hooks/useRetryQueue.test.ts +++ /dev/null @@ -1,640 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { renderHook, act } from '@testing-library/react'; -import { useRetryQueue } from '../../src/hooks/useRetryQueue'; - -describe('useRetryQueue', () => { - beforeEach(() => { - // Clear localStorage before each test - localStorage.clear(); - // Clear all timers - vi.clearAllTimers(); - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - vi.useRealTimers(); - }); - - describe('enqueue', () => { - it('should add a new transaction with default maxRetries of 5', () => { - const { result } = renderHook(() => useRetryQueue()); - - act(() => { - result.current.enqueue('tx-1'); - }); - - expect(result.current.queue).toHaveLength(1); - expect(result.current.queue[0]).toMatchObject({ - id: 'tx-1', - txHash: null, - status: 'pending', - retryCount: 0, - maxRetries: 5, - error: null, - }); - expect(result.current.queue[0].createdAt).toBeGreaterThan(0); - }); - - it('should add a new transaction with custom maxRetries', () => { - const { result } = renderHook(() => useRetryQueue()); - - act(() => { - result.current.enqueue('tx-1', 3); - }); - - expect(result.current.queue[0].maxRetries).toBe(3); - }); - - it('should not add duplicate transactions', () => { - const { result } = renderHook(() => useRetryQueue()); - - act(() => { - result.current.enqueue('tx-1'); - result.current.enqueue('tx-1'); - }); - - expect(result.current.queue).toHaveLength(1); - }); - - it('should add transactions at the beginning of the queue', () => { - const { result } = renderHook(() => useRetryQueue()); - - act(() => { - result.current.enqueue('tx-1'); - result.current.enqueue('tx-2'); - }); - - expect(result.current.queue[0].id).toBe('tx-2'); - expect(result.current.queue[1].id).toBe('tx-1'); - }); - }); - - describe('updateTxHash', () => { - it('should update txHash and set status to submitted', () => { - const { result } = renderHook(() => useRetryQueue()); - - act(() => { - result.current.enqueue('tx-1'); - result.current.updateTxHash('tx-1', 'hash-123'); - }); - - expect(result.current.queue[0]).toMatchObject({ - id: 'tx-1', - txHash: 'hash-123', - status: 'submitted', - }); - }); - - it('should not affect other transactions', () => { - const { result } = renderHook(() => useRetryQueue()); - - act(() => { - result.current.enqueue('tx-1'); - result.current.enqueue('tx-2'); - result.current.updateTxHash('tx-2', 'hash-456'); - }); - - expect(result.current.queue[0].txHash).toBe('hash-456'); - expect(result.current.queue[1].txHash).toBeNull(); - }); - }); - - describe('markConfirmed', () => { - it('should mark transaction as confirmed', () => { - const { result } = renderHook(() => useRetryQueue()); - - act(() => { - result.current.enqueue('tx-1'); - result.current.markConfirmed('tx-1'); - }); - - expect(result.current.queue[0].status).toBe('confirmed'); - }); - - it('should cleanup any pending retry timer', () => { - const { result } = renderHook(() => useRetryQueue()); - - act(() => { - result.current.enqueue('tx-1'); - result.current.markFailed('tx-1', 'Network error'); - }); - - // Should have scheduled a retry - expect(vi.getTimerCount()).toBeGreaterThan(0); - - act(() => { - result.current.markConfirmed('tx-1'); - }); - - // Timer should be cleaned up - expect(result.current.queue[0].status).toBe('confirmed'); - }); - }); - - describe('markFailed', () => { - it('should mark transaction as failed with error message', () => { - const { result } = renderHook(() => useRetryQueue()); - - act(() => { - result.current.enqueue('tx-1'); - result.current.markFailed('tx-1', 'Network timeout'); - }); - - expect(result.current.queue[0]).toMatchObject({ - status: 'failed', - error: 'Network timeout', - }); - }); - - it('should schedule auto-retry with 1s delay on first failure', async () => { - const { result } = renderHook(() => useRetryQueue()); - - act(() => { - result.current.enqueue('tx-1'); - result.current.markFailed('tx-1', 'Error 1'); - }); - - expect(result.current.queue[0].status).toBe('failed'); - expect(result.current.queue[0].retryCount).toBe(0); - - // Advance time by 1 second - await act(async () => { - vi.advanceTimersByTime(1000); - }); - - expect(result.current.queue[0].status).toBe('pending'); - expect(result.current.queue[0].retryCount).toBe(1); - expect(result.current.queue[0].error).toBeNull(); - }); - - it('should schedule auto-retry with 5s delay on second failure', async () => { - const { result } = renderHook(() => useRetryQueue()); - - act(() => { - result.current.enqueue('tx-1'); - result.current.markFailed('tx-1', 'Error 1'); - }); - - // First retry after 1s - await act(async () => { - vi.advanceTimersByTime(1000); - }); - - expect(result.current.queue[0].retryCount).toBe(1); - - act(() => { - result.current.markFailed('tx-1', 'Error 2'); - }); - - // Should not retry after 1s - await act(async () => { - vi.advanceTimersByTime(1000); - }); - - expect(result.current.queue[0].status).toBe('failed'); - - // Should retry after 5s total - await act(async () => { - vi.advanceTimersByTime(4000); - }); - - expect(result.current.queue[0].status).toBe('pending'); - expect(result.current.queue[0].retryCount).toBe(2); - }); - - it('should use exponential backoff delays [1s, 5s, 15s, 30s, 60s]', async () => { - const { result } = renderHook(() => useRetryQueue()); - const delays = [1000, 5000, 15000, 30000, 60000]; - - act(() => { - result.current.enqueue('tx-1'); - }); - - for (let i = 0; i < delays.length; i++) { - act(() => { - result.current.markFailed('tx-1', `Error ${i + 1}`); - }); - - expect(result.current.queue[0].status).toBe('failed'); - - // Advance by the expected delay - await act(async () => { - vi.advanceTimersByTime(delays[i]); - }); - - expect(result.current.queue[0].status).toBe('pending'); - expect(result.current.queue[0].retryCount).toBe(i + 1); - } - }); - - it('should cap delay at 60s for retries beyond 5th', async () => { - const { result } = renderHook(() => useRetryQueue()); - - act(() => { - result.current.enqueue('tx-1', 7); // Allow 7 retries - }); - - // Get to 5th retry - for (let i = 0; i < 5; i++) { - act(() => { - result.current.markFailed('tx-1', 'Error'); - }); - - await act(async () => { - vi.advanceTimersByTime([1000, 5000, 15000, 30000, 60000][i]); - }); - } - - expect(result.current.queue[0].retryCount).toBe(5); - - // 6th failure should still use 60s delay - act(() => { - result.current.markFailed('tx-1', 'Error 6'); - }); - - await act(async () => { - vi.advanceTimersByTime(60000); - }); - - expect(result.current.queue[0].retryCount).toBe(6); - }); - - it('should not schedule retry when maxRetries is reached', async () => { - const { result } = renderHook(() => useRetryQueue()); - - act(() => { - result.current.enqueue('tx-1', 2); // Max 2 retries - }); - - // First failure - act(() => { - result.current.markFailed('tx-1', 'Error 1'); - }); - - await act(async () => { - vi.advanceTimersByTime(1000); - }); - - expect(result.current.queue[0].retryCount).toBe(1); - - // Second failure - act(() => { - result.current.markFailed('tx-1', 'Error 2'); - }); - - await act(async () => { - vi.advanceTimersByTime(5000); - }); - - expect(result.current.queue[0].retryCount).toBe(2); - - // Third failure - should stay failed - act(() => { - result.current.markFailed('tx-1', 'Final error'); - }); - - await act(async () => { - vi.advanceTimersByTime(60000); - }); - - // Should remain failed - expect(result.current.queue[0].status).toBe('failed'); - expect(result.current.queue[0].error).toBe('Final error'); - expect(result.current.queue[0].retryCount).toBe(2); - }); - - it('should cleanup old timer before scheduling new one', async () => { - const { result } = renderHook(() => useRetryQueue()); - - act(() => { - result.current.enqueue('tx-1'); - result.current.markFailed('tx-1', 'Error 1'); - }); - - const timerCount1 = vi.getTimerCount(); - - act(() => { - result.current.markFailed('tx-1', 'Error 2'); - }); - - // Should not have more timers than before - expect(vi.getTimerCount()).toBeLessThanOrEqual(timerCount1); - }); - }); - - describe('retry', () => { - it('should manually reset transaction to pending and increment retryCount', async () => { - const { result } = renderHook(() => useRetryQueue()); - - act(() => { - result.current.enqueue('tx-1'); - result.current.markFailed('tx-1', 'Error'); - }); - - expect(result.current.queue[0].status).toBe('failed'); - - await act(async () => { - await result.current.retry('tx-1'); - }); - - expect(result.current.queue[0]).toMatchObject({ - status: 'pending', - retryCount: 1, - error: null, - }); - }); - - it('should cleanup any pending auto-retry timer', async () => { - const { result } = renderHook(() => useRetryQueue()); - - act(() => { - result.current.enqueue('tx-1'); - result.current.markFailed('tx-1', 'Error'); - }); - - // Auto-retry should be scheduled - expect(vi.getTimerCount()).toBeGreaterThan(0); - - await act(async () => { - await result.current.retry('tx-1'); - }); - - // Original timer should be cleaned up - expect(result.current.queue[0].status).toBe('pending'); - }); - - it('should work on transactions with any status', async () => { - const { result } = renderHook(() => useRetryQueue()); - - act(() => { - result.current.enqueue('tx-1'); - result.current.updateTxHash('tx-1', 'hash-123'); - }); - - expect(result.current.queue[0].status).toBe('submitted'); - - await act(async () => { - await result.current.retry('tx-1'); - }); - - expect(result.current.queue[0].status).toBe('pending'); - expect(result.current.queue[0].retryCount).toBe(1); - }); - }); - - describe('purge', () => { - it('should clear all transactions from queue', () => { - const { result } = renderHook(() => useRetryQueue()); - - act(() => { - result.current.enqueue('tx-1'); - result.current.enqueue('tx-2'); - result.current.enqueue('tx-3'); - }); - - expect(result.current.queue).toHaveLength(3); - - act(() => { - result.current.purge(); - }); - - expect(result.current.queue).toHaveLength(0); - }); - - it('should clear all pending timers', () => { - const { result } = renderHook(() => useRetryQueue()); - - act(() => { - result.current.enqueue('tx-1'); - result.current.enqueue('tx-2'); - result.current.markFailed('tx-1', 'Error 1'); - result.current.markFailed('tx-2', 'Error 2'); - }); - - expect(vi.getTimerCount()).toBeGreaterThan(0); - - act(() => { - result.current.purge(); - }); - - expect(vi.getTimerCount()).toBe(0); - }); - }); - - describe('pendingCount', () => { - it('should count transactions with pending status', () => { - const { result } = renderHook(() => useRetryQueue()); - - act(() => { - result.current.enqueue('tx-1'); - result.current.enqueue('tx-2'); - }); - - expect(result.current.pendingCount).toBe(2); - }); - - it('should count transactions with submitted status', () => { - const { result } = renderHook(() => useRetryQueue()); - - act(() => { - result.current.enqueue('tx-1'); - result.current.updateTxHash('tx-1', 'hash-123'); - }); - - expect(result.current.pendingCount).toBe(1); - }); - - it('should not count confirmed or failed transactions', () => { - const { result } = renderHook(() => useRetryQueue()); - - act(() => { - result.current.enqueue('tx-1'); - result.current.enqueue('tx-2'); - result.current.enqueue('tx-3'); - result.current.markConfirmed('tx-1'); - result.current.markFailed('tx-2', 'Error'); - }); - - expect(result.current.pendingCount).toBe(1); - }); - - it('should update when transactions change status', async () => { - const { result } = renderHook(() => useRetryQueue()); - - act(() => { - result.current.enqueue('tx-1'); - result.current.enqueue('tx-2'); - }); - - expect(result.current.pendingCount).toBe(2); - - act(() => { - result.current.markFailed('tx-1', 'Error'); - }); - - expect(result.current.pendingCount).toBe(1); - - // Wait for auto-retry - await act(async () => { - vi.advanceTimersByTime(1000); - }); - - expect(result.current.pendingCount).toBe(2); - }); - }); - - describe('localStorage persistence', () => { - it('should persist queue to localStorage on changes', () => { - const { result } = renderHook(() => useRetryQueue()); - - act(() => { - result.current.enqueue('tx-1'); - }); - - const stored = localStorage.getItem('retry-queue-state'); - expect(stored).toBeTruthy(); - - const parsed = JSON.parse(stored!); - expect(parsed).toHaveLength(1); - expect(parsed[0].id).toBe('tx-1'); - }); - - it('should restore queue from localStorage on mount', () => { - // Set up localStorage with existing queue - const existingQueue = [ - { - id: 'tx-existing', - txHash: 'hash-old', - status: 'submitted', - retryCount: 2, - maxRetries: 5, - error: null, - createdAt: Date.now() - 5000, - }, - ]; - localStorage.setItem('retry-queue-state', JSON.stringify(existingQueue)); - - const { result } = renderHook(() => useRetryQueue()); - - expect(result.current.queue).toHaveLength(1); - expect(result.current.queue[0]).toMatchObject({ - id: 'tx-existing', - txHash: 'hash-old', - status: 'submitted', - retryCount: 2, - }); - }); - - it('should handle corrupt localStorage data gracefully', () => { - localStorage.setItem('retry-queue-state', 'invalid json{'); - - const { result } = renderHook(() => useRetryQueue()); - - // Should start with empty queue instead of crashing - expect(result.current.queue).toHaveLength(0); - }); - - it('should handle missing localStorage gracefully', () => { - // Don't set anything in localStorage - const { result } = renderHook(() => useRetryQueue()); - - expect(result.current.queue).toHaveLength(0); - }); - - it('should update localStorage when purging', () => { - const { result } = renderHook(() => useRetryQueue()); - - act(() => { - result.current.enqueue('tx-1'); - result.current.enqueue('tx-2'); - }); - - expect(localStorage.getItem('retry-queue-state')).toBeTruthy(); - - act(() => { - result.current.purge(); - }); - - const stored = localStorage.getItem('retry-queue-state'); - expect(JSON.parse(stored!)).toHaveLength(0); - }); - }); - - describe('cleanup on unmount', () => { - it('should clear all timers on unmount', () => { - const { result, unmount } = renderHook(() => useRetryQueue()); - - act(() => { - result.current.enqueue('tx-1'); - result.current.enqueue('tx-2'); - result.current.markFailed('tx-1', 'Error 1'); - result.current.markFailed('tx-2', 'Error 2'); - }); - - expect(vi.getTimerCount()).toBeGreaterThan(0); - - unmount(); - - expect(vi.getTimerCount()).toBe(0); - }); - }); - - describe('state transitions', () => { - it('should follow correct state flow: pending → submitted → confirmed', () => { - const { result } = renderHook(() => useRetryQueue()); - - act(() => { - result.current.enqueue('tx-1'); - }); - expect(result.current.queue[0].status).toBe('pending'); - - act(() => { - result.current.updateTxHash('tx-1', 'hash-123'); - }); - expect(result.current.queue[0].status).toBe('submitted'); - - act(() => { - result.current.markConfirmed('tx-1'); - }); - expect(result.current.queue[0].status).toBe('confirmed'); - }); - - it('should follow retry flow: pending → failed → pending (auto) → failed (final)', async () => { - const { result } = renderHook(() => useRetryQueue()); - - act(() => { - result.current.enqueue('tx-1', 1); // Max 1 retry - }); - expect(result.current.queue[0].status).toBe('pending'); - - act(() => { - result.current.markFailed('tx-1', 'Error 1'); - }); - expect(result.current.queue[0].status).toBe('failed'); - expect(result.current.queue[0].retryCount).toBe(0); - - // Auto-retry after 1s - await act(async () => { - vi.advanceTimersByTime(1000); - }); - - expect(result.current.queue[0].status).toBe('pending'); - expect(result.current.queue[0].retryCount).toBe(1); - - act(() => { - result.current.markFailed('tx-1', 'Final error'); - }); - - expect(result.current.queue[0].status).toBe('failed'); - expect(result.current.queue[0].error).toBe('Final error'); - - // Should not retry again - await act(async () => { - vi.advanceTimersByTime(60000); - }); - - expect(result.current.queue[0].status).toBe('failed'); - }); - }); -}); diff --git a/tests/hooks/useVirtualList.test.ts b/tests/hooks/useVirtualList.test.ts deleted file mode 100644 index 3145b27..0000000 --- a/tests/hooks/useVirtualList.test.ts +++ /dev/null @@ -1,318 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { renderHook, act } from "@testing-library/react"; -import { useVirtualList } from "@/hooks/useVirtualList"; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** Generate N simple items. */ -function makeItems(count: number): Array<{ id: number; label: string }> { - return Array.from({ length: count }, (_, i) => ({ - id: i, - label: `Item ${i}`, - })); -} - -/** Stub the container ref with controllable scrollTop / clientHeight. */ -function mockContainerRef( - hook: ReturnType, unknown>>, - scrollTop = 0, - clientHeight = 400 -) { - const div = document.createElement("div"); - - Object.defineProperties(div, { - scrollTop: { value: scrollTop, writable: true }, - clientHeight: { value: clientHeight, configurable: true }, - scrollTo: { - value: vi.fn(({ top }: ScrollToOptions) => { - Object.defineProperty(div, "scrollTop", { - value: top, - writable: true, - configurable: true, - }); - }), - configurable: true, - }, - }); - - // Assign the mock div to the containerRef - (hook.result.current.containerRef as { current: HTMLDivElement | null }).current = div; - return div; -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe("useVirtualList", () => { - beforeEach(() => { - vi.useFakeTimers({ shouldAdvanceTime: true }); - - // Mock requestAnimationFrame to execute callback immediately - vi.stubGlobal( - "requestAnimationFrame", - (cb: FrameRequestCallback) => { - cb(performance.now()); - return 1; - } - ); - vi.stubGlobal("cancelAnimationFrame", vi.fn()); - }); - - afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); - sessionStorage.clear(); - }); - - // ----------------------------------------------------------------------- - // Basic rendering - // ----------------------------------------------------------------------- - - it("returns empty virtualItems for an empty dataset", () => { - const { result } = renderHook(() => - useVirtualList({ items: [], estimatedItemHeight: 40 }) - ); - expect(result.current.virtualItems).toEqual([]); - expect(result.current.totalHeight).toBe(0); - expect(result.current.visibleItems).toEqual([]); - }); - - it("computes totalHeight from estimatedItemHeight × item count", () => { - const items = makeItems(100); - const { result } = renderHook(() => - useVirtualList({ items, estimatedItemHeight: 50 }) - ); - expect(result.current.totalHeight).toBe(100 * 50); - }); - - it("uses default estimatedItemHeight of 48 when unspecified", () => { - const items = makeItems(10); - const { result } = renderHook(() => useVirtualList({ items })); - expect(result.current.totalHeight).toBe(10 * 48); - }); - - // ----------------------------------------------------------------------- - // Large dataset support - // ----------------------------------------------------------------------- - - it("handles 10,000+ items without error", () => { - const items = makeItems(15_000); - const { result } = renderHook(() => - useVirtualList({ items, estimatedItemHeight: 40 }) - ); - expect(result.current.totalHeight).toBe(15_000 * 40); - // Only a small window should be in virtualItems (not all 15k) - expect(result.current.virtualItems.length).toBeLessThan(100); - }); - - it("handles 50,000 items efficiently", () => { - const items = makeItems(50_000); - const start = performance.now(); - const { result } = renderHook(() => - useVirtualList({ items, estimatedItemHeight: 40 }) - ); - const elapsed = performance.now() - start; - - expect(result.current.totalHeight).toBe(50_000 * 40); - // Should initialise in under 500ms even with 50k items - expect(elapsed).toBeLessThan(500); - }); - - // ----------------------------------------------------------------------- - // Dynamic row height measurement - // ----------------------------------------------------------------------- - - it("updates totalHeight after measuring a row", () => { - const items = makeItems(10); - const { result } = renderHook(() => - useVirtualList({ items, estimatedItemHeight: 40 }) - ); - - expect(result.current.totalHeight).toBe(400); // 10 × 40 - - // Simulate measuring index 0 as 80px tall (double the estimate) - const el = document.createElement("div"); - vi.spyOn(el, "getBoundingClientRect").mockReturnValue({ - height: 80, - width: 200, - x: 0, - y: 0, - top: 0, - right: 200, - bottom: 80, - left: 0, - toJSON: () => ({}), - }); - - act(() => { - result.current.measureElement(0, el); - }); - - // Total should now be 80 + 9×40 = 440 - expect(result.current.totalHeight).toBe(440); - }); - - it("does not re-render when measured height is unchanged", () => { - const items = makeItems(5); - const { result } = renderHook(() => - useVirtualList({ items, estimatedItemHeight: 40 }) - ); - - const el = document.createElement("div"); - vi.spyOn(el, "getBoundingClientRect").mockReturnValue({ - height: 40, - width: 200, - x: 0, - y: 0, - top: 0, - right: 200, - bottom: 40, - left: 0, - toJSON: () => ({}), - }); - - const totalBefore = result.current.totalHeight; - - act(() => { - result.current.measureElement(0, el); - }); - - // Height matches the estimate — totalHeight should be unchanged. - expect(result.current.totalHeight).toBe(totalBefore); - }); - - // ----------------------------------------------------------------------- - // measureElement null safety - // ----------------------------------------------------------------------- - - it("safely ignores null elements passed to measureElement", () => { - const items = makeItems(5); - const { result } = renderHook(() => useVirtualList({ items })); - - // Should not throw - act(() => { - result.current.measureElement(0, null); - }); - - expect(result.current.totalHeight).toBe(5 * 48); - }); - - // ----------------------------------------------------------------------- - // Scroll position restoration - // ----------------------------------------------------------------------- - - it("stores scroll position in sessionStorage", () => { - const items = makeItems(100); - const key = "test-scroll"; - - renderHook(() => - useVirtualList({ - items, - estimatedItemHeight: 40, - scrollRestorationKey: key, - }) - ); - - // The key should have been accessed - const stored = sessionStorage.getItem(`vlist-scroll-${key}`); - // It may be "0" on initial render - expect(stored).not.toBeNull(); - }); - - // ----------------------------------------------------------------------- - // Loading state - // ----------------------------------------------------------------------- - - it("exposes isLoading from options", () => { - const items = makeItems(10); - const { result, rerender } = renderHook( - ({ loading }: { loading: boolean }) => - useVirtualList({ items, isLoading: loading }), - { initialProps: { loading: false } } - ); - - expect(result.current.isLoading).toBe(false); - - rerender({ loading: true }); - expect(result.current.isLoading).toBe(true); - }); - - // ----------------------------------------------------------------------- - // scrollToIndex - // ----------------------------------------------------------------------- - - it("provides a scrollToIndex function", () => { - const items = makeItems(100); - const { result } = renderHook(() => - useVirtualList({ items, estimatedItemHeight: 40 }) - ); - - // scrollToIndex should be a callable function - expect(typeof result.current.scrollToIndex).toBe("function"); - }); - - // ----------------------------------------------------------------------- - // Refs - // ----------------------------------------------------------------------- - - it("provides containerRef and innerRef", () => { - const items = makeItems(10); - const { result } = renderHook(() => useVirtualList({ items })); - - expect(result.current.containerRef).toBeDefined(); - expect(result.current.innerRef).toBeDefined(); - }); - - // ----------------------------------------------------------------------- - // Virtual items structure - // ----------------------------------------------------------------------- - - it("virtual items contain correct index and offsetTop", () => { - const items = makeItems(20); - const { result } = renderHook(() => - useVirtualList({ items, estimatedItemHeight: 50 }) - ); - - for (const vi of result.current.virtualItems) { - expect(vi.index).toBeGreaterThanOrEqual(0); - expect(vi.index).toBeLessThan(20); - expect(vi.offsetTop).toBe(vi.index * 50); - expect(vi.height).toBe(50); - } - }); - - it("visibleItems maps correctly to virtualItems indices", () => { - const items = makeItems(20); - const { result } = renderHook(() => - useVirtualList({ items, estimatedItemHeight: 50 }) - ); - - const { virtualItems, visibleItems } = result.current; - expect(visibleItems.length).toBe(virtualItems.length); - - visibleItems.forEach((item, i) => { - expect(item).toBe(items[virtualItems[i].index]); - }); - }); - - // ----------------------------------------------------------------------- - // Reactivity to items change - // ----------------------------------------------------------------------- - - it("updates when items array grows", () => { - let data = makeItems(10); - const { result, rerender } = renderHook(() => - useVirtualList({ items: data, estimatedItemHeight: 40 }) - ); - - expect(result.current.totalHeight).toBe(400); - - data = makeItems(20); - rerender(); - - expect(result.current.totalHeight).toBe(800); - }); -}); diff --git a/tests/hooks/useWebSocket.test.ts b/tests/hooks/useWebSocket.test.ts deleted file mode 100644 index 791a7d9..0000000 --- a/tests/hooks/useWebSocket.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { renderHook, act } from "@testing-library/react"; -import { useWebSocket } from "@/hooks/useWebSocket"; -import { connectionStore } from "@/store/slices/connectionSlice"; -import type { SocketLike } from "@/hooks/useTelemetryStream"; -import type { TelemetryFrame } from "@/types/connection"; - -class FakeSocket implements SocketLike { - static instances: FakeSocket[] = []; - binaryType = "arraybuffer"; - readyState = 0; - sent: string[] = []; - onopen: ((ev: Event) => void) | null = null; - onmessage: ((ev: MessageEvent) => void) | null = null; - onclose: ((ev: CloseEvent) => void) | null = null; - onerror: ((ev: Event) => void) | null = null; - - constructor(public url: string) { - FakeSocket.instances.push(this); - } - send(data: string | ArrayBuffer): void { - this.sent.push(typeof data === "string" ? data : ""); - } - close(): void { - this.readyState = 3; - this.onclose?.(new CloseEvent("close")); - } - fireOpen(): void { - this.readyState = 1; - this.onopen?.(new Event("open")); - } - fireMessage(payload: unknown): void { - const data = typeof payload === "string" ? payload : JSON.stringify(payload); - this.onmessage?.({ data } as MessageEvent); - } - lastSent(): unknown { - const raw = this.sent[this.sent.length - 1]; - try { - return JSON.parse(raw); - } catch { - return raw; - } - } -} - -function makeStorage() { - const map = new Map(); - return { - getItem: (k: string) => map.get(k) ?? null, - setItem: (k: string, v: string) => void map.set(k, v), - removeItem: (k: string) => void map.delete(k), - _map: map, - }; -} - -const baseOptions = () => ({ - url: "wss://api.test/ws", - createSocket: (url: string) => new FakeSocket(url), - rng: () => 0.5, - now: () => 1000, - storage: makeStorage(), -}); - -beforeEach(() => { - vi.useFakeTimers(); - FakeSocket.instances = []; - connectionStore.dispatch({ type: "RESET" }); -}); - -afterEach(() => { - vi.runOnlyPendingTimers(); - vi.useRealTimers(); -}); - -describe("useWebSocket", () => { - it("connects, opens, and reaches connected", () => { - const { result } = renderHook(() => useWebSocket(baseOptions())); - expect(FakeSocket.instances).toHaveLength(1); - - act(() => FakeSocket.instances[0].fireOpen()); - expect(result.current.status).toBe("connected"); - expect(connectionStore.getState().status).toBe("connected"); - }); - - it("answers server pings with a pong", () => { - renderHook(() => useWebSocket(baseOptions())); - const sock = FakeSocket.instances[0]; - act(() => sock.fireOpen()); - act(() => sock.fireMessage({ type: "ping" })); - expect(sock.lastSent()).toEqual({ type: "pong" }); - }); - - it("persists the assigned sticky node id and uses it on reconnect", () => { - const opts = baseOptions(); - renderHook(() => useWebSocket(opts)); - const sock = FakeSocket.instances[0]; - act(() => sock.fireOpen()); - act(() => sock.fireMessage({ type: "node_assigned", nodeId: "node-42" })); - - expect(opts.storage._map.get("ws:stickyNodeId")).toBe("node-42"); - - // Drop the connection and let the backoff timer fire. - act(() => sock.close()); - act(() => vi.advanceTimersByTime(1000)); - const reconnected = FakeSocket.instances[1]; - expect(reconnected.url).toContain("nodeId=node-42"); - }); - - it("buffers telemetry frames and forwards them to onFrame", () => { - const frames: TelemetryFrame[] = []; - const opts = { ...baseOptions(), onFrame: (f: TelemetryFrame) => frames.push(f) }; - renderHook(() => useWebSocket(opts)); - const sock = FakeSocket.instances[0]; - act(() => sock.fireOpen()); - act(() => sock.fireMessage({ sequenceId: 1, data: { x: 1 }, receivedAt: 1, size: 50 })); - act(() => sock.fireMessage({ sequenceId: 2, data: { x: 2 }, receivedAt: 2, size: 50 })); - expect(frames.map((f) => f.sequenceId)).toEqual([1, 2]); - }); - - it("schedules a jittered reconnect, runs recovery, and backfills missed frames", async () => { - const replayFetch = vi - .fn<(from: number, to: number) => Promise>() - .mockResolvedValue([ - { sequenceId: 6, data: {}, receivedAt: 6, size: 10 }, - ]); - const backfilled: number[] = []; - const opts = { - ...baseOptions(), - replayFetch, - onFrame: (f: TelemetryFrame) => backfilled.push(f.sequenceId), - }; - const { result } = renderHook(() => useWebSocket(opts)); - - const sock = FakeSocket.instances[0]; - act(() => sock.fireOpen()); - act(() => sock.fireMessage({ sequenceId: 5, data: {}, receivedAt: 5, size: 10 })); - - // Server drops us. - act(() => sock.close()); - expect(result.current.status).toBe("reconnecting"); - expect(result.current.attempt).toBe(1); - - // ceiling(0)=500, rng 0.5 → 250 ms backoff. - act(() => vi.advanceTimersByTime(250)); - expect(FakeSocket.instances).toHaveLength(2); - - const sock2 = FakeSocket.instances[1]; - act(() => sock2.fireOpen()); - // Reconnect enters recovery and replays the buffered frame. - expect(result.current.status).toBe("recovering"); - const recovery = sock2.lastSent() as { type: string; lastSequenceId: number }; - expect(recovery.type).toBe("recovery"); - expect(recovery.lastSequenceId).toBe(5); - - await act(async () => { - sock2.fireMessage({ - type: "recovery_ack", - missedCount: 1, - serverCurrentSeq: 6, - }); - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(result.current.status).toBe("connected"); - expect(replayFetch).toHaveBeenCalledWith(5, 6); - expect(backfilled).toContain(6); - }); - - it("treats a missed heartbeat as a dead link and reconnects", () => { - const { result } = renderHook(() => useWebSocket(baseOptions())); - const sock = FakeSocket.instances[0]; - act(() => sock.fireOpen()); - expect(result.current.status).toBe("connected"); - - // No ping arrives within interval + timeout (20s) → watchdog fires. - act(() => vi.advanceTimersByTime(20_000)); - expect(result.current.missedHeartbeats).toBe(1); - expect(result.current.status).toBe("reconnecting"); - }); - - it("surfaces terminal failure after the max attempts", () => { - const { result } = renderHook(() => useWebSocket(baseOptions())); - // Fail every connection attempt by closing immediately on open-less sockets. - act(() => FakeSocket.instances[0].close()); - for (let i = 1; i <= 12; i++) { - act(() => vi.advanceTimersByTime(30_000)); - const latest = FakeSocket.instances[FakeSocket.instances.length - 1]; - act(() => latest.close()); - } - expect(result.current.status).toBe("failed"); - expect(connectionStore.getState().status).toBe("failed"); - }); -}); diff --git a/tests/unit/aggregation.test.ts b/tests/unit/aggregation.test.ts deleted file mode 100644 index e6d32e7..0000000 --- a/tests/unit/aggregation.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -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/backoff.test.ts b/tests/unit/backoff.test.ts deleted file mode 100644 index 65e527e..0000000 --- a/tests/unit/backoff.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { backoffCeiling, fullJitterBackoff } from "@/utils/backoff"; - -describe("backoffCeiling", () => { - it("grows exponentially from the base", () => { - expect(backoffCeiling(0, 500, 30_000)).toBe(500); - expect(backoffCeiling(1, 500, 30_000)).toBe(1000); - expect(backoffCeiling(2, 500, 30_000)).toBe(2000); - expect(backoffCeiling(3, 500, 30_000)).toBe(4000); - }); - - it("clamps to the cap", () => { - // 500 * 2^7 = 64000 > 30000 cap - expect(backoffCeiling(7, 500, 30_000)).toBe(30_000); - expect(backoffCeiling(20, 500, 30_000)).toBe(30_000); - }); - - it("never overflows to Infinity for huge attempts", () => { - expect(backoffCeiling(2000, 500, 30_000)).toBe(30_000); - }); - - it("returns 0 for negative attempts", () => { - expect(backoffCeiling(-1)).toBe(0); - }); -}); - -describe("fullJitterBackoff", () => { - it("returns 0 when rng yields 0", () => { - expect(fullJitterBackoff(3, 500, 30_000, () => 0)).toBe(0); - }); - - it("returns ~half the ceiling at rng=0.5", () => { - // ceiling(3) = 4000 → 0.5 * 4000 = 2000 - expect(fullJitterBackoff(3, 500, 30_000, () => 0.5)).toBe(2000); - }); - - it("never exceeds the ceiling even at rng→1", () => { - const ceiling = backoffCeiling(2, 500, 30_000); // 2000 - const value = fullJitterBackoff(2, 500, 30_000, () => 0.9999999); - expect(value).toBeLessThanOrEqual(ceiling); - expect(value).toBeGreaterThan(0); - }); - - it("stays within [0, cap] across many random draws", () => { - let seed = 1; - const rng = () => { - seed = (seed * 1103515245 + 12345) & 0x7fffffff; - return seed / 0x7fffffff; - }; - for (let attempt = 0; attempt < 12; attempt++) { - for (let i = 0; i < 50; i++) { - const d = fullJitterBackoff(attempt, 500, 30_000, rng); - expect(d).toBeGreaterThanOrEqual(0); - expect(d).toBeLessThanOrEqual(30_000); - } - } - }); -}); diff --git a/tests/unit/cameraMatrix.test.ts b/tests/unit/cameraMatrix.test.ts deleted file mode 100644 index f1808b6..0000000 --- a/tests/unit/cameraMatrix.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - buildViewProjection, - forwardVector, - mercatorMeters, - perspective, - multiply, -} from "@/utils/cameraMatrix"; -import { extractFrustumPlanes, pointInFrustum } from "@/utils/frustum"; - -describe("perspective / multiply", () => { - it("produces a 16-element column-major matrix", () => { - const m = perspective(Math.PI / 3, 1, 1, 1000); - expect(m).toHaveLength(16); - expect(m[11]).toBe(-1); - }); - - it("multiply by identity is a no-op", () => { - const id = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; - const m = perspective(Math.PI / 3, 1.5, 1, 100); - expect(multiply(m, id)).toEqual(m); - }); -}); - -describe("forwardVector", () => { - it("points north for heading 0, pitch 0", () => { - const f = forwardVector(0, 0); - expect(f.x).toBeCloseTo(0, 10); - expect(f.y).toBeCloseTo(1, 10); - expect(f.z).toBeCloseTo(0, 10); - }); - - it("points east for heading 90", () => { - const f = forwardVector(90, 0); - expect(f.x).toBeCloseTo(1, 10); - expect(f.y).toBeCloseTo(0, 10); - }); - - it("tilts downward as pitch increases", () => { - expect(forwardVector(0, 45).z).toBeLessThan(0); - }); -}); - -describe("buildViewProjection + frustum round-trip", () => { - const vp = buildViewProjection({ - position: { x: 0, y: 0, z: 0 }, - heading: 0, // looking +y (north) - pitch: 0, - aspect: 1, - fov: 60, - near: 1, - far: 1000, - }); - const planes = extractFrustumPlanes(vp); - - it("sees a point straight ahead", () => { - expect(pointInFrustum(planes, { x: 0, y: 10, z: 0 })).toBe(true); - }); - - it("does not see a point behind the camera", () => { - expect(pointInFrustum(planes, { x: 0, y: -10, z: 0 })).toBe(false); - }); - - it("does not see a point beyond the far plane", () => { - expect(pointInFrustum(planes, { x: 0, y: 2000, z: 0 })).toBe(false); - }); - - it("does not see a point far off-axis (outside the fov)", () => { - expect(pointInFrustum(planes, { x: 500, y: 10, z: 0 })).toBe(false); - }); -}); - -describe("mercatorMeters", () => { - it("maps the reference point to the origin", () => { - const p = mercatorMeters(12.5, 41.9, { lng: 12.5, lat: 41.9 }); - expect(p.x).toBeCloseTo(0, 6); - expect(p.y).toBeCloseTo(0, 6); - }); - - it("is monotonic in lng and lat", () => { - const ref = { lng: 0, lat: 0 }; - expect(mercatorMeters(0.001, 0, ref).x).toBeGreaterThan(0); - expect(mercatorMeters(0, 0.001, ref).y).toBeGreaterThan(0); - }); - - it("east/north offsets are ~111 m per 0.001° near the equator", () => { - const { x } = mercatorMeters(0.001, 0, { lng: 0, lat: 0 }); - expect(x).toBeGreaterThan(100); - expect(x).toBeLessThan(120); - }); -}); diff --git a/tests/unit/capacityPlanning.test.ts b/tests/unit/capacityPlanning.test.ts deleted file mode 100644 index fd5c7b2..0000000 --- a/tests/unit/capacityPlanning.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { planCapacity, type HistoricalReading } from "@/utils/capacityPlanning"; - -describe("planCapacity", () => { - it("projects historical growth and recommends margin-adjusted capacity", () => { - vi.setSystemTime(new Date("2026-07-18T00:00:00Z")); - const hour = 60 * 60 * 1000; - const readings: HistoricalReading[] = [ - { resource: "water", value: 10, timestamp: 0 }, - { resource: "water", value: 20, timestamp: hour }, - { resource: "water", value: 40, timestamp: 2 * hour }, - ]; - - const plan = planCapacity( - readings, - [{ resource: "water", capacityBase: 85_000 }], - { windowMs: hour, horizonWindows: 2, safetyMargin: 0.1 } - ); - - expect(plan.generatedAt).toBe(Date.parse("2026-07-18T00:00:00Z")); - expect(plan.forecasts.water.currentBase).toBe(BigInt(40_000)); - expect(plan.forecasts.water.trendPerWindowBase).toBe(BigInt(15_000)); - expect(plan.forecasts.water.projectedBase).toBe(BigInt(70_000)); - expect(plan.forecasts.water.recommendedCapacityBase).toBe(BigInt(77_000)); - expect(plan.forecasts.water.status).toBe("watch"); - vi.useRealTimers(); - }); - - it("marks exhausted resources critical immediately", () => { - const plan = planCapacity( - [{ resource: "energy", value: 2, timestamp: 0 }], - [{ resource: "energy", capacityBase: 1 }], - { windowMs: 1000, horizonWindows: 1 } - ); - - expect(plan.forecasts.energy.exhaustedAtWindow).toBe(0); - expect(plan.forecasts.energy.status).toBe("critical"); - }); - - it("validates planning bounds", () => { - expect(() => - planCapacity([], [], { windowMs: 0, horizonWindows: 1 }) - ).toThrow("windowMs must be positive"); - expect(() => - planCapacity([], [], { windowMs: 1, horizonWindows: -1 }) - ).toThrow("horizonWindows cannot be negative"); - }); -}); diff --git a/tests/unit/chaosBlueprint.test.ts b/tests/unit/chaosBlueprint.test.ts deleted file mode 100644 index 630a007..0000000 --- a/tests/unit/chaosBlueprint.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - STAGING_CHAOS_BLUEPRINT, - getExperimentsForService, - validateChaosBlueprint, - type ChaosBlueprint, -} from "@/utils/chaosBlueprint"; - -describe("staging chaos engineering blueprint", () => { - it("meets latency, availability, security, and rollout guardrails", () => { - expect(validateChaosBlueprint(STAGING_CHAOS_BLUEPRINT)).toEqual([]); - }); - - it("exposes service-specific experiments for orchestration", () => { - const walletExperiments = getExperimentsForService("wallet-adapter"); - - expect(walletExperiments).toHaveLength(1); - expect(walletExperiments[0]).toMatchObject({ - id: "wallet-adapter-dependency-failure", - securityReviewRequired: true, - }); - }); - - it("reports every safety violation with actionable messages", () => { - const unsafeBlueprint: ChaosBlueprint = { - ...STAGING_CHAOS_BLUEPRINT, - p99TargetMs: 150, - availabilityTargetPercent: 99.9, - deploymentStrategy: { ...STAGING_CHAOS_BLUEPRINT.deploymentStrategy, mode: "blue-green-with-canary" }, - experiments: [ - { - ...STAGING_CHAOS_BLUEPRINT.experiments[0], - securityReviewRequired: false, - blastRadiusPercent: 25, - steadyState: { p99LatencyMs: 200, availabilityPercent: 99, errorRatePercent: 0.1 }, - abortConditions: [], - rollback: [], - monitoringSignals: ["web_vitals_p99"], - }, - ], - }; - - expect(validateChaosBlueprint(unsafeBlueprint)).toEqual([ - "Critical path P99 target must be <= 100 ms.", - "Availability target must be at least 99.99%.", - "frontend-latency-100ms must require security review.", - "frontend-latency-100ms blast radius must be between 1% and 10% for staging.", - "frontend-latency-100ms exceeds the blueprint P99 latency target.", - "frontend-latency-100ms falls below the blueprint availability target.", - "frontend-latency-100ms needs abort conditions.", - "frontend-latency-100ms needs rollback steps.", - "frontend-latency-100ms needs at least three monitoring signals.", - ]); - }); -}); diff --git a/tests/unit/crdtMerge.test.ts b/tests/unit/crdtMerge.test.ts deleted file mode 100644 index b849019..0000000 --- a/tests/unit/crdtMerge.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - mergeRegister, - emptyOrSet, - orSetAdd, - orSetRemove, - orSetRemoveTags, - orSetMerge, - orSetValues, - observedTags, - mergeEvents, -} from "@/utils/crdtMerge"; -import type { - ChainId, - CrdtEvent, - LWWRegister, - LamportTimestamp, - ResourceState, - VectorClock, -} from "@/types/crdt"; - -const ts = (chainId: ChainId, counter: number): LamportTimestamp => ({ chainId, counter }); -const reg = (value: T, t: LamportTimestamp): LWWRegister => ({ - kind: "lww", - value, - timestamp: t, -}); - -describe("mergeRegister (LWW)", () => { - it("keeps the value with the greater timestamp", () => { - expect(mergeRegister(reg("a", ts("testnet", 1)), reg("b", ts("testnet", 2))).value).toBe("b"); - }); - - it("breaks concurrent ties by chain priority", () => { - const winner = mergeRegister(reg("t", ts("testnet", 5)), reg("m", ts("mainnet", 5))); - expect(winner.value).toBe("m"); - }); - - it("is commutative", () => { - const a = reg("a", ts("testnet", 5)); - const b = reg("b", ts("mainnet", 5)); - expect(mergeRegister(a, b)).toEqual(mergeRegister(b, a)); - }); -}); - -describe("OR-set", () => { - it("adds and materialises elements", () => { - let s = emptyOrSet(); - s = orSetAdd(s, "deviceA", "t1"); - s = orSetAdd(s, "deviceB", "t2"); - expect(orSetValues(s).sort()).toEqual(["deviceA", "deviceB"]); - }); - - it("removes by tombstoning observed tags", () => { - let s = orSetAdd(emptyOrSet(), "x", "t1"); - s = orSetRemove(s, "x"); - expect(orSetValues(s)).toEqual([]); - }); - - it("supports add → remove → re-add with a fresh tag", () => { - let s = orSetAdd(emptyOrSet(), "x", "t1"); - s = orSetRemove(s, "x"); // tombstones t1 - expect(orSetValues(s)).toEqual([]); - s = orSetAdd(s, "x", "t2"); // fresh tag survives - expect(orSetValues(s)).toEqual(["x"]); - }); - - it("concurrent add wins over a remove that didn't observe its tag", () => { - // Replica A adds with tag tA; replica B removes (observing only tB). - const base = orSetAdd(emptyOrSet(), "x", "tB"); - const a = orSetAdd(base, "x", "tA"); // A adds a fresh tag - const observed = observedTags(base, "x"); // B only saw [tB] - const b = orSetRemoveTags(base, observed); // B removes tB - const merged = orSetMerge(a, b); - // tA was never tombstoned → element survives (add-wins). - expect(orSetValues(merged)).toEqual(["x"]); - }); - - it("merge is commutative", () => { - const a = orSetAdd(orSetAdd(emptyOrSet(), "x", "t1"), "y", "t2"); - const b = orSetRemoveTags(orSetAdd(emptyOrSet(), "z", "t3"), ["t1"]); - expect(orSetValues(orSetMerge(a, b)).sort()).toEqual( - orSetValues(orSetMerge(b, a)).sort() - ); - }); -}); - -describe("mergeEvents convergence", () => { - const events: CrdtEvent[] = [ - { type: "register-set", resourceId: "meter:1", value: 10, timestamp: ts("testnet", 1) }, - { type: "register-set", resourceId: "meter:1", value: 20, timestamp: ts("mainnet", 3) }, - { type: "register-set", resourceId: "meter:1", value: 15, timestamp: ts("futurenet", 2) }, - { type: "or-set-add", resourceId: "devices", element: "d1", tag: "g1", timestamp: ts("testnet", 1) }, - { type: "or-set-add", resourceId: "devices", element: "d2", tag: "g2", timestamp: ts("mainnet", 2) }, - { type: "or-set-remove", resourceId: "devices", element: "d1", tags: ["g1"], timestamp: ts("mainnet", 4) }, - ]; - - function materialise(state: ResourceState | undefined): unknown { - if (!state) return undefined; - return state.kind === "lww" ? state.value : orSetValues(state).sort(); - } - - function runOrder(order: CrdtEvent[]): Record { - const { diffs } = mergeEvents({}, {}, order); - const out: Record = {}; - for (const d of diffs) out[d.resourceId] = materialise(d.state); - return out; - } - - it("converges to the same state under arbitrary interleavings", () => { - const original = runOrder(events); - // The LWW winner is the highest timestamp (mainnet,3) → 20. - expect(original["meter:1"]).toBe(20); - // d1 removed (g1 tombstoned), d2 present. - expect(original["devices"]).toEqual(["d2"]); - - // Several permutations must yield identical materialised state. - const reversed = runOrder([...events].reverse()); - const rotated = runOrder([...events.slice(3), ...events.slice(0, 3)]); - expect(reversed).toEqual(original); - expect(rotated).toEqual(original); - }); - - it("reports vector clocks and chain-seen counters", () => { - const { diffs, chainSeen } = mergeEvents({}, {}, events); - const meter = diffs.find((d) => d.resourceId === "meter:1")!; - const expectedClock: VectorClock = { testnet: 1, mainnet: 3, futurenet: 2 }; - expect(meter.vectorClock).toEqual(expectedClock); - expect(chainSeen).toEqual({ testnet: 1, mainnet: 4, futurenet: 2 }); - }); - - it("folds incrementally into prior state like the worker does", () => { - const first = mergeEvents({}, {}, events.slice(0, 3)); - const states: Record = {}; - const clocks: Record = {}; - for (const d of first.diffs) { - states[d.resourceId] = d.state; - clocks[d.resourceId] = d.vectorClock; - } - const second = mergeEvents(states, clocks, events.slice(3)); - const all: Record = {}; - for (const d of [...first.diffs, ...second.diffs]) { - all[d.resourceId] = materialise(d.state); - } - expect(all["meter:1"]).toBe(20); - expect(all["devices"]).toEqual(["d2"]); - }); -}); diff --git a/tests/unit/crdtSlice.test.ts b/tests/unit/crdtSlice.test.ts deleted file mode 100644 index 304760a..0000000 --- a/tests/unit/crdtSlice.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, it, expect, beforeEach } from "vitest"; -import { - crdtStore, - findStaleChains, - runStalenessWatch, - selectResourceValue, -} from "@/store/slices/crdtSlice"; -import { RECONCILE_TIMEOUT_MS, type ResourceDiff } from "@/types/crdt"; - -beforeEach(() => crdtStore.dispatch({ type: "RESET" })); - -const registerDiff = (id: string, value: unknown): ResourceDiff => ({ - resourceId: id, - state: { kind: "lww", value, timestamp: { chainId: "mainnet", counter: 1 } }, - vectorClock: { mainnet: 1 }, -}); - -describe("findStaleChains", () => { - it("flags chains silent past the timeout", () => { - const lastSeen = { mainnet: 0, testnet: 1000 }; - expect(findStaleChains(lastSeen, RECONCILE_TIMEOUT_MS + 500, RECONCILE_TIMEOUT_MS)).toEqual([ - "mainnet", - ]); - }); - - it("returns nothing when all chains are fresh", () => { - expect(findStaleChains({ mainnet: 1000 }, 1500, RECONCILE_TIMEOUT_MS)).toEqual([]); - }); -}); - -describe("crdtStore", () => { - it("applies diffs and records the chains seen", () => { - crdtStore.dispatch({ - type: "APPLY_DIFFS", - payload: { - diffs: [registerDiff("meter:1", 42)], - chainSeen: { mainnet: 1 }, - at: 5000, - }, - }); - const state = crdtStore.getState(); - expect(selectResourceValue(state, "meter:1")).toBe(42); - expect(state.lastSeen.mainnet).toBe(5000); - }); - - it("materialises an OR-set resource as an array", () => { - crdtStore.dispatch({ - type: "APPLY_DIFFS", - payload: { - diffs: [ - { - resourceId: "devices", - state: { - kind: "or-set", - adds: { d1: ["t1"], d2: ["t2"] }, - tombstones: ["t2"], - values: { d1: "d1", d2: "d2" }, - }, - vectorClock: { mainnet: 2 }, - }, - ], - chainSeen: { mainnet: 2 }, - at: 1, - }, - }); - expect(selectResourceValue(crdtStore.getState(), "devices")).toEqual(["d1"]); - }); - - it("flags stale chains for reconciliation, then clears on next sighting", () => { - crdtStore.dispatch({ type: "CHAIN_SEEN", payload: { chainId: "testnet", at: 0 } }); - const flagged = runStalenessWatch(RECONCILE_TIMEOUT_MS + 1000, RECONCILE_TIMEOUT_MS); - expect(flagged).toEqual(["testnet"]); - expect(crdtStore.getState().reconciling).toEqual(["testnet"]); - - // A fresh sighting clears the reconcile flag. - crdtStore.dispatch({ type: "CHAIN_SEEN", payload: { chainId: "testnet", at: 999999 } }); - expect(crdtStore.getState().reconciling).toEqual([]); - }); - - it("applying a diff clears reconcile for chains heard from", () => { - crdtStore.dispatch({ type: "RECONCILE", payload: { chainId: "futurenet" } }); - expect(crdtStore.getState().reconciling).toContain("futurenet"); - crdtStore.dispatch({ - type: "APPLY_DIFFS", - payload: { diffs: [], chainSeen: { futurenet: 5 }, at: 10 }, - }); - expect(crdtStore.getState().reconciling).toEqual([]); - }); -}); diff --git a/tests/unit/dbMigration.test.ts b/tests/unit/dbMigration.test.ts deleted file mode 100644 index a71e406..0000000 --- a/tests/unit/dbMigration.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { - createMigrationPlan, - InMemoryMigrationStore, - MigrationError, - MigrationRunner, - type DatabaseMigration, -} from "@/utils/dbMigration"; - -function migration(version: number, calls: string[]): DatabaseMigration { - return { - version, - name: `migration-${version}`, - up: () => { - calls.push(`up:${version}`); - }, - down: () => { - calls.push(`down:${version}`); - }, - }; -} - -describe("database migration versioning", () => { - it("plans forward migrations in ascending version order", () => { - const calls: string[] = []; - const plan = createMigrationPlan([migration(3, calls), migration(1, calls), migration(2, calls)], 1, 3); - - expect(plan.map((step) => `${step.direction}:${step.migration.version}`)).toEqual(["up:2", "up:3"]); - }); - - it("plans rollback migrations in descending version order", () => { - const calls: string[] = []; - const plan = createMigrationPlan([migration(1, calls), migration(2, calls), migration(3, calls)], 3, 1); - - expect(plan.map((step) => `${step.direction}:${step.migration.version}`)).toEqual(["down:3", "down:2"]); - }); - - it("applies migrations, persists version state, and emits metrics", async () => { - const calls: string[] = []; - const recordMetric = vi.fn(); - const store = new InMemoryMigrationStore(); - const runner = new MigrationRunner({ - migrations: [migration(1, calls), migration(2, calls)], - store, - context: { recordMetric }, - }); - - const records = await runner.migrateTo(2); - - expect(calls).toEqual(["up:1", "up:2"]); - expect(store.getCurrentVersion()).toBe(2); - expect(records.map((record) => record.status)).toEqual(["applied", "applied"]); - expect(recordMetric).toHaveBeenCalledWith( - "db_migration_duration_ms", - expect.any(Number), - expect.objectContaining({ direction: "up", version: "1" }) - ); - }); - - it("rolls back the requested number of versions", async () => { - const calls: string[] = []; - const store = new InMemoryMigrationStore(3); - const runner = new MigrationRunner({ - migrations: [migration(1, calls), migration(2, calls), migration(3, calls)], - store, - }); - - const records = await runner.rollback(2); - - expect(calls).toEqual(["down:3", "down:2"]); - expect(store.getCurrentVersion()).toBe(1); - expect(records.map((record) => record.status)).toEqual(["rolled_back", "rolled_back"]); - }); - - it("stops and preserves the current version when a migration fails", async () => { - const store = new InMemoryMigrationStore(1); - const recordMetric = vi.fn(); - const runner = new MigrationRunner({ - migrations: [ - migration(1, []), - { - version: 2, - name: "broken", - up: () => { - throw new Error("boom"); - }, - down: () => undefined, - }, - ], - store, - context: { recordMetric }, - }); - - await expect(runner.migrateTo(2)).rejects.toBeInstanceOf(MigrationError); - expect(store.getCurrentVersion()).toBe(1); - expect(recordMetric).toHaveBeenCalledWith( - "db_migration_failure", - 1, - expect.objectContaining({ direction: "up", version: "2" }) - ); - }); -}); diff --git a/tests/unit/decimal.test.ts b/tests/unit/decimal.test.ts deleted file mode 100644 index 4fc75a4..0000000 --- a/tests/unit/decimal.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -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/edgeBundling.test.ts b/tests/unit/edgeBundling.test.ts deleted file mode 100644 index 42dfda9..0000000 --- a/tests/unit/edgeBundling.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - subdivide, - edgeCompatibility, - bundleEdges, -} from "@/utils/edgeBundling"; -import { FDEB, type NetworkEdge } from "@/types/network"; - -function edge( - id: string, - from: [number, number], - to: [number, number] -): NetworkEdge { - return { - id, - geometry: [from, to], - flowDirection: "forward", - loadStatus: "nominal", - }; -} - -describe("subdivide", () => { - it("produces n evenly-spaced points including endpoints", () => { - const pts = subdivide({ x: 0, y: 0 }, { x: 10, y: 0 }, 5); - expect(pts).toHaveLength(5); - expect(pts[0]).toEqual({ x: 0, y: 0 }); - expect(pts[4]).toEqual({ x: 10, y: 0 }); - expect(pts[2].x).toBeCloseTo(5, 10); - }); -}); - -describe("edgeCompatibility", () => { - it("scores parallel segments as fully angle-compatible", () => { - const c = edgeCompatibility( - { x: 0, y: 0 }, - { x: 10, y: 0 }, - { x: 0, y: 5 }, - { x: 10, y: 5 } - ); - expect(c.angle).toBeCloseTo(1, 10); - }); - - it("scores perpendicular segments as angle-incompatible", () => { - const c = edgeCompatibility( - { x: 0, y: 0 }, - { x: 10, y: 0 }, - { x: 0, y: 0 }, - { x: 0, y: 10 } - ); - expect(c.angle).toBeCloseTo(0, 10); - }); -}); - -describe("bundleEdges", () => { - it("returns control-point polylines with pinned endpoints", () => { - const e = edge("e1", [0, 0], [0.01, 0]); - const [bundled] = bundleEdges([e]); - expect(bundled.points).toHaveLength(FDEB.subdivisions); - expect(bundled.points[0][0]).toBeCloseTo(0, 6); - expect(bundled.points[0][1]).toBeCloseTo(0, 6); - expect(bundled.points[FDEB.subdivisions - 1][0]).toBeCloseTo(0.01, 6); - }); - - it("keeps an isolated edge straight (no compatible partners)", () => { - const [bundled] = bundleEdges([edge("e1", [0, 0], [0.01, 0])]); - // Every control point stays on the y=0 line. - for (const p of bundled.points) expect(p[1]).toBeCloseTo(0, 6); - }); - - it("pulls two parallel neighbouring edges together", () => { - const a = edge("a", [0, 0], [0.01, 0]); - const b = edge("b", [0, 0.001], [0.01, 0.001]); - const result = bundleEdges([a, b]); - const mid = Math.floor(FDEB.subdivisions / 2); - const before = 0.001; - const after = Math.abs(result[0].points[mid][1] - result[1].points[mid][1]); - expect(after).toBeLessThan(before); - }); - - it("preserves edge metadata and ids", () => { - const result = bundleEdges([edge("x", [0, 0], [0.01, 0.01])]); - expect(result[0].id).toBe("x"); - expect(result[0].flowDirection).toBe("forward"); - expect(result[0].loadStatus).toBe("nominal"); - }); - - it("handles an empty list", () => { - expect(bundleEdges([])).toEqual([]); - }); - - it("respects the iteration cap (terminates)", () => { - // A larger compatible set should still return promptly. - const edges = Array.from({ length: 20 }, (_, i) => - edge(`e${i}`, [0, i * 0.0001], [0.01, i * 0.0001]) - ); - const result = bundleEdges(edges, { maxIterations: FDEB.maxIterations }); - expect(result).toHaveLength(20); - }); -}); diff --git a/tests/unit/edgeSimplification.test.ts b/tests/unit/edgeSimplification.test.ts deleted file mode 100644 index 39e074f..0000000 --- a/tests/unit/edgeSimplification.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - zoomEpsilon, - douglasPeucker, - perpendicularDistance, - simplifyGeometry, - simplifyEdges, -} from "@/utils/edgeSimplification"; -import { SIMPLIFY_EPSILON, type NetworkEdge } from "@/types/network"; - -describe("zoomEpsilon", () => { - it("is 5 m at zoom ≤ 14 and 20 m at zoom ≥ 16", () => { - expect(zoomEpsilon(10)).toBe(SIMPLIFY_EPSILON.low); - expect(zoomEpsilon(14)).toBe(5); - expect(zoomEpsilon(16)).toBe(20); - expect(zoomEpsilon(18)).toBe(20); - }); - - it("interpolates across the 14–16 band", () => { - expect(zoomEpsilon(15)).toBe(12.5); - }); -}); - -describe("perpendicularDistance", () => { - it("measures distance to the line through a,b", () => { - const d = perpendicularDistance( - { x: 0, y: 5 }, - { x: -10, y: 0 }, - { x: 10, y: 0 } - ); - expect(d).toBeCloseTo(5, 10); - }); - - it("handles a degenerate segment", () => { - const d = perpendicularDistance({ x: 3, y: 4 }, { x: 0, y: 0 }, { x: 0, y: 0 }); - expect(d).toBeCloseTo(5, 10); - }); -}); - -describe("douglasPeucker", () => { - it("keeps endpoints and drops near-collinear points", () => { - const pts = [ - { x: 0, y: 0 }, - { x: 1, y: 0.001 }, - { x: 2, y: -0.001 }, - { x: 3, y: 0 }, - ]; - expect(douglasPeucker(pts, 1)).toEqual([ - { x: 0, y: 0 }, - { x: 3, y: 0 }, - ]); - }); - - it("retains a point that deviates beyond epsilon", () => { - const pts = [ - { x: 0, y: 0 }, - { x: 1, y: 10 }, - { x: 2, y: 0 }, - ]; - const out = douglasPeucker(pts, 1); - expect(out).toHaveLength(3); - }); - - it("returns short polylines unchanged", () => { - const pts = [{ x: 0, y: 0 }, { x: 1, y: 1 }]; - expect(douglasPeucker(pts, 5)).toEqual(pts); - }); -}); - -describe("simplifyGeometry / simplifyEdges", () => { - it("collapses a nearly-straight lng/lat run to its endpoints", () => { - const ref: [number, number] = [0, 0]; - // ~0.0000001° ≈ 1 cm wiggle, well under the 5 m epsilon. - const geom: [number, number][] = [ - [0, 0], - [0.001, 0.0000001], - [0.002, 0], - ]; - expect(simplifyGeometry(geom, 5, ref)).toHaveLength(2); - }); - - it("simplifies every edge and preserves metadata", () => { - const edges: NetworkEdge[] = [ - { - id: "e1", - geometry: [ - [0, 0], - [0.001, 0.0000001], - [0.002, 0], - ], - flowDirection: "forward", - loadStatus: "nominal", - }, - ]; - const out = simplifyEdges(edges, 12); - expect(out[0].id).toBe("e1"); - expect(out[0].flowDirection).toBe("forward"); - expect(out[0].geometry.length).toBeLessThanOrEqual(3); - }); - - it("handles an empty edge list", () => { - expect(simplifyEdges([], 14)).toEqual([]); - }); -}); diff --git a/tests/unit/errors.test.ts b/tests/unit/errors.test.ts deleted file mode 100644 index 981c0cf..0000000 --- a/tests/unit/errors.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - SOROBAN_ERROR_CODES, - classifyError, - formatErrorForDisplay, - resolveSorobanError, -} from "@/utils/errors"; - -describe("resolveSorobanError()", () => { - it("maps known Soroban error substrings to readable messages", () => { - expect(resolveSorobanError("tx_bad_seq")).toContain("sequence"); - expect(resolveSorobanError("AUTH_REVOKED")).toContain("re-authenticate"); - expect(resolveSorobanError("insufficient_balance")).toContain("funds"); - }); - - it("falls back to network error keywords", () => { - expect(resolveSorobanError("Failed to fetch")).toContain("server"); - expect(resolveSorobanError("WebSocket connection timeout")).toContain("internet"); - }); - - it("wraps unknown errors in a generic message", () => { - const unknown = "some obscure internal issue with no mapping"; - expect(resolveSorobanError(unknown)).toContain("unexpected error"); - }); - - it("exposes a sizable dictionary of known codes", () => { - expect(Object.keys(SOROBAN_ERROR_CODES).length).toBeGreaterThanOrEqual(15); - }); -}); - -describe("classifyError()", () => { - it("marks authorization problems as high severity and actionable", () => { - const result = classifyError(new Error("AUTH_REVOKED")); - expect(result.severity).toBe("high"); - expect(result.actionable).toBe(true); - expect(result.message).toContain("re-authenticate"); - }); - - it("marks balance and fee issues as medium severity and actionable", () => { - const result = classifyError("insufficient_balance"); - expect(result.severity).toBe("medium"); - expect(result.actionable).toBe(true); - }); - - it("marks timeouts as medium severity and actionable", () => { - const result = classifyError("request timeout"); - expect(result.severity).toBe("medium"); - expect(result.actionable).toBe(true); - }); - - it("marks unknown failures as low severity and non-actionable", () => { - const result = classifyError("something unexpected"); - expect(result.severity).toBe("low"); - expect(result.actionable).toBe(false); - }); -}); - -describe("formatErrorForDisplay()", () => { - it("prefixes the severity tag", () => { - expect(formatErrorForDisplay("request timeout")).toContain("[MEDIUM]"); - expect(formatErrorForDisplay(new Error("AUTH_REVOKED"))).toContain("[HIGH]"); - }); -}); diff --git a/tests/unit/frameBuffer.test.ts b/tests/unit/frameBuffer.test.ts deleted file mode 100644 index 34ffcfb..0000000 --- a/tests/unit/frameBuffer.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { FrameBuffer } from "@/utils/frameBuffer"; -import type { TelemetryFrame } from "@/types/connection"; - -function frame(seq: number, size = 100): TelemetryFrame { - return { sequenceId: seq, data: { v: seq }, receivedAt: seq, size }; -} - -describe("FrameBuffer", () => { - it("retains frames in chronological order", () => { - const buf = new FrameBuffer(4); - buf.push(frame(1)); - buf.push(frame(2)); - buf.push(frame(3)); - expect(buf.size).toBe(3); - expect(buf.toArray().map((f) => f.sequenceId)).toEqual([1, 2, 3]); - }); - - it("evicts the oldest frames once full (ring behaviour)", () => { - const buf = new FrameBuffer(3); - [1, 2, 3, 4, 5].forEach((s) => buf.push(frame(s))); - expect(buf.isFull).toBe(true); - expect(buf.size).toBe(3); - expect(buf.toArray().map((f) => f.sequenceId)).toEqual([3, 4, 5]); - }); - - it("tracks the highest sequence id", () => { - const buf = new FrameBuffer(3); - buf.push(frame(10)); - buf.push(frame(11)); - expect(buf.lastSequenceId).toBe(11); - }); - - it("drains all frames and clears, preserving lastSequenceId", () => { - const buf = new FrameBuffer(5); - [1, 2, 3].forEach((s) => buf.push(frame(s))); - const drained = buf.drain(); - expect(drained.map((f) => f.sequenceId)).toEqual([1, 2, 3]); - expect(buf.size).toBe(0); - expect(buf.toArray()).toEqual([]); - // lastSequenceId survives a drain so the next recovery handshake is correct. - expect(buf.lastSequenceId).toBe(3); - }); - - it("sums byte sizes", () => { - const buf = new FrameBuffer(5); - buf.push(frame(1, 200)); - buf.push(frame(2, 300)); - expect(buf.byteLength()).toBe(500); - }); - - it("clear() resets the high-water mark", () => { - const buf = new FrameBuffer(5); - buf.push(frame(7)); - buf.clear(); - expect(buf.size).toBe(0); - expect(buf.lastSequenceId).toBe(-1); - }); - - it("rejects a non-positive capacity", () => { - expect(() => new FrameBuffer(0)).toThrow(); - }); - - it("defaults to a capacity of 500", () => { - const buf = new FrameBuffer(); - for (let i = 0; i < 600; i++) buf.push(frame(i)); - expect(buf.size).toBe(500); - expect(buf.toArray()[0].sequenceId).toBe(100); // oldest 100 evicted - }); -}); diff --git a/tests/unit/frustum.test.ts b/tests/unit/frustum.test.ts deleted file mode 100644 index a17467a..0000000 --- a/tests/unit/frustum.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - extractFrustumPlanes, - sphereInFrustum, - pointInFrustum, - planeDistance, - distance3D, - normalizePlane, -} from "@/utils/frustum"; - -const IDENTITY = [ - 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, -]; - -describe("extractFrustumPlanes", () => { - it("derives the NDC cube from an identity matrix", () => { - const planes = extractFrustumPlanes(IDENTITY); - expect(planes).toHaveLength(6); - // left: x >= -1 → a=1, d=1 - expect(planes[0]).toMatchObject({ a: 1, b: 0, c: 0, d: 1 }); - // right: x <= 1 → a=-1, d=1 - expect(planes[1]).toMatchObject({ a: -1, b: 0, c: 0, d: 1 }); - // near: z >= -1 - expect(planes[4]).toMatchObject({ a: 0, b: 0, c: 1, d: 1 }); - }); -}); - -describe("sphereInFrustum / pointInFrustum (NDC cube)", () => { - const planes = extractFrustumPlanes(IDENTITY); - - it("accepts a point at the origin", () => { - expect(pointInFrustum(planes, { x: 0, y: 0, z: 0 })).toBe(true); - }); - - it("rejects a point outside the cube", () => { - expect(pointInFrustum(planes, { x: 2, y: 0, z: 0 })).toBe(false); - }); - - it("keeps a sphere that straddles a face (conservative)", () => { - expect( - sphereInFrustum(planes, { center: { x: 1.4, y: 0, z: 0 }, radius: 0.5 }) - ).toBe(true); - }); - - it("rejects a sphere fully outside a face", () => { - expect( - sphereInFrustum(planes, { center: { x: 2, y: 0, z: 0 }, radius: 0.4 }) - ).toBe(false); - }); -}); - -describe("helpers", () => { - it("normalizePlane yields a unit normal", () => { - const p = normalizePlane({ a: 0, b: 3, c: 4, d: 10 }); - expect(Math.hypot(p.a, p.b, p.c)).toBeCloseTo(1, 10); - expect(p.d).toBeCloseTo(2, 10); - }); - - it("planeDistance is positive on the interior side", () => { - expect(planeDistance({ a: 1, b: 0, c: 0, d: 0 }, { x: 5, y: 0, z: 0 })).toBe(5); - }); - - it("distance3D is Euclidean", () => { - expect(distance3D({ x: 0, y: 0, z: 0 }, { x: 3, y: 4, z: 0 })).toBe(5); - }); -}); diff --git a/tests/unit/kafkaConsumerLag.test.ts b/tests/unit/kafkaConsumerLag.test.ts deleted file mode 100644 index 33696d4..0000000 --- a/tests/unit/kafkaConsumerLag.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - DEFAULT_LAG_POLICY, - partitionLag, - recommendConsumerReplicas, - summarizeConsumerLag, - type KafkaConsumerLagSample, -} from "@/utils/kafkaConsumerLag"; - -const baseSample: KafkaConsumerLagSample = { - groupId: "billing-writer", - topic: "meter-readings", - partition: 0, - committedOffset: 100, - highWatermark: 150, - sampledAt: 1_000, -}; - -describe("partitionLag", () => { - it("never reports negative lag when offsets race", () => { - expect(partitionLag({ ...baseSample, committedOffset: 200, highWatermark: 150 })).toBe(0); - }); - - it("rejects non-finite offsets", () => { - expect(() => partitionLag({ ...baseSample, committedOffset: Number.NaN })).toThrow( - "Kafka offsets must be finite numbers" - ); - }); -}); - -describe("summarizeConsumerLag", () => { - it("aggregates lag by consumer group and sorts the largest backlog first", () => { - const summaries = summarizeConsumerLag([ - baseSample, - { ...baseSample, partition: 1, committedOffset: 0, highWatermark: 25 }, - { ...baseSample, groupId: "notifications", committedOffset: 10, highWatermark: 15 }, - ], DEFAULT_LAG_POLICY, 1_000); - - expect(summaries).toEqual([ - expect.objectContaining({ - groupId: "billing-writer", - totalLag: 75, - maxPartitionLag: 50, - partitions: 2, - severity: "healthy", - stale: false, - }), - expect.objectContaining({ groupId: "notifications", totalLag: 5 }), - ]); - }); - - it("marks stale metrics as critical even with low lag", () => { - const [summary] = summarizeConsumerLag([baseSample], DEFAULT_LAG_POLICY, 125_001); - - expect(summary.severity).toBe("critical"); - expect(summary.stale).toBe(true); - }); -}); - -describe("recommendConsumerReplicas", () => { - it("steps scale-out decisions instead of jumping straight to the target", () => { - const [summary] = summarizeConsumerLag([ - { ...baseSample, committedOffset: 0, highWatermark: 80_000 }, - ], DEFAULT_LAG_POLICY, 1_000); - - expect(recommendConsumerReplicas(summary, 3)).toEqual({ - groupId: "billing-writer", - currentReplicas: 3, - desiredReplicas: 7, - severity: "critical", - reason: "scale out to drain 80000 queued messages", - }); - }); - - it("scales in only after lag has recovered", () => { - const [summary] = summarizeConsumerLag([baseSample], DEFAULT_LAG_POLICY, 1_000); - - expect(recommendConsumerReplicas(summary, 12).desiredReplicas).toBe(8); - }); -}); diff --git a/tests/unit/lamportClock.test.ts b/tests/unit/lamportClock.test.ts deleted file mode 100644 index e09646c..0000000 --- a/tests/unit/lamportClock.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - LamportClock, - compareTimestamp, - maxTimestamp, - dominates, -} from "@/utils/lamportClock"; -import type { LamportTimestamp } from "@/types/crdt"; - -const ts = (chainId: LamportTimestamp["chainId"], counter: number): LamportTimestamp => ({ - chainId, - counter, -}); - -describe("compareTimestamp", () => { - it("orders by counter first", () => { - expect(compareTimestamp(ts("testnet", 1), ts("testnet", 2))).toBe(-1); - expect(compareTimestamp(ts("testnet", 3), ts("testnet", 2))).toBe(1); - }); - - it("breaks ties by chain priority (mainnet > testnet > futurenet)", () => { - expect(compareTimestamp(ts("mainnet", 5), ts("testnet", 5))).toBe(1); - expect(compareTimestamp(ts("futurenet", 5), ts("testnet", 5))).toBe(-1); - }); - - it("is equal only for the same chain and counter", () => { - expect(compareTimestamp(ts("mainnet", 5), ts("mainnet", 5))).toBe(0); - }); - - it("is a deterministic total order regardless of argument order", () => { - const a = ts("testnet", 5); - const b = ts("mainnet", 5); - expect(compareTimestamp(a, b)).toBe(-compareTimestamp(b, a)); - }); - - it("maxTimestamp and dominates agree with compare", () => { - expect(maxTimestamp(ts("futurenet", 4), ts("mainnet", 4))).toEqual( - ts("mainnet", 4) - ); - expect(dominates(ts("mainnet", 4), ts("testnet", 4))).toBe(true); - expect(dominates(ts("testnet", 4), ts("mainnet", 4))).toBe(false); - }); -}); - -describe("LamportClock", () => { - it("ticks monotonically", () => { - const clock = new LamportClock("testnet"); - expect(clock.tick()).toEqual(ts("testnet", 1)); - expect(clock.tick()).toEqual(ts("testnet", 2)); - expect(clock.value).toBe(2); - }); - - it("observes remote timestamps and advances past them", () => { - const clock = new LamportClock("testnet", 3); - clock.observe(ts("mainnet", 10)); - expect(clock.value).toBe(10); - expect(clock.tick()).toEqual(ts("testnet", 11)); - }); - - it("does not regress on a lower remote timestamp", () => { - const clock = new LamportClock("testnet", 7); - clock.observe(ts("mainnet", 2)); - expect(clock.value).toBe(7); - }); - - it("tickAfter implements receive-then-send", () => { - const clock = new LamportClock("testnet", 4); - expect(clock.tickAfter(ts("mainnet", 9))).toEqual(ts("testnet", 10)); - }); -}); diff --git a/tests/unit/lruEviction.test.ts b/tests/unit/lruEviction.test.ts deleted file mode 100644 index ae95027..0000000 --- a/tests/unit/lruEviction.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { LRUList } from "@/utils/lruEviction"; -import { TTL_MS, type TileMeta } from "@/types/tile"; - -function meta( - key: string, - over: Partial = {} -): TileMeta { - return { - key, - z: 14, - size: 42_000, - fetchedAt: 0, - accessCount: 0, - lastAccess: 0, - ...over, - }; -} - -describe("LRUList ordering", () => { - it("keeps newest at the head", () => { - const lru = new LRUList(); - lru.add(meta("a")); - lru.add(meta("b")); - lru.add(meta("c")); - expect(lru.orderedKeys()).toEqual(["c", "b", "a"]); - }); - - it("touch promotes to MRU and bumps access stats", () => { - const lru = new LRUList(); - lru.add(meta("a")); - lru.add(meta("b")); - const updated = lru.touch("a", 5000); - expect(lru.orderedKeys()).toEqual(["a", "b"]); - expect(updated?.accessCount).toBe(1); - expect(updated?.lastAccess).toBe(5000); - }); - - it("tracks size and byte usage", () => { - const lru = new LRUList(); - lru.add(meta("a", { size: 1000 })); - lru.add(meta("b", { size: 2000 })); - expect(lru.size).toBe(2); - expect(lru.byteSize).toBe(3000); - lru.remove("a"); - expect(lru.byteSize).toBe(2000); - }); - - it("replacing a key adjusts byte usage", () => { - const lru = new LRUList(); - lru.add(meta("a", { size: 1000 })); - lru.add(meta("a", { size: 4000 })); - expect(lru.size).toBe(1); - expect(lru.byteSize).toBe(4000); - }); -}); - -describe("LRUList eviction", () => { - it("evicts the lowest access_count / age ratio first", () => { - const now = 10_000; - const lru = new LRUList(); - lru.add(meta("hot", { fetchedAt: 0, accessCount: 100 })); // 0.01 - lru.add(meta("cold", { fetchedAt: 0, accessCount: 1 })); // 0.0001 - lru.add(meta("recent", { fetchedAt: 9000, accessCount: 1 })); // 0.001 - const order = lru.evictionCandidates(3, now).map((c) => c.key); - expect(order).toEqual(["cold", "recent", "hot"]); - }); - - it("evicts stale tiles before any fresh tile regardless of ratio", () => { - const now = TTL_MS.highZoom + 100_000; - const lru = new LRUList(); - lru.add(meta("fresh", { z: 14, fetchedAt: now - 1000, accessCount: 0 })); - lru.add( - meta("stale", { z: 16, fetchedAt: now - TTL_MS.highZoom - 1, accessCount: 999 }) - ); - expect(lru.evictionCandidates(1, now)[0].key).toBe("stale"); - }); - - it("evict removes entries and returns the keys", () => { - const now = 10_000; - const lru = new LRUList(); - lru.add(meta("a", { accessCount: 1, fetchedAt: 0 })); - lru.add(meta("b", { accessCount: 100, fetchedAt: 0 })); - const removed = lru.evict(now, 1); - expect(removed).toEqual(["a"]); - expect(lru.has("a")).toBe(false); - expect(lru.has("b")).toBe(true); - }); - - it("shouldEvict triggers at the threshold", () => { - const lru = new LRUList(); - lru.add(meta("a")); - lru.add(meta("b")); - expect(lru.shouldEvict(3)).toBe(false); - lru.add(meta("c")); - expect(lru.shouldEvict(3)).toBe(true); - }); -}); diff --git a/tests/unit/math.test.ts b/tests/unit/math.test.ts deleted file mode 100644 index c49620f..0000000 --- a/tests/unit/math.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { describe, it, expect } from "vitest"; -import BigNumber from "bignumber.js"; -import { - SCALE, - SCALE_FACTOR, - I128_MAX, - validateScale, - toContractUnits, - fromContractUnits, - addSafe, - subtractSafe, - multiplySafe, - divideSafe, - OverflowError, - ScaleValidationError, -} from "@/utils/math"; - -// Note: the issue specifies src/__tests__/math.test.ts, but the project's vitest -// config only picks up tests/**/*.test.{ts,tsx}; placed here so it runs in CI. - -describe("constants", () => { - it("SCALE is 7 and SCALE_FACTOR is 10^7", () => { - expect(SCALE).toBe(7); - expect(SCALE_FACTOR.toFixed(0)).toBe("10000000"); - }); -}); - -describe("validateScale", () => { - it("accepts up to 7 decimal places", () => { - expect(validateScale("1.2345678")).toEqual({ valid: true }); - expect(validateScale("0.0000001")).toEqual({ valid: true }); - expect(validateScale("-1.5")).toEqual({ valid: true }); - expect(validateScale("0")).toEqual({ valid: true }); - }); - - it("rejects more than 7 decimal places", () => { - const r = validateScale("1.23456789"); - expect(r.valid).toBe(false); - expect(r.reason).toMatch(/decimal places/); - }); - - it("rejects sub-1e-7 rates like 0.00000001 (the round-to-zero bug)", () => { - expect(validateScale("0.00000001").valid).toBe(false); - }); - - it("accepts exponential notation only for safe integers", () => { - expect(validateScale("1e7")).toEqual({ valid: true }); - expect(validateScale("1.5e3")).toEqual({ valid: true }); // 1500 - expect(validateScale("1.5e-3").valid).toBe(false); // 0.0015, not integer - expect(validateScale("1e20").valid).toBe(false); // not a safe integer - }); - - it("rejects an integer part beyond 10^12", () => { - expect(validateScale("1000000000000").valid).toBe(true); // exactly 10^12 - const r = validateScale("10000000000000"); // 10^13 - expect(r.valid).toBe(false); - expect(r.reason).toMatch(/integer part/); - }); - - it("rejects non-numbers and empty input", () => { - expect(validateScale("abc").valid).toBe(false); - expect(validateScale("").valid).toBe(false); - expect(validateScale(" ").valid).toBe(false); - }); -}); - -describe("toContractUnits", () => { - it("scales whole and fractional values", () => { - expect(toContractUnits("1")).toBe("10000000"); - expect(toContractUnits("0")).toBe("0"); - expect(toContractUnits("-1.5")).toBe("-15000000"); - expect(toContractUnits("0.0000001")).toBe("1"); - }); - - it("explicitly truncates beyond 7 decimals (no silent rounding up)", () => { - expect(toContractUnits("1.99999999")).toBe("19999999"); // truncated, not 20000000 - expect(toContractUnits("0.00000001")).toBe("0"); - }); - - it("strict mode rejects values with excess precision", () => { - expect(() => toContractUnits("1.23456789", { strict: true })).toThrow( - ScaleValidationError - ); - expect(() => toContractUnits("0.00000001", { strict: true })).toThrow(); - }); - - it("throws on i128 overflow", () => { - expect(() => toContractUnits("1e32")).toThrow(OverflowError); - }); - - it("throws on invalid numeric input", () => { - expect(() => toContractUnits("not-a-number")).toThrow(ScaleValidationError); - }); -}); - -describe("fromContractUnits", () => { - it("descales to a 7-decimal string", () => { - expect(fromContractUnits("10000000")).toBe("1.0000000"); - expect(fromContractUnits("0")).toBe("0.0000000"); - expect(fromContractUnits("-15000000")).toBe("-1.5000000"); - expect(fromContractUnits("1")).toBe("0.0000001"); - }); - - it("round-trips through toContractUnits", () => { - for (const v of ["3.1415926", "0.0000001", "123456.789", "-42.5"]) { - expect(fromContractUnits(toContractUnits(v))).toBe(new BigNumber(v).toFixed(7)); - } - }); - - it("handles the maximum i128 contract value", () => { - expect(() => fromContractUnits(I128_MAX.toFixed(0))).not.toThrow(); - }); -}); - -describe("safe arithmetic", () => { - it("adds, subtracts, multiplies and divides", () => { - expect(addSafe("1.5", "2.5")).toBe("4.0000000"); - expect(subtractSafe("5", "3")).toBe("2.0000000"); - expect(multiplySafe("2", "3")).toBe("6.0000000"); - expect(divideSafe("1", "4")).toBe("0.2500000"); - }); - - it("handles zero and negative operands", () => { - expect(addSafe("0", "0")).toBe("0.0000000"); - expect(subtractSafe("0", "5")).toBe("-5.0000000"); - expect(multiplySafe("-2", "3")).toBe("-6.0000000"); - }); - - it("throws on division by zero", () => { - expect(() => divideSafe("1", "0")).toThrow(OverflowError); - }); - - it("throws on overflow past i128", () => { - expect(() => addSafe("1e31", "1e31")).toThrow(OverflowError); - expect(() => multiplySafe("1e20", "1e20")).toThrow(OverflowError); - }); - - it("allows values right up to the i128 boundary", () => { - const maxDecimal = I128_MAX.div(SCALE_FACTOR).integerValue().toFixed(0); - expect(() => addSafe(maxDecimal, "0")).not.toThrow(); - }); -}); diff --git a/tests/unit/networkTopology.test.ts b/tests/unit/networkTopology.test.ts deleted file mode 100644 index 3c98c9f..0000000 --- a/tests/unit/networkTopology.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { parseGeoJsonNetwork } from "@/hooks/useNetworkTopology"; - -describe("parseGeoJsonNetwork", () => { - it("parses LineString features into edges with metadata", () => { - const edges = parseGeoJsonNetwork({ - type: "FeatureCollection", - features: [ - { - type: "Feature", - geometry: { type: "LineString", coordinates: [[0, 0], [1, 1]] }, - properties: { id: "pipe-1", flowDirection: "reverse", loadStatus: "overloaded" }, - }, - ], - }); - expect(edges).toHaveLength(1); - expect(edges[0]).toMatchObject({ - id: "pipe-1", - flowDirection: "reverse", - loadStatus: "overloaded", - }); - expect(edges[0].geometry).toEqual([[0, 0], [1, 1]]); - }); - - it("defaults missing/invalid properties", () => { - const [edge] = parseGeoJsonNetwork({ - type: "FeatureCollection", - features: [ - { - type: "Feature", - geometry: { type: "LineString", coordinates: [[0, 0], [1, 0]] }, - properties: { flowDirection: "sideways" as never }, - }, - ], - }); - expect(edge.id).toBe("edge-0"); - expect(edge.flowDirection).toBe("bidirectional"); - expect(edge.loadStatus).toBe("nominal"); - }); - - it("skips non-LineString and degenerate features", () => { - const edges = parseGeoJsonNetwork({ - type: "FeatureCollection", - features: [ - { - type: "Feature", - // @ts-expect-error — intentionally wrong geometry type - geometry: { type: "Point", coordinates: [0, 0] }, - properties: {}, - }, - { - type: "Feature", - geometry: { type: "LineString", coordinates: [[0, 0]] }, // too short - properties: {}, - }, - ], - }); - expect(edges).toHaveLength(0); - }); -}); diff --git a/tests/unit/notificationSlice.test.ts b/tests/unit/notificationSlice.test.ts deleted file mode 100644 index fd8e69b..0000000 --- a/tests/unit/notificationSlice.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { NotificationStore } from "@/store/slices/notificationSlice"; -import type { PushPayload } from "@/types/notification"; - -/** A controllable scheduler so coalescence timers can be driven deterministically. */ -function fakeScheduler() { - let nextId = 1; - let clock = 0; - const timers = new Map void }>(); - return { - now: () => clock, - setTimeoutFn: (cb: () => void, ms: number) => { - const id = nextId++; - timers.set(id, { fireAt: clock + ms, cb }); - return id as unknown as ReturnType; - }, - clearTimeoutFn: (id: ReturnType) => { - timers.delete(id as unknown as number); - }, - advance: (ms: number) => { - clock += ms; - for (const [id, t] of [...timers.entries()]) { - if (t.fireAt <= clock) { - timers.delete(id); - t.cb(); - } - } - }, - pending: () => timers.size, - }; -} - -function makeStore(sched: ReturnType) { - return new NotificationStore({ - setTimeoutFn: sched.setTimeoutFn, - clearTimeoutFn: sched.clearTimeoutFn, - now: sched.now, - window: 60_000, - }); -} - -function payload(topic: string, body: string, extra?: Partial): PushPayload { - return { topic, title: "Title", body, ...extra }; -} - -describe("NotificationStore coalescence", () => { - it("creates a notification with count 1", () => { - const sched = fakeScheduler(); - const store = makeStore(sched); - const key = store.receive(payload("meter.water.breach", "leak")); - const state = store.getState(); - expect(state).toHaveLength(1); - expect(state[0].id).toBe(key); - expect(state[0].count).toBe(1); - }); - - it("coalesces identical events into a single incremented entry", () => { - const sched = fakeScheduler(); - const store = makeStore(sched); - store.receive(payload("meter.water.breach", "leak")); - store.receive(payload("meter.water.breach", "leak")); - store.receive(payload("meter.water.breach", "leak")); - const state = store.getState(); - expect(state).toHaveLength(1); - expect(state[0].count).toBe(3); - }); - - it("keeps distinct keys separate", () => { - const sched = fakeScheduler(); - const store = makeStore(sched); - store.receive(payload("meter.water.breach", "leak")); - store.receive(payload("contract.execution.reverted", "tx failed")); - expect(store.getState()).toHaveLength(2); - }); - - it("auto-dismisses after the coalescence window", () => { - const sched = fakeScheduler(); - const store = makeStore(sched); - store.receive(payload("system.health.cpu", "spike")); - expect(store.getState()).toHaveLength(1); - sched.advance(60_000); - expect(store.getState()).toHaveLength(0); - }); - - it("extends the window on each repeat occurrence", () => { - const sched = fakeScheduler(); - const store = makeStore(sched); - store.receive(payload("system.health.cpu", "spike")); - sched.advance(40_000); // not yet expired - store.receive(payload("system.health.cpu", "spike")); // resets the 60s timer - sched.advance(40_000); // 80s since first, but only 40s since last - expect(store.getState()).toHaveLength(1); - expect(store.getState()[0].count).toBe(2); - sched.advance(20_000); // now 60s since last - expect(store.getState()).toHaveLength(0); - }); - - it("dismiss removes the entry and cancels its timer", () => { - const sched = fakeScheduler(); - const store = makeStore(sched); - const key = store.receive(payload("meter.water.breach", "leak")); - store.dismiss(key); - expect(store.getState()).toHaveLength(0); - expect(sched.pending()).toBe(0); - }); - - it("caps actions at two", () => { - const sched = fakeScheduler(); - const store = makeStore(sched); - store.receive( - payload("meter.water.breach", "leak", { - actions: [ - { action: "a", title: "A" }, - { action: "b", title: "B" }, - { action: "c", title: "C" }, - ], - }) - ); - expect(store.getState()[0].actions).toHaveLength(2); - }); - - it("returns a stable snapshot reference when unchanged", () => { - const sched = fakeScheduler(); - const store = makeStore(sched); - const a = store.getState(); - const b = store.getState(); - expect(a).toBe(b); - }); - - it("clear() empties the queue and cancels timers", () => { - const sched = fakeScheduler(); - const store = makeStore(sched); - store.receive(payload("meter.water.breach", "leak")); - store.receive(payload("system.health.cpu", "spike")); - store.clear(); - expect(store.getState()).toHaveLength(0); - expect(sched.pending()).toBe(0); - }); -}); diff --git a/tests/unit/porMerkle.test.ts b/tests/unit/porMerkle.test.ts deleted file mode 100644 index 40de421..0000000 --- a/tests/unit/porMerkle.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - buildTree, - computeRoot, - generateProof, - verifyProof, - leafHash, - sha256, - u64le, - bytesToHex, - hexToBytes, -} from "@/utils/porMerkle"; - -async function leaves(n: number) { - const out = []; - for (let i = 0; i < n; i++) { - out.push( - await leafHash({ meterId: `meter-${i}`, consumption: i * 100, salt: "0xdead" }) - ); - } - return out; -} - -describe("hex / u64le helpers", () => { - it("round-trips hex", () => { - const bytes = new Uint8Array([0, 255, 16, 1]); - expect(hexToBytes(bytesToHex(bytes))).toEqual(bytes); - expect(hexToBytes("0xff00")).toEqual(new Uint8Array([255, 0])); - }); - - it("encodes u64 little-endian", () => { - expect(Array.from(u64le(1))).toEqual([1, 0, 0, 0, 0, 0, 0, 0]); - expect(Array.from(u64le(256))).toEqual([0, 1, 0, 0, 0, 0, 0, 0]); - }); - - it("rejects negative consumption", () => { - expect(() => u64le(-1)).toThrow(); - }); -}); - -describe("leafHash", () => { - it("is deterministic and 32 bytes", async () => { - const a = await leafHash({ meterId: "m1", consumption: 10, salt: "0x01" }); - const b = await leafHash({ meterId: "m1", consumption: 10, salt: "0x01" }); - expect(a).toEqual(b); - expect(a.length).toBe(32); - }); - - it("changes when any field changes", async () => { - const base = await leafHash({ meterId: "m1", consumption: 10, salt: "0x01" }); - const diff = await leafHash({ meterId: "m1", consumption: 11, salt: "0x01" }); - expect(bytesToHex(base)).not.toBe(bytesToHex(diff)); - }); -}); - -describe("buildTree / computeRoot", () => { - it("a single leaf is its own root", async () => { - const [leaf] = await leaves(1); - const { root } = await buildTree([leaf]); - expect(bytesToHex(root)).toBe(bytesToHex(leaf)); - }); - - it("is deterministic across builds", async () => { - const l = await leaves(5); - expect(await computeRoot(l)).toBe(await computeRoot(l.slice())); - }); - - it("a two-leaf root equals SHA-256(left||right)", async () => { - const l = await leaves(2); - const expected = await sha256( - new Uint8Array([...l[0], ...l[1]]) - ); - const { root } = await buildTree(l); - expect(bytesToHex(root)).toBe(bytesToHex(expected)); - }); - - it("empty input yields a zero root", async () => { - const { root } = await buildTree([]); - expect(bytesToHex(root)).toBe("00".repeat(32)); - }); -}); - -describe("generateProof / verifyProof", () => { - it("verifies an inclusion path for every leaf (balanced tree)", async () => { - const l = await leaves(8); - const tree = await buildTree(l); - for (let i = 0; i < l.length; i++) { - const proof = generateProof(tree, i); - expect(await verifyProof(tree.root, l[i], proof)).toBe(true); - } - }); - - it("verifies with an odd number of leaves (padded layer)", async () => { - const l = await leaves(5); - const tree = await buildTree(l); - for (let i = 0; i < l.length; i++) { - const proof = generateProof(tree, i); - expect(await verifyProof(tree.root, l[i], proof)).toBe(true); - } - }); - - it("rejects a tampered leaf", async () => { - const l = await leaves(8); - const tree = await buildTree(l); - const proof = generateProof(tree, 3); - const forged = await leafHash({ meterId: "evil", consumption: 0, salt: "0x00" }); - expect(await verifyProof(tree.root, forged, proof)).toBe(false); - }); - - it("rejects an out-of-range index", async () => { - const tree = await buildTree(await leaves(4)); - expect(() => generateProof(tree, 4)).toThrow(/out of range/); - }); -}); diff --git a/tests/unit/proofOfReserve.test.ts b/tests/unit/proofOfReserve.test.ts deleted file mode 100644 index 4872d65..0000000 --- a/tests/unit/proofOfReserve.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { - aggregateAudit, - checkSolvency, - buildProofInputs, - generateRandomness, - attestationHashOf, - runProofOfReserve, - InsolvencyError, - type ProofOfReserveDeps, -} from "@/services/proofOfReserve"; -import type { - AuditInventory, - OnChainCommitment, - RangeProof, -} from "@/types/reserve"; - -const audit = (totals: string[]): AuditInventory => ({ - entries: totals.map((total, i) => ({ resourceClass: `r${i}`, total })), - serverTimestamp: 1_700_000_000, - signature: "0xsig", -}); - -const commitment = (liability: string): OnChainCommitment => ({ - merkleRoot: "0x" + "ab".repeat(32), - totalLiability: liability, - lastAuditLedger: 42, -}); - -const fakeProof: RangeProof = { - proof: "0x" + "11".repeat(32), - commitment: "0x" + "22".repeat(32), - challenge: "0x" + "33".repeat(32), -}; - -describe("aggregateAudit", () => { - it("sums entry totals as bigints", () => { - expect(aggregateAudit(audit(["100", "250", "650"]))).toBe(BigInt(1000)); - }); - - it("handles values beyond Number.MAX_SAFE_INTEGER", () => { - expect(aggregateAudit(audit(["9007199254740993", "1"]))).toBe( - BigInt("9007199254740994") - ); - }); -}); - -describe("checkSolvency", () => { - it("returns null when reserves cover liability", () => { - expect(checkSolvency(BigInt(1000), BigInt(1000))).toBeNull(); - expect(checkSolvency(BigInt(1000), BigInt(1500))).toBeNull(); - }); - - it("reports a shortfall when reserves fall short", () => { - const report = checkSolvency(BigInt(1000), BigInt(600)); - expect(report).toEqual({ - liability: "1000", - auditTotal: "600", - shortfall: "-400", - }); - }); -}); - -describe("buildProofInputs", () => { - it("computes the surplus and carries the root", () => { - const inputs = buildProofInputs(commitment("1000"), BigInt(1500)); - expect(inputs.surplus).toBe("500"); - expect(inputs.totalLiability).toBe("1000"); - expect(inputs.auditTotal).toBe("1500"); - expect(inputs.merkleRoot).toBe(commitment("1000").merkleRoot); - expect(inputs.randomness).toMatch(/^0x[0-9a-f]{64}$/); - }); - - it("throws when the surplus exceeds the 64-bit range", () => { - const huge = (BigInt(1) << BigInt(65)).toString(); - expect(() => buildProofInputs(commitment("0"), BigInt(huge))).toThrow( - /64-bit range/ - ); - }); -}); - -describe("generateRandomness / attestationHashOf", () => { - it("produces a 32-byte hex blinding factor", () => { - expect(generateRandomness()).toMatch(/^0x[0-9a-f]{64}$/); - }); - - it("hashes the proof bytes to a 32-byte attestation hash", async () => { - const hash = await attestationHashOf(fakeProof); - expect(hash).toMatch(/^0x[0-9a-f]{64}$/); - }); -}); - -describe("runProofOfReserve", () => { - const baseDeps = (over: Partial = {}): ProofOfReserveDeps => ({ - fetchCommitment: vi.fn().mockResolvedValue(commitment("1000")), - fetchAudit: vi.fn().mockResolvedValue(audit(["700", "500"])), // 1200 ≥ 1000 - prove: vi.fn().mockResolvedValue(fakeProof), - ...over, - }); - - it("runs the full flow and reports progress in order", async () => { - const progress: Array<[string, number]> = []; - const submitAttestation = vi.fn().mockResolvedValue({ ledger: 99 }); - - const outcome = await runProofOfReserve( - { contractId: "C1", from: 0, to: 10 }, - baseDeps({ submitAttestation }), - (phase, pct) => progress.push([phase, pct]) - ); - - expect(outcome.auditTotal).toBe("1200"); - expect(outcome.result.ledger).toBe(99); - expect(outcome.result.attestationHash).toMatch(/^0x[0-9a-f]{64}$/); - expect(submitAttestation).toHaveBeenCalled(); - - const phases = progress.map((p) => p[0]); - expect(phases).toEqual([ - "fetching", - "fetching", - "proving", - "submitting", - "confirmed", - ]); - expect(progress[progress.length - 1][1]).toBe(100); - }); - - it("aborts with InsolvencyError when reserves fall short", async () => { - await expect( - runProofOfReserve( - { contractId: "C1", from: 0, to: 10 }, - baseDeps({ fetchAudit: vi.fn().mockResolvedValue(audit(["100"])) }) - ) - ).rejects.toBeInstanceOf(InsolvencyError); - }); - - it("does not invoke the prover on insolvency", async () => { - const prove = vi.fn().mockResolvedValue(fakeProof); - await runProofOfReserve( - { contractId: "C1", from: 0, to: 10 }, - baseDeps({ fetchAudit: vi.fn().mockResolvedValue(audit(["10"])), prove }) - ).catch(() => {}); - expect(prove).not.toHaveBeenCalled(); - }); - - it("produces a proof with no submission when submitAttestation is absent", async () => { - const outcome = await runProofOfReserve( - { contractId: "C1", from: 0, to: 10 }, - baseDeps() - ); - expect(outcome.result.ledger).toBeNull(); - expect(outcome.proof).toEqual(fakeProof); - }); -}); diff --git a/tests/unit/quadTree.test.ts b/tests/unit/quadTree.test.ts deleted file mode 100644 index 6af4f07..0000000 --- a/tests/unit/quadTree.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { QuadTree, getLODLevel } from "@/components/map/lod/QuadTree"; -import { LODLevel, type AssetInstance, type FrustumPlane } from "@/types/spatial"; - -let counter = 0; -function asset(x: number, y: number, z = 0): AssetInstance { - return { id: `a${counter++}`, type: "meter", position: { x, y, z } }; -} - -/** Empty plane set → every node passes the frustum test (isolates LOD logic). */ -const ACCEPT_ALL: FrustumPlane[] = []; - -describe("getLODLevel", () => { - it("buckets by distance", () => { - expect(getLODLevel(0)).toBe(LODLevel.Full); - expect(getLODLevel(99)).toBe(LODLevel.Full); - expect(getLODLevel(100)).toBe(LODLevel.Simplified); - expect(getLODLevel(299)).toBe(LODLevel.Simplified); - expect(getLODLevel(300)).toBe(LODLevel.Impostor); - expect(getLODLevel(499)).toBe(LODLevel.Impostor); - expect(getLODLevel(500)).toBe(LODLevel.Culled); - }); - - it("honours degraded distances", () => { - const degraded = { full: 50, simplified: 200, impostor: 500 }; - expect(getLODLevel(60, degraded)).toBe(LODLevel.Simplified); - }); -}); - -describe("QuadTree insert", () => { - it("tracks size", () => { - const tree = new QuadTree(); - tree.insert(asset(0, 0)); - tree.insertAll([asset(10, 10), asset(-20, -20)]); - expect(tree.size).toBe(3); - }); - - it("subdivides past leaf capacity without losing assets", () => { - const tree = new QuadTree(); - for (let i = 0; i < 100; i++) tree.insert(asset(i, i)); - const visible = tree.queryFrustum(ACCEPT_ALL, { - cameraPosition: { x: 50, y: 50, z: 0 }, - lodDistances: { full: 1e9, simplified: 1e9, impostor: 1e9 }, - }); - expect(visible).toHaveLength(100); - }); -}); - -describe("QuadTree.queryFrustum LOD", () => { - it("tags assets by camera distance and culls beyond 500 m", () => { - const tree = new QuadTree(); - tree.insert(asset(50, 0)); // 50 m → Full - tree.insert(asset(200, 0)); // 200 m → Simplified - tree.insert(asset(400, 0)); // 400 m → Impostor - tree.insert(asset(600, 0)); // 600 m → culled (excluded) - - const visible = tree.queryFrustum(ACCEPT_ALL, { - cameraPosition: { x: 0, y: 0, z: 0 }, - }); - const byDist = visible.sort((a, b) => a.distance - b.distance); - expect(byDist.map((v) => v.lod)).toEqual([ - LODLevel.Full, - LODLevel.Simplified, - LODLevel.Impostor, - ]); - }); - - it("excludes assets outside the frustum", () => { - const tree = new QuadTree(); - tree.insert(asset(100, 100)); - // Plane: x - 1e9 >= 0 → everything is outside. - const reject: FrustumPlane[] = [{ a: 1, b: 0, c: 0, d: -1e9 }]; - expect( - tree.queryFrustum(reject, { cameraPosition: { x: 0, y: 0, z: 0 } }) - ).toHaveLength(0); - }); -}); - -describe("QuadTree batch culling", () => { - it("keeps the nearest cullTarget when over the cap", () => { - const tree = new QuadTree(); - for (let i = 1; i <= 10; i++) tree.insert(asset(i, 0)); // 1..10 m away - const visible = tree.queryFrustum(ACCEPT_ALL, { - cameraPosition: { x: 0, y: 0, z: 0 }, - maxAssets: 5, - cullTarget: 3, - }); - expect(visible).toHaveLength(3); - // The three nearest (1, 2, 3 m) survive. - expect(visible.every((v) => v.distance <= 3)).toBe(true); - }); -}); - -describe("QuadTree clear", () => { - it("resets and re-inserts correctly (no stale extent)", () => { - const tree = new QuadTree(); - tree.insert(asset(40000, 40000)); - tree.clear(); - expect(tree.size).toBe(0); - tree.insert(asset(10, 10)); - const visible = tree.queryFrustum(ACCEPT_ALL, { - cameraPosition: { x: 10, y: 10, z: 0 }, - }); - expect(visible).toHaveLength(1); - expect(visible[0].lod).toBe(LODLevel.Full); - }); -}); diff --git a/tests/unit/reconnectMachine.test.ts b/tests/unit/reconnectMachine.test.ts deleted file mode 100644 index 1407a50..0000000 --- a/tests/unit/reconnectMachine.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { ReconnectMachine } from "@/services/reconnectMachine"; - -/** Deterministic machine: jitter rng pinned to 0.5. */ -function machine() { - return new ReconnectMachine({ rng: () => 0.5 }); -} - -describe("ReconnectMachine", () => { - it("connects from idle and reaches connected on first open", () => { - const m = machine(); - m.send({ type: "CONNECT" }); - expect(m.status).toBe("connecting"); - m.send({ type: "CONNECTED" }); - expect(m.status).toBe("connected"); - expect(m.getContext().attempt).toBe(0); - }); - - it("enters reconnecting with a jittered delay on disconnect", () => { - const m = machine(); - m.send({ type: "CONNECT" }); - m.send({ type: "CONNECTED" }); - m.send({ type: "DISCONNECTED" }); - const ctx = m.getContext(); - expect(ctx.status).toBe("reconnecting"); - expect(ctx.attempt).toBe(1); - // ceiling(0) = 500, rng 0.5 → 250 - expect(ctx.nextDelayMs).toBe(250); - }); - - it("RETRY moves reconnecting → connecting", () => { - const m = machine(); - m.send({ type: "CONNECT" }); - m.send({ type: "CONNECTED" }); - m.send({ type: "DISCONNECTED" }); - m.send({ type: "RETRY" }); - expect(m.status).toBe("connecting"); - expect(m.getContext().nextDelayMs).toBeNull(); - }); - - it("re-open after an outage routes through recovering, not connected", () => { - const m = machine(); - m.send({ type: "CONNECT" }); - m.send({ type: "CONNECTED" }); - m.send({ type: "DISCONNECTED" }); // attempt 1 - m.send({ type: "RETRY" }); - m.send({ type: "CONNECTED" }); - expect(m.status).toBe("recovering"); - m.send({ type: "RECOVERY_SUCCESS" }); - expect(m.status).toBe("connected"); - expect(m.getContext().attempt).toBe(0); - }); - - it("backoff ceiling grows with each consecutive failed attempt", () => { - const m = machine(); - m.send({ type: "CONNECT" }); - m.send({ type: "CONNECTED" }); - const delays: number[] = []; - for (let i = 0; i < 4; i++) { - m.send({ type: "DISCONNECTED" }); - delays.push(m.getContext().nextDelayMs!); - m.send({ type: "RETRY" }); - } - // rng 0.5 × ceiling(0..3) = 0.5 × [500,1000,2000,4000] - expect(delays).toEqual([250, 500, 1000, 2000]); - }); - - it("declares terminal failure after the max attempts", () => { - const m = new ReconnectMachine({ rng: () => 0.5, maxAttempts: 3 }); - m.send({ type: "CONNECT" }); - m.send({ type: "CONNECTED" }); - // 3 allowed attempts, the 4th tips into failed. - for (let i = 0; i < 3; i++) { - m.send({ type: "DISCONNECTED" }); - expect(m.status).toBe("reconnecting"); - m.send({ type: "RETRY" }); - } - m.send({ type: "DISCONNECTED" }); - expect(m.status).toBe("failed"); - expect(m.isTerminal).toBe(true); - expect(m.getContext().lastError).toMatch(/after 3 attempts/); - }); - - it("a failed machine can be manually reconnected", () => { - const m = new ReconnectMachine({ rng: () => 0.5, maxAttempts: 1 }); - m.send({ type: "CONNECT" }); - m.send({ type: "CONNECTED" }); - m.send({ type: "DISCONNECTED" }); // attempt 1 - m.send({ type: "RETRY" }); - m.send({ type: "DISCONNECTED" }); // attempt 2 > max → failed - expect(m.status).toBe("failed"); - m.send({ type: "CONNECT" }); - expect(m.status).toBe("connecting"); - expect(m.getContext().attempt).toBe(0); - }); - - it("heartbeat timeout triggers a reconnect", () => { - const m = machine(); - m.send({ type: "CONNECT" }); - m.send({ type: "CONNECTED" }); - const count = m.recordMissedHeartbeat(); - expect(count).toBe(1); - m.send({ type: "HEARTBEAT_TIMEOUT" }); - expect(m.status).toBe("reconnecting"); - expect(m.getContext().lastError).toMatch(/Heartbeat/); - }); - - it("RESET returns to idle from any state", () => { - const m = machine(); - m.send({ type: "CONNECT" }); - m.send({ type: "CONNECTED" }); - m.send({ type: "DISCONNECTED" }); - m.send({ type: "RESET" }); - expect(m.status).toBe("idle"); - expect(m.getContext().attempt).toBe(0); - }); - - it("notifies subscribers on transitions", () => { - const m = machine(); - const seen: string[] = []; - m.subscribe((ctx) => seen.push(ctx.status)); - m.send({ type: "CONNECT" }); - m.send({ type: "CONNECTED" }); - expect(seen).toEqual(["connecting", "connected"]); - }); -}); diff --git a/tests/unit/resourceMath.test.ts b/tests/unit/resourceMath.test.ts deleted file mode 100644 index 15074a1..0000000 --- a/tests/unit/resourceMath.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { - aggregateReadings, - perResourceUnits, - relativeError, - PRECISION_THRESHOLD, - MAX_READINGS_PER_WINDOW, - type Reading, -} from "@/utils/aggregation/resourceMath"; -import { - RESOURCE_FACTORS, - RESOURCE_TYPES, - type ResourceType, -} from "@/utils/aggregation/unitConversion"; - -describe("relativeError", () => { - it("is 0 when both are zero", () => { - expect(relativeError(0, BigInt(0))).toBe(0); - }); - it("is Infinity when exact is zero but approx is not", () => { - expect(relativeError(5, BigInt(0))).toBe(Infinity); - }); - it("computes the magnitude of the relative gap", () => { - expect(relativeError(110, BigInt(100))).toBeCloseTo(0.1, 12); - }); -}); - -describe("aggregateReadings", () => { - it("sums exactly across resources", () => { - const readings: Reading[] = [ - { resource: "water", value: 5 }, // 5000 - { resource: "energy", value: 2 }, // 7,200,000 - { resource: "bandwidth", value: 1 }, // 1,000,000,000 - ]; - const result = aggregateReadings(readings); - expect(result.exactBase).toBe(BigInt(1_007_205_000)); - expect(result.total.toBaseUnits()).toBe(BigInt(1_007_205_000)); - expect(result.byResource.energy).toBe(BigInt(7_200_000)); - expect(result.readingCount).toBe(3); - }); - - it("handles an empty window without warning", () => { - const logger = { warn: vi.fn() }; - const result = aggregateReadings([], { logger }); - expect(result.exactBase).toBe(BigInt(0)); - expect(result.relativeError).toBe(0); - expect(logger.warn).not.toHaveBeenCalled(); - }); - - it("does not warn for well-formed integer readings", () => { - const logger = { warn: vi.fn() }; - aggregateReadings( - [ - { resource: "energy", value: BigInt("999999999999") }, - { resource: "water", value: 12345 }, - ], - { logger } - ); - expect(logger.warn).not.toHaveBeenCalled(); - }); - - it("warns when the window exceeds the reading cap", () => { - const logger = { warn: vi.fn() }; - const readings: Reading[] = Array.from( - { length: MAX_READINGS_PER_WINDOW + 1 }, - () => ({ resource: "water" as ResourceType, value: 1 }) - ); - aggregateReadings(readings, { logger }); - expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining("exceeding") - ); - }); - - it("perResourceUnits wraps each subtotal exactly", () => { - const result = aggregateReadings([{ resource: "energy", value: 2 }]); - expect(perResourceUnits(result).energy.toBaseUnits()).toBe(BigInt(7_200_000)); - }); -}); - -describe("property: 10,000 random readings stay within 1e-5 relative error", () => { - it("keeps the displayed total within tolerance of the exact total", () => { - // Deterministic LCG so the property test is reproducible. - let seed = 987654321; - const rand = () => { - seed = (seed * 1103515245 + 12345) & 0x7fffffff; - return seed / 0x7fffffff; - }; - - const readings: Reading[] = []; - let independentExact = BigInt(0); - for (let i = 0; i < 10_000; i++) { - const resource = RESOURCE_TYPES[Math.floor(rand() * RESOURCE_TYPES.length)]; - const value = BigInt(Math.floor(rand() * 1e15)); // [0, 10^15] - readings.push({ resource, value }); - independentExact += value * RESOURCE_FACTORS[resource]; - } - - const result = aggregateReadings(readings); - - // The BigInt pipeline matches an independent exact computation. - expect(result.exactBase).toBe(independentExact); - - // The single lossy Number conversion at display stays well within tolerance. - const trueVal = result.exactBase; - const displayedInt = BigInt(Math.round(result.total.toNumber())); - const diff = displayedInt > trueVal ? displayedInt - trueVal : trueVal - displayedInt; - const relErr = trueVal === BigInt(0) ? 0 : Number(diff) / Number(trueVal); - - expect(relErr).toBeLessThan(PRECISION_THRESHOLD); - }); -}); diff --git a/tests/unit/resourceSlice.test.ts b/tests/unit/resourceSlice.test.ts deleted file mode 100644 index 3f16643..0000000 --- a/tests/unit/resourceSlice.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -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); - }); -}); diff --git a/tests/unit/resourceUnits.test.ts b/tests/unit/resourceUnits.test.ts deleted file mode 100644 index 47036f0..0000000 --- a/tests/unit/resourceUnits.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { ResourceUnits } from "@/utils/aggregation/ResourceUnits"; - -describe("ResourceUnits", () => { - it("wraps and unwraps exact base units", () => { - const u = ResourceUnits.fromBaseUnits(BigInt(5)); - expect(u.toBaseUnits()).toBe(BigInt(5)); - expect(u.toNumber()).toBe(5); - }); - - it("adds exactly, even beyond Number.MAX_SAFE_INTEGER", () => { - const a = ResourceUnits.fromBaseUnits(BigInt("999999999999999999999")); - const b = ResourceUnits.fromBaseUnits(BigInt(1)); - expect(a.add(b).toBaseUnits()).toBe(BigInt("1000000000000000000000")); - }); - - it("multiplies by an integer factor exactly", () => { - const u = ResourceUnits.fromBaseUnits(BigInt("1000000000000")); // 1e12 - expect(u.multiply(BigInt(3_600_000)).toBaseUnits()).toBe( - BigInt("3600000000000000000") - ); - }); - - it("rejects a non-integer multiply factor", () => { - expect(() => ResourceUnits.fromBaseUnits(BigInt(1)).multiply(1.5)).toThrow(); - }); - - it("aligns scales when adding", () => { - const a = new ResourceUnits(BigInt(150), 2); // 1.50 - const b = new ResourceUnits(BigInt(2500), 3); // 2.500 - const sum = a.add(b); // 4.000 - expect(sum.scale).toBe(3); - expect(sum.toNumber()).toBeCloseTo(4, 10); - }); - - it("preserves fractional precision at large magnitude in toNumber", () => { - // 1e18 base units + 0.25 (at scale 2) → 1e18 + 0.25 - const u = new ResourceUnits(BigInt("100000000000000000025"), 2); - expect(u.toNumber()).toBeCloseTo(1e18 + 0.25, 0); - }); - - it("rejects an invalid scale", () => { - expect(() => new ResourceUnits(BigInt(0), -1)).toThrow(); - expect(() => new ResourceUnits(BigInt(0), 1.5)).toThrow(); - }); - - it("compares for equality across scales", () => { - expect( - ResourceUnits.fromBaseUnits(BigInt(5), 0).equals( - new ResourceUnits(BigInt(500), 2) - ) - ).toBe(true); - }); - - it("formats with 2–4 fraction digits", () => { - const s = ResourceUnits.fromBaseUnits(BigInt(7)).toDisplay(4, 2); - expect(s).toMatch(/7[.,]00/); - }); -}); diff --git a/tests/unit/sanitize.test.ts b/tests/unit/sanitize.test.ts deleted file mode 100644 index 82d6702..0000000 --- a/tests/unit/sanitize.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { sanitize, validateLabel } from "@/utils/sanitize"; - -describe("sanitize()", () => { - it("strips < and > characters", () => { - expect(sanitize("")).toBe("scriptalert(1)/script"); - }); - - it("removes javascript: URI scheme (case-insensitive)", () => { - expect(sanitize("javascript:alert(1)")).toBe("alert(1)"); - expect(sanitize("JAVASCRIPT:alert(1)")).toBe("alert(1)"); - expect(sanitize("JavaScript:void(0)")).toBe("void(0)"); - }); - - it("strips onerror= and other on* event handlers", () => { - expect(sanitize("img src=x onerror=alert(1)")).toBe("img src=x alert(1)"); - expect(sanitize("onclick=doSomething()")).toBe("doSomething()"); - expect(sanitize("onmouseover=steal()")).toBe("steal()"); - }); - - it("leaves safe strings unchanged", () => { - expect(sanitize("Sensor A")).toBe("Sensor A"); - expect(sanitize("Floor 3 - Room 12")).toBe("Floor 3 - Room 12"); - expect(sanitize(" trim me ")).toBe("trim me"); - }); - - it("handles empty string", () => { - expect(sanitize("")).toBe(""); - }); - - it("handles combined payloads", () => { - const payload = ''; - const result = sanitize(payload); - expect(result).not.toContain("<"); - expect(result).not.toContain(">"); - expect(result).not.toContain("onerror="); - }); - - it("removes javascript: even when embedded in larger string", () => { - const payload = "click here: javascript:void(0)"; - expect(sanitize(payload)).not.toContain("javascript:"); - }); -}); - -describe("validateLabel()", () => { - it("truncates to 64 characters", () => { - const long = "a".repeat(100); - expect(validateLabel(long).length).toBe(64); - }); - - it("leaves short labels unchanged", () => { - expect(validateLabel("My Device")).toBe("My Device"); - }); - - it("sanitizes before truncating", () => { - const payload = "