diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 2275b3e2..db7c07fa 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -610,6 +610,51 @@ "errorIcon": "Error icon — theme failed to apply" } }, + "networkStatus": { + "label": "Network Status", + "ariaLabel": "Stellar network status indicator", + "ariaLive": "Network status: {status}", + "statusOperational": "Operational", + "statusDegraded": "Degraded", + "statusPartialOutage": "Partial Outage", + "statusMajorOutage": "Major Outage", + "statusUnknown": "Unknown", + "statusChecking": "Checking…", + "statusOffline": "Offline", + "loading": "Checking network status…", + "checking": "Rechecking…", + "lastChecked": "Last checked {time}", + "lastCheckedNever": "Not yet checked", + "checkNow": "Check now", + "retry": "Retry", + "retryCount": "Retry {count}", + "dismiss": "Dismiss", + "errorHeading": "Status check failed", + "errorMessage": "Could not reach the Stellar network. Check your connection.", + "networkError": "Network error — retrying…", + "latency": "{ms}ms", + "latencyLabel": "Response time: {ms}ms", + "ledger": "Ledger {sequence}", + "ledgerLabel": "Current ledger: {sequence}", + "horizon": "Horizon", + "stellar": "Stellar", + "mainnet": "Mainnet", + "testnet": "Testnet", + "networkLabel": "{network} network", + "expandPanel": "Show network details", + "collapsePanel": "Hide network details", + "detailsHeading": "Network Details", + "horizonUrl": "Horizon URL", + "tooltipOperational": "All systems are operating normally.", + "tooltipDegraded": "Some services are experiencing issues.", + "tooltipPartialOutage": "Some services are unavailable.", + "tooltipMajorOutage": "Critical services are unavailable.", + "tooltipOffline": "Could not connect to the network.", + "tooltipChecking": "Verifying network connectivity…", + "pollingPaused": "Status polling paused", + "pollingActive": "Status polling active", + "autoRefresh": "Auto-refresh every {seconds}s" + }, "portfolioChartWidget": { "title": "Portfolio Value", "allocation": "Allocation", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 55f15553..f0b62327 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -610,6 +610,51 @@ "errorIcon": "Icono de error — no se pudo aplicar el tema" } }, + "networkStatus": { + "label": "Estado de Red", + "ariaLabel": "Indicador de estado de la red Stellar", + "ariaLive": "Estado de red: {status}", + "statusOperational": "Operativa", + "statusDegraded": "Degradada", + "statusPartialOutage": "Interrupcion Parcial", + "statusMajorOutage": "Interrupcion Mayor", + "statusUnknown": "Desconocido", + "statusChecking": "Verificando…", + "statusOffline": "Sin conexion", + "loading": "Verificando estado de la red…", + "checking": "Reverificando…", + "lastChecked": "Ultima verificacion: {time}", + "lastCheckedNever": "Aun no verificado", + "checkNow": "Verificar ahora", + "retry": "Reintentar", + "retryCount": "Intento {count}", + "dismiss": "Descartar", + "errorHeading": "Error al verificar el estado", + "errorMessage": "No se pudo acceder a la red Stellar. Verifica tu conexion.", + "networkError": "Error de red — reintentando…", + "latency": "{ms}ms", + "latencyLabel": "Tiempo de respuesta: {ms}ms", + "ledger": "Ledger {sequence}", + "ledgerLabel": "Ledger actual: {sequence}", + "horizon": "Horizon", + "stellar": "Stellar", + "mainnet": "Mainnet", + "testnet": "Testnet", + "networkLabel": "Red {network}", + "expandPanel": "Mostrar detalles de red", + "collapsePanel": "Ocultar detalles de red", + "detailsHeading": "Detalles de red", + "horizonUrl": "URL de Horizon", + "tooltipOperational": "Todos los servicios funcionan con normalidad.", + "tooltipDegraded": "Algunos servicios tienen problemas.", + "tooltipPartialOutage": "Algunos servicios no estan disponibles.", + "tooltipMajorOutage": "Los servicios criticos no estan disponibles.", + "tooltipOffline": "No se pudo conectar a la red.", + "tooltipChecking": "Verificando conectividad de red…", + "pollingPaused": "Actualizacion automatica pausada", + "pollingActive": "Actualizacion automatica activa", + "autoRefresh": "Actualizacion automatica cada {seconds}s" + }, "portfolioChartWidget": { "title": "Valor del portafolio", "allocation": "Distribucion", diff --git a/frontend/messages/pt.json b/frontend/messages/pt.json index 875b31e6..2c04d2da 100644 --- a/frontend/messages/pt.json +++ b/frontend/messages/pt.json @@ -610,6 +610,51 @@ "errorIcon": "Icone de erro — falha ao aplicar o tema" } }, + "networkStatus": { + "label": "Status da Rede", + "ariaLabel": "Indicador de status da rede Stellar", + "ariaLive": "Status da rede: {status}", + "statusOperational": "Operacional", + "statusDegraded": "Degradada", + "statusPartialOutage": "Interrupcao Parcial", + "statusMajorOutage": "Interrupcao Grave", + "statusUnknown": "Desconhecido", + "statusChecking": "Verificando…", + "statusOffline": "Sem conexao", + "loading": "Verificando status da rede…", + "checking": "Reverificando…", + "lastChecked": "Ultima verificacao: {time}", + "lastCheckedNever": "Ainda nao verificado", + "checkNow": "Verificar agora", + "retry": "Tentar novamente", + "retryCount": "Tentativa {count}", + "dismiss": "Fechar", + "errorHeading": "Falha na verificacao de status", + "errorMessage": "Nao foi possivel acessar a rede Stellar. Verifique sua conexao.", + "networkError": "Erro de rede — tentando novamente…", + "latency": "{ms}ms", + "latencyLabel": "Tempo de resposta: {ms}ms", + "ledger": "Ledger {sequence}", + "ledgerLabel": "Ledger atual: {sequence}", + "horizon": "Horizon", + "stellar": "Stellar", + "mainnet": "Mainnet", + "testnet": "Testnet", + "networkLabel": "Rede {network}", + "expandPanel": "Mostrar detalhes da rede", + "collapsePanel": "Ocultar detalhes da rede", + "detailsHeading": "Detalhes da rede", + "horizonUrl": "URL do Horizon", + "tooltipOperational": "Todos os servicos estao funcionando normalmente.", + "tooltipDegraded": "Alguns servicos estao com problemas.", + "tooltipPartialOutage": "Alguns servicos estao indisponiveis.", + "tooltipMajorOutage": "Servicos criticos estao indisponiveis.", + "tooltipOffline": "Nao foi possivel conectar-se a rede.", + "tooltipChecking": "Verificando conectividade de rede…", + "pollingPaused": "Atualizacao automatica pausada", + "pollingActive": "Atualizacao automatica ativa", + "autoRefresh": "Atualizacao automatica a cada {seconds}s" + }, "portfolioChartWidget": { "title": "Valor do portafolio", "allocation": "Distribuicao", diff --git a/frontend/src/components/NetworkStatusIndicator.test.tsx b/frontend/src/components/NetworkStatusIndicator.test.tsx index b7a80c1f..03573031 100644 --- a/frontend/src/components/NetworkStatusIndicator.test.tsx +++ b/frontend/src/components/NetworkStatusIndicator.test.tsx @@ -1,496 +1,584 @@ -import React from "react"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; -import "@testing-library/jest-dom"; -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import NetworkStatusIndicator from "./NetworkStatusIndicator"; -import { useNetworkStatusStore } from "@/lib/network-status-store"; - -// Mock the network status store -vi.mock("@/lib/network-status-store"); -const mockUseNetworkStatusStore = useNetworkStatusStore as ReturnType; - -// Mock framer-motion -vi.mock("framer-motion", () => ({ - motion: { - div: ({ children, ...props }: any) =>
{children}
, - button: ({ children, ...props }: any) => , - span: ({ children, ...props }: any) => {children}, - svg: ({ children, ...props }: any) => {children}, - }, - AnimatePresence: ({ children }: any) => <>{children}, - useAnimation: () => ({ - start: vi.fn(), - stop: vi.fn(), - }), -})); - -// Mock next-intl -vi.mock("next-intl", () => ({ - useTranslations: () => (key: string) => key, -})); - -// Mock animation utilities -vi.mock("@/lib/network-animations", () => ({ - statusDotVariants: {}, - statusBadgeVariants: {}, - detailsPanelVariants: {}, - refreshButtonVariants: {}, - latencyVariants: {}, - connectionQualityVariants: {}, - errorMessageVariants: {}, - containerVariants: {}, - hoverEffectVariants: {}, - focusRingVariants: {}, - getLatencyVariant: () => "good", - getConnectionQualityVariant: () => "excellent", - getStatusDotVariant: () => "online", - useReducedMotion: () => false, - getAdaptiveTransition: (transition: any) => transition, -})); - -describe("NetworkStatusIndicator", () => { - const mockStore = { - status: "online" as const, - latency: 50, - connectionType: "wifi", - errorMessage: null, - isMonitoring: true, - setStatus: vi.fn(), - setLatency: vi.fn(), - setConnectionType: vi.fn(), - setErrorMessage: vi.fn(), - setIsMonitoring: vi.fn(), - checkStatus: vi.fn(), - reset: vi.fn(), - }; - - beforeEach(() => { - vi.clearAllMocks(); - mockUseNetworkStatusStore.mockReturnValue(mockStore); - }); - - describe("Rendering", () => { - it("renders network status indicator", () => { - render(); - - expect(screen.getByRole("region")).toBeInTheDocument(); - expect(screen.getByText("Online")).toBeInTheDocument(); - }); - - it("displays latency when available", () => { - render(); - - expect(screen.getAllByText(/50/)[0]).toBeInTheDocument(); - expect(screen.getByText("(wifi)")).toBeInTheDocument(); - }); - - it("displays connection type when available", () => { - render(); - - expect(screen.getByText("(wifi)")).toBeInTheDocument(); - }); - - it("hides details when showDetails is false", () => { - render(); - - expect(screen.queryByText("50ms")).not.toBeInTheDocument(); - expect(screen.queryByText("(wifi)")).not.toBeInTheDocument(); - }); - - it("displays connection quality when enabled", () => { - render(); - - expect(screen.getByText("Connection Quality:")).toBeInTheDocument(); - expect(screen.getByText("Good")).toBeInTheDocument(); - }); - - it("hides connection quality when disabled", () => { - render(); - - expect(screen.queryByText("Connection Quality:")).not.toBeInTheDocument(); - expect(screen.queryByText("Excellent")).not.toBeInTheDocument(); - }); - }); - - describe("Status Display", () => { - it("displays correct status for online", () => { - mockUseNetworkStatusStore.mockReturnValue({ - ...mockStore, - status: "online", - }); - - render(); - - expect(screen.getByText("Online")).toBeInTheDocument(); - }); - - it("displays correct status for offline", () => { - mockUseNetworkStatusStore.mockReturnValue({ - ...mockStore, - status: "offline", - }); - - render(); - - expect(screen.getByText("Offline")).toBeInTheDocument(); - }); - - it("displays correct status for slow", () => { - mockUseNetworkStatusStore.mockReturnValue({ - ...mockStore, - status: "slow", - }); - - render(); - - expect(screen.getByText("Slow")).toBeInTheDocument(); - }); - - it("displays correct status for checking", () => { - mockUseNetworkStatusStore.mockReturnValue({ - ...mockStore, - status: "checking", - }); - - render(); - - expect(screen.getByText("Checking...")).toBeInTheDocument(); - }); - - it("displays error message when present", () => { - mockUseNetworkStatusStore.mockReturnValue({ - ...mockStore, - errorMessage: "Network error occurred", - }); - - render(); - - expect(screen.getByText("Network error occurred")).toBeInTheDocument(); - }); - }); - - describe("Refresh Functionality", () => { - it("calls checkStatus when refresh button is clicked", () => { - render(); - - const refreshButton = screen.getByLabelText("network.refresh"); - - // Clear the initial check call - vi.clearAllMocks(); - - fireEvent.click(refreshButton); - - expect(mockStore.checkStatus).toHaveBeenCalledTimes(1); - }); - - it("disables refresh button when checking", () => { - mockUseNetworkStatusStore.mockReturnValue({ - ...mockStore, - status: "checking", - }); - - render(); - - const refreshButton = screen.getByLabelText("network.refresh"); - expect(refreshButton).toBeDisabled(); - }); - - it("enables refresh button when not checking", () => { - mockUseNetworkStatusStore.mockReturnValue({ - ...mockStore, - status: "online", - }); - - render(); - - const refreshButton = screen.getByLabelText("network.refresh"); - expect(refreshButton).not.toBeDisabled(); - }); - }); - - describe("Auto-check Functionality", () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("sets up auto-check when enabled", () => { - render(); - - expect(mockStore.checkStatus).toHaveBeenCalledTimes(1); // Initial check - expect(mockStore.setIsMonitoring).toHaveBeenCalledWith(true); - }); - - it("does not set up auto-check when disabled", () => { - render(); - - expect(mockStore.checkStatus).not.toHaveBeenCalled(); - expect(mockStore.setIsMonitoring).not.toHaveBeenCalled(); - }); - - it("calls checkStatus at specified intervals", async () => { - render(); - - // Clear initial call - vi.clearAllMocks(); - - // Fast-forward time - await vi.advanceTimersByTimeAsync(5000); - - expect(mockStore.checkStatus).toHaveBeenCalledTimes(1); - }); - - it("cleans up interval on unmount", () => { - const { unmount } = render(); - - unmount(); - - expect(mockStore.setIsMonitoring).toHaveBeenCalledWith(false); - }); - }); - - describe("Status Change Callback", () => { - it("calls onStatusChange when status changes", () => { - const mockOnStatusChange = vi.fn(); - - render(); - - expect(mockOnStatusChange).toHaveBeenCalledWith("online"); - }); - - it("does not call onStatusChange when not provided", () => { - expect(() => { - render(); - }).not.toThrow(); - }); - }); - - describe("Accessibility", () => { - it("has correct ARIA attributes", () => { - render(); - - const region = screen.getByRole("region"); - expect(region).toHaveAttribute("aria-label", "network.status"); - expect(region).toHaveAttribute("aria-live", "polite"); - expect(region).toHaveAttribute("aria-atomic", "true"); - }); - - it("has accessible refresh button", () => { - render(); - - const refreshButton = screen.getByLabelText("network.refresh"); - expect(refreshButton).toBeInTheDocument(); - }); - - it("announces status changes", () => { - const { rerender } = render(); - - expect(screen.getByRole("region")).toHaveAttribute("aria-live", "polite"); - }); - }); - - describe("Connection Quality", () => { - it("shows excellent quality for low latency", () => { - mockUseNetworkStatusStore.mockReturnValue({ - ...mockStore, - latency: 30, - }); - - render(); - - expect(screen.getByText("Excellent")).toBeInTheDocument(); - }); - - it("shows good quality for medium latency", () => { - mockUseNetworkStatusStore.mockReturnValue({ - ...mockStore, - latency: 100, - }); - - render(); - - expect(screen.getByText("Good")).toBeInTheDocument(); - }); - - it("shows fair quality for high latency", () => { - mockUseNetworkStatusStore.mockReturnValue({ - ...mockStore, - latency: 200, - }); - - render(); - - expect(screen.getByText("Fair")).toBeInTheDocument(); - }); - - it("shows poor quality for very high latency", () => { - mockUseNetworkStatusStore.mockReturnValue({ - ...mockStore, - latency: 400, - }); - - render(); - - expect(screen.getByText("Poor")).toBeInTheDocument(); - }); - - it("hides quality indicator when latency is null", () => { - mockUseNetworkStatusStore.mockReturnValue({ - ...mockStore, - latency: null, - }); - - render(); - - expect(screen.queryByText("Connection Quality:")).not.toBeInTheDocument(); - }); - }); - - describe("Error States", () => { - it("displays error message clearly", () => { - mockUseNetworkStatusStore.mockReturnValue({ - ...mockStore, - errorMessage: "Failed to connect to server", - }); - - render(); - - expect(screen.getByText("Failed to connect to server")).toBeInTheDocument(); - }); - - it("shows error styling for error messages", () => { - mockUseNetworkStatusStore.mockReturnValue({ - ...mockStore, - errorMessage: "Network error", - }); - - render(); - - const errorElement = screen.getByText("Network error").closest(".rounded"); - expect(errorElement).toHaveClass("bg-red-50"); - }); - - it("shows last checked time when online and no errors", () => { - mockUseNetworkStatusStore.mockReturnValue({ - ...mockStore, - status: "online", - errorMessage: null, - }); - - render(); - - expect(screen.getByText(/lastChecked/)).toBeInTheDocument(); - }); - - it("hides last checked time when there are errors", () => { - mockUseNetworkStatusStore.mockReturnValue({ - ...mockStore, - status: "offline", - errorMessage: "Connection failed", - }); - - render(); - - expect(screen.queryByText(/lastChecked/)).not.toBeInTheDocument(); - }); - }); - - describe("Micro-interactions", () => { - it("enables micro-interactions by default", () => { - render(); - - // Component should render without issues - expect(screen.getByRole("region")).toBeInTheDocument(); - }); - - it("disables micro-interactions when specified", () => { - render(); - - // Component should still render normally - expect(screen.getByRole("region")).toBeInTheDocument(); - }); - }); - - describe("Performance", () => { - it("renders efficiently with default props", () => { - const startTime = performance.now(); - - render(); - - const endTime = performance.now(); - const renderTime = endTime - startTime; - - // Should render quickly (under 200ms for test environment) - expect(renderTime).toBeLessThan(200); - }); - - it("handles rapid status changes efficiently", () => { - const { rerender } = render(); - - // Simulate rapid status changes - const statuses = ["online", "offline", "checking", "slow", "online"]; - - statuses.forEach((status) => { - mockUseNetworkStatusStore.mockReturnValue({ - ...mockStore, - status: status as any, - }); - - rerender(); - }); - - // Should handle changes without errors - expect(screen.getByRole("region")).toBeInTheDocument(); - }); - }); - - describe("Edge Cases", () => { - it("handles null connection type gracefully", () => { - mockUseNetworkStatusStore.mockReturnValue({ - ...mockStore, - connectionType: null, - }); - - render(); - - expect(screen.queryByText(/\(/)).not.toBeInTheDocument(); - }); - - it("handles unknown connection type gracefully", () => { - mockUseNetworkStatusStore.mockReturnValue({ - ...mockStore, - connectionType: "unknown", - }); - - render(); - - expect(screen.queryByText(/\(/)).not.toBeInTheDocument(); - }); - - it("handles zero latency gracefully", () => { - mockUseNetworkStatusStore.mockReturnValue({ - ...mockStore, - latency: 0, - }); - - render(); - - expect(screen.getAllByText(/0/)[0]).toBeInTheDocument(); - }); - - it("handles very high latency gracefully", () => { - mockUseNetworkStatusStore.mockReturnValue({ - ...mockStore, - latency: 9999, - }); - - render(); - - expect(screen.getAllByText(/9999/)[0]).toBeInTheDocument(); - expect(screen.getByText("Poor")).toBeInTheDocument(); - }); - }); -}); +/** + * @vitest-environment jsdom + * + * Tests for NetworkStatusIndicator + network-status reducer. + * + * Covers: + * - Reducer: all action types, state transitions, selectors + * - Indicator: all health states, loading states, error banner, retry/dismiss, + * expand/collapse panel, compact variant, a11y attributes + * - Hook (via mocked fetch): operational, degraded, error, offline paths + */ + +import React from "react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; +import "@testing-library/jest-dom/vitest"; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string, params?: Record) => { + if (!params) return key; + return Object.entries(params).reduce( + (acc, [k, v]) => acc.replace(`{${k}}`, String(v)), + key, + ); + }, +})); + +vi.mock("framer-motion", () => ({ + motion: new Proxy({}, { + get: (_t, tag: string) => + React.forwardRef(({ children, ...props }: any, ref: any) => { + const { variants, initial, animate, exit, transition, ...rest } = props; + void variants; void initial; void animate; void exit; void transition; + return React.createElement(tag, { ...rest, ref }, children); + }), + }), + AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +// ── Reducer + selectors ─────────────────────────────────────────────────────── + +import { + networkStatusReducer, + initialNetworkStatusState, + deriveHealth, + healthToColor, + selectIsLoading, + selectHasError, + selectIsHealthy, + selectIsDegraded, + selectIsOffline, + selectLastCheckedLabel, + LATENCY_THRESHOLDS, + type NetworkCheckResult, +} from "@/lib/network-status"; + +const mockResult = (overrides: Partial = {}): NetworkCheckResult => ({ + checkedAt: Date.now(), + latencyMs: 120, + ledgerSequence: 49999999, + horizonUrl: "https://horizon-testnet.stellar.org", + health: "operational", + httpStatus: 200, + ...overrides, +}); + +describe("networkStatusReducer", () => { + it("returns initial state", () => { + expect(initialNetworkStatusState.health).toBe("unknown"); + expect(initialNetworkStatusState.fetchState).toBe("idle"); + expect(initialNetworkStatusState.checkCount).toBe(0); + }); + + it("CHECK_START sets checking + loading", () => { + const s = networkStatusReducer(initialNetworkStatusState, { type: "CHECK_START" }); + expect(s.health).toBe("checking"); + expect(s.fetchState).toBe("loading"); + expect(s.isManualCheck).toBe(false); + }); + + it("CHECK_START with manual=true sets isManualCheck", () => { + const s = networkStatusReducer(initialNetworkStatusState, { type: "CHECK_START", manual: true }); + expect(s.isManualCheck).toBe(true); + }); + + it("CHECK_SUCCESS stores result and resets errors", () => { + const pending = networkStatusReducer(initialNetworkStatusState, { type: "CHECK_START" }); + const result = mockResult(); + const s = networkStatusReducer(pending, { type: "CHECK_SUCCESS", result }); + expect(s.health).toBe("operational"); + expect(s.fetchState).toBe("success"); + expect(s.lastResult).toEqual(result); + expect(s.checkCount).toBe(1); + expect(s.consecutiveErrors).toBe(0); + expect(s.isManualCheck).toBe(false); + }); + + it("CHECK_ERROR sets offline + increments consecutiveErrors", () => { + const s = networkStatusReducer(initialNetworkStatusState, { + type: "CHECK_ERROR", error: "Network error", + }); + expect(s.health).toBe("offline"); + expect(s.fetchState).toBe("error"); + expect(s.errorMessage).toBe("Network error"); + expect(s.consecutiveErrors).toBe(1); + expect(s.checkCount).toBe(1); + }); + + it("CHECK_SUCCESS after errors resets consecutiveErrors", () => { + let s = networkStatusReducer(initialNetworkStatusState, { type: "CHECK_ERROR", error: "err" }); + s = networkStatusReducer(s, { type: "CHECK_ERROR", error: "err2" }); + expect(s.consecutiveErrors).toBe(2); + s = networkStatusReducer(s, { type: "CHECK_SUCCESS", result: mockResult() }); + expect(s.consecutiveErrors).toBe(0); + }); + + it("POLLING_START / POLLING_STOP toggle isPolling", () => { + const started = networkStatusReducer(initialNetworkStatusState, { type: "POLLING_START" }); + expect(started.isPolling).toBe(true); + const stopped = networkStatusReducer(started, { type: "POLLING_STOP" }); + expect(stopped.isPolling).toBe(false); + }); + + it("DISMISS_ERROR clears error and reverts to unknown health", () => { + const errState = networkStatusReducer(initialNetworkStatusState, { + type: "CHECK_ERROR", error: "fail", + }); + const dismissed = networkStatusReducer(errState, { type: "DISMISS_ERROR" }); + expect(dismissed.errorMessage).toBeNull(); + expect(dismissed.health).toBe("unknown"); + expect(dismissed.fetchState).toBe("idle"); + }); + + it("RESET returns initial state", () => { + let s = networkStatusReducer(initialNetworkStatusState, { type: "CHECK_ERROR", error: "e" }); + s = networkStatusReducer(s, { type: "RESET" }); + expect(s).toEqual(initialNetworkStatusState); + }); +}); + +describe("selectors", () => { + it("selectIsLoading returns true when fetchState=loading", () => { + const s = networkStatusReducer(initialNetworkStatusState, { type: "CHECK_START" }); + expect(selectIsLoading(s)).toBe(true); + expect(selectIsLoading(initialNetworkStatusState)).toBe(false); + }); + + it("selectHasError returns true when error state", () => { + const s = networkStatusReducer(initialNetworkStatusState, { type: "CHECK_ERROR", error: "e" }); + expect(selectHasError(s)).toBe(true); + expect(selectHasError(initialNetworkStatusState)).toBe(false); + }); + + it("selectIsHealthy returns true only for operational", () => { + const s = networkStatusReducer(initialNetworkStatusState, { + type: "CHECK_SUCCESS", result: mockResult({ health: "operational" }), + }); + expect(selectIsHealthy(s)).toBe(true); + }); + + it("selectIsDegraded returns true for degraded/partial/major outage", () => { + for (const h of ["degraded", "partial_outage", "major_outage"] as const) { + const s = networkStatusReducer(initialNetworkStatusState, { + type: "CHECK_SUCCESS", result: mockResult({ health: h }), + }); + expect(selectIsDegraded(s)).toBe(true); + } + }); + + it("selectIsOffline returns true for offline health", () => { + const s = networkStatusReducer(initialNetworkStatusState, { + type: "CHECK_ERROR", error: "Network error", + }); + expect(selectIsOffline(s)).toBe(true); + }); +}); + +describe("deriveHealth", () => { + it("returns major_outage for 5xx", () => { + expect(deriveHealth(500, 100)).toBe("major_outage"); + expect(deriveHealth(503, 50)).toBe("major_outage"); + }); + + it("returns partial_outage for 4xx", () => { + expect(deriveHealth(404, 200)).toBe("partial_outage"); + }); + + it("returns operational for fast latency", () => { + expect(deriveHealth(200, LATENCY_THRESHOLDS.operational)).toBe("operational"); + expect(deriveHealth(200, 100)).toBe("operational"); + }); + + it("returns degraded for elevated latency", () => { + expect(deriveHealth(200, LATENCY_THRESHOLDS.operational + 1)).toBe("degraded"); + expect(deriveHealth(200, LATENCY_THRESHOLDS.degraded)).toBe("degraded"); + }); + + it("returns partial_outage for very high latency", () => { + expect(deriveHealth(200, LATENCY_THRESHOLDS.degraded + 1)).toBe("partial_outage"); + }); +}); + +describe("selectLastCheckedLabel", () => { + it("returns empty string for null", () => { + expect(selectLastCheckedLabel(null, Date.now())).toBe(""); + }); + + it("returns 'just now' for < 5s ago", () => { + const t = Date.now(); + expect(selectLastCheckedLabel(t - 2000, t)).toBe("just now"); + }); + + it("returns Xs ago for < 60s", () => { + const t = Date.now(); + expect(selectLastCheckedLabel(t - 30_000, t)).toBe("30s ago"); + }); + + it("returns Xm ago for < 60min", () => { + const t = Date.now(); + expect(selectLastCheckedLabel(t - 5 * 60_000, t)).toBe("5m ago"); + }); + + it("returns Xh ago for >= 60min", () => { + const t = Date.now(); + expect(selectLastCheckedLabel(t - 2 * 60 * 60_000, t)).toBe("2h ago"); + }); +}); + +describe("healthToColor", () => { + it("returns emerald for operational", () => { + expect(healthToColor("operational").dot).toContain("emerald"); + }); + + it("returns amber for degraded", () => { + expect(healthToColor("degraded").dot).toContain("amber"); + }); + + it("returns red for major_outage", () => { + expect(healthToColor("major_outage").dot).toContain("red"); + }); + + it("returns red for offline", () => { + expect(healthToColor("offline").dot).toContain("red"); + }); +}); + +// ── Component tests ─────────────────────────────────────────────────────────── + +import { NetworkStatusIndicator } from "@/components/NetworkStatusIndicator"; + +/** Render the indicator with a mocked fetch. */ +function renderIndicator(fetchImpl: () => Promise, props = {}) { + global.fetch = vi.fn(fetchImpl as any); + global.performance.now = vi.fn(() => 100); + return render( + React.createElement(NetworkStatusIndicator, { + horizonUrl: "https://horizon-testnet.stellar.org", + pollIntervalMs: 60_000, + autoStart: false, // controlled manually in tests + ...props, + }), + ); +} + +describe("NetworkStatusIndicator component", () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + global.window.matchMedia = vi.fn().mockReturnValue({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }); + }); + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + // ── Initial / loading state ───────────────────────────────────────────── + + it("renders the card wrapper on mount", () => { + renderIndicator(() => new Promise(() => {})); + expect(screen.getByTestId("network-status-card")).toBeInTheDocument(); + }); + + it("renders sr-only announcement region", () => { + renderIndicator(() => new Promise(() => {})); + expect(screen.getByTestId("sr-announcement")).toBeInTheDocument(); + }); + + it("shows 'checkNow' button", () => { + renderIndicator(() => new Promise(() => {})); + expect(screen.getByTestId("check-now-button")).toBeInTheDocument(); + }); + + it("shows loading spinner in check-now button when checking", async () => { + let resolve!: (v: unknown) => void; + renderIndicator(() => new Promise((r) => { resolve = r; })); + fireEvent.click(screen.getByTestId("check-now-button")); + await waitFor(() => + expect(screen.getByTestId("check-now-button")).toHaveAttribute("aria-busy", "true"), + ); + act(() => resolve({ ok: true, status: 200, json: async () => ({ history_latest_ledger: 1 }) })); + }); + + it("disables check-now button while loading", async () => { + renderIndicator(() => new Promise(() => {})); + fireEvent.click(screen.getByTestId("check-now-button")); + await waitFor(() => + expect(screen.getByTestId("check-now-button")).toBeDisabled(), + ); + }); + + // ── Successful check ──────────────────────────────────────────────────── + + it("displays latency after a successful check", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ history_latest_ledger: 49999999 }), + }); + global.performance.now = vi.fn() + .mockReturnValueOnce(0) + .mockReturnValueOnce(120); + + render( + React.createElement(NetworkStatusIndicator, { + horizonUrl: "https://horizon-testnet.stellar.org", + pollIntervalMs: 60_000, + autoStart: true, + }), + ); + + await waitFor(() => + expect(screen.getByTestId("latency-value")).toBeInTheDocument(), + ); + }); + + it("displays ledger sequence after a successful check", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ history_latest_ledger: 49999999 }), + }); + + render( + React.createElement(NetworkStatusIndicator, { + horizonUrl: "https://horizon-testnet.stellar.org", + autoStart: true, + }), + ); + + await waitFor(() => + expect(screen.getByTestId("ledger-value")).toBeInTheDocument(), + ); + }); + + // ── Error state ───────────────────────────────────────────────────────── + + it("shows error banner when fetch fails", async () => { + global.fetch = vi.fn().mockRejectedValue(new Error("Connection refused")); + + render( + React.createElement(NetworkStatusIndicator, { + horizonUrl: "https://horizon-testnet.stellar.org", + autoStart: true, + pollIntervalMs: 60_000, + }), + ); + + await waitFor(() => + expect(screen.getByTestId("error-banner")).toBeInTheDocument(), + ); + }); + + it("error banner has role=alert and aria-live=assertive", async () => { + global.fetch = vi.fn().mockRejectedValue(new Error("fail")); + + render( + React.createElement(NetworkStatusIndicator, { + horizonUrl: "https://horizon-testnet.stellar.org", + autoStart: true, + pollIntervalMs: 60_000, + }), + ); + + await waitFor(() => { + const banner = screen.getByTestId("error-banner"); + expect(banner).toHaveAttribute("role", "alert"); + expect(banner).toHaveAttribute("aria-live", "assertive"); + }); + }); + + it("shows retry button in error state", async () => { + global.fetch = vi.fn().mockRejectedValue(new Error("fail")); + + render( + React.createElement(NetworkStatusIndicator, { + horizonUrl: "https://horizon-testnet.stellar.org", + autoStart: true, + pollIntervalMs: 60_000, + }), + ); + + await waitFor(() => + expect(screen.getByTestId("retry-button")).toBeInTheDocument(), + ); + }); + + it("dismisses error banner when dismiss button clicked", async () => { + global.fetch = vi.fn().mockRejectedValue(new Error("fail")); + + render( + React.createElement(NetworkStatusIndicator, { + horizonUrl: "https://horizon-testnet.stellar.org", + autoStart: true, + pollIntervalMs: 60_000, + }), + ); + + await waitFor(() => screen.getByTestId("dismiss-button")); + fireEvent.click(screen.getByTestId("dismiss-button")); + + await waitFor(() => + expect(screen.queryByTestId("error-banner")).not.toBeInTheDocument(), + ); + }); + + it("shows retry count after multiple consecutive errors", async () => { + global.fetch = vi.fn().mockRejectedValue(new Error("fail")); + + // Manually dispatch two errors via the reducer + // We simulate this by using internal state — checked via data-testid + render( + React.createElement(NetworkStatusIndicator, { + horizonUrl: "https://horizon-testnet.stellar.org", + autoStart: true, + pollIntervalMs: 60_000, + }), + ); + + await waitFor(() => screen.getByTestId("error-banner")); + // First error — retry count not shown (only shown after > 1) + expect(screen.queryByTestId("retry-count")).not.toBeInTheDocument(); + }); + + // ── Expand / collapse panel ───────────────────────────────────────────── + + it("shows expand button in full variant", () => { + renderIndicator(() => new Promise(() => {})); + expect(screen.getByTestId("expand-button")).toBeInTheDocument(); + }); + + it("expand button toggles aria-expanded", () => { + renderIndicator(() => new Promise(() => {})); + const btn = screen.getByTestId("expand-button"); + expect(btn).toHaveAttribute("aria-expanded", "false"); + fireEvent.click(btn); + expect(btn).toHaveAttribute("aria-expanded", "true"); + fireEvent.click(btn); + expect(btn).toHaveAttribute("aria-expanded", "false"); + }); + + it("reveals details panel when expanded", () => { + renderIndicator(() => new Promise(() => {})); + expect(screen.queryByTestId("details-panel")).not.toBeInTheDocument(); + fireEvent.click(screen.getByTestId("expand-button")); + expect(screen.getByTestId("details-panel")).toBeInTheDocument(); + }); + + it("hides details panel when collapsed again", () => { + renderIndicator(() => new Promise(() => {})); + fireEvent.click(screen.getByTestId("expand-button")); + expect(screen.getByTestId("details-panel")).toBeInTheDocument(); + fireEvent.click(screen.getByTestId("expand-button")); + expect(screen.queryByTestId("details-panel")).not.toBeInTheDocument(); + }); + + it("shows pause/resume polling buttons in details panel", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ history_latest_ledger: 1 }), + }); + + render( + React.createElement(NetworkStatusIndicator, { + horizonUrl: "https://horizon-testnet.stellar.org", + autoStart: true, + pollIntervalMs: 60_000, + }), + ); + + fireEvent.click(screen.getByTestId("expand-button")); + await waitFor(() => screen.getByTestId("details-panel")); + expect( + screen.getByTestId("pause-polling-button") || + screen.getByTestId("resume-polling-button"), + ).toBeInTheDocument(); + }); + + // ── Compact variant ──────────────────────────────────────────────────── + + it("renders compact pill instead of card", () => { + renderIndicator(() => new Promise(() => {}), { compact: true }); + expect(screen.getByTestId("network-status-compact")).toBeInTheDocument(); + expect(screen.queryByTestId("network-status-card")).not.toBeInTheDocument(); + }); + + it("compact variant has translated aria-label", () => { + renderIndicator(() => new Promise(() => {}), { compact: true }); + expect(screen.getByTestId("network-status-compact")).toHaveAttribute( + "aria-label", + "ariaLabel", + ); + }); + + it("compact variant has aria-busy when loading", async () => { + renderIndicator(() => new Promise(() => {}), { compact: true }); + fireEvent.click(screen.getByTestId("network-status-compact")); + await waitFor(() => + expect(screen.getByTestId("network-status-compact")).toHaveAttribute( + "aria-busy", "true", + ), + ); + }); + + // ── Accessibility ────────────────────────────────────────────────────── + + it("sr-only status region has aria-live=polite", () => { + renderIndicator(() => new Promise(() => {})); + expect(screen.getByTestId("sr-announcement")).toHaveAttribute("aria-live", "polite"); + }); + + it("check-now button has translated aria-label", () => { + renderIndicator(() => new Promise(() => {})); + expect(screen.getByTestId("check-now-button")).toHaveAttribute("aria-label", "checkNow"); + }); + + it("expand button has translated aria-label when collapsed", () => { + renderIndicator(() => new Promise(() => {})); + expect(screen.getByTestId("expand-button")).toHaveAttribute("aria-label", "expandPanel"); + }); + + it("expand button has translated aria-label when expanded", () => { + renderIndicator(() => new Promise(() => {})); + fireEvent.click(screen.getByTestId("expand-button")); + expect(screen.getByTestId("expand-button")).toHaveAttribute("aria-label", "collapsePanel"); + }); + + it("showDetails=false hides expand button", () => { + renderIndicator(() => new Promise(() => {}), { showDetails: false }); + expect(screen.queryByTestId("expand-button")).not.toBeInTheDocument(); + }); +}); + +// ── NetworkStatusSkeleton ───────────────────────────────────────────────────── + +import NetworkStatusSkeleton from "@/components/NetworkStatusSkeleton"; + +describe("NetworkStatusSkeleton", () => { + it("renders with role=status and aria-busy=true", () => { + render(React.createElement(NetworkStatusSkeleton)); + const el = screen.getByTestId("network-status-skeleton"); + expect(el).toHaveAttribute("role", "status"); + expect(el).toHaveAttribute("aria-busy", "true"); + }); + + it("renders compact variant", () => { + render(React.createElement(NetworkStatusSkeleton, { compact: true })); + expect(screen.getByTestId("network-status-skeleton")).toBeInTheDocument(); + }); + + it("shows custom loading label in sr-only span", () => { + render( + React.createElement(NetworkStatusSkeleton, { + loadingLabel: "Custom loading label", + }), + ); + expect(screen.getByText("Custom loading label")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/NetworkStatusIndicator.tsx b/frontend/src/components/NetworkStatusIndicator.tsx index 3154fbcd..e4f3326e 100644 --- a/frontend/src/components/NetworkStatusIndicator.tsx +++ b/frontend/src/components/NetworkStatusIndicator.tsx @@ -1,441 +1,492 @@ -"use client"; - -import React, { useState, useEffect } from "react"; -import { motion, AnimatePresence, type Variants, useAnimation } from "framer-motion"; -import { useTranslations } from "next-intl"; -import { useNetworkStatusStore } from "@/lib/network-status-store"; -import { - statusDotVariants, - statusBadgeVariants, - detailsPanelVariants, - refreshButtonVariants, - latencyVariants, - connectionQualityVariants, - errorMessageVariants, - containerVariants, - hoverEffectVariants, - focusRingVariants, - getLatencyVariant, - getConnectionQualityVariant, - getStatusDotVariant, - useReducedMotion, - getAdaptiveTransition, - statusChangeFlashVariants, - offlineAlertVariants, - monitoringPulseVariants, -} from "@/lib/network-animations"; -import { useNetworkMonitor } from "@/hooks/useNetworkMonitor"; - -/** - * Props for NetworkStatusIndicator component - */ -interface NetworkStatusIndicatorProps { - showDetails?: boolean; - autoCheck?: boolean; - checkInterval?: number; - onStatusChange?: (status: string) => void; - showConnectionQuality?: boolean; - enableMicroInteractions?: boolean; - enableScreenReaderSupport?: boolean; - enableKeyboardNavigation?: boolean; - announcementsEnabled?: boolean; -} - -/** - * Status color mapper - */ -const getStatusColor = ( - status: string -): { - dot: string; - bg: string; - text: string; - label: string; -} => { - const colors: Record< - string, - { dot: string; bg: string; text: string; label: string } - > = { - online: { - dot: "bg-green-500", - bg: "bg-green-50", - text: "text-green-700", - label: "Online", - }, - offline: { - dot: "bg-red-500", - bg: "bg-red-50", - text: "text-red-700", - label: "Offline", - }, - slow: { - dot: "bg-yellow-500", - bg: "bg-yellow-50", - text: "text-yellow-700", - label: "Slow", - }, - checking: { - dot: "bg-gray-400", - bg: "bg-gray-50", - text: "text-gray-700", - label: "Checking...", - }, - }; - - return colors[status] || colors.checking; -}; - -/** - * NetworkStatusIndicator Component - * - * Displays real-time network status with automatic monitoring. - * Uses Zustand for state management and framer-motion for animations. - * Includes latency measurement and connection type detection. - */ -export const NetworkStatusIndicator: React.FC< - NetworkStatusIndicatorProps -> = ({ - showDetails = true, - autoCheck = true, - checkInterval = 30000, - onStatusChange, - showConnectionQuality = true, - enableMicroInteractions = true, - enableScreenReaderSupport = true, - enableKeyboardNavigation = true, - announcementsEnabled = true, -}) => { - const t = useTranslations(); - const [isHovered, setIsHovered] = useState(false); - const [isFocused, setIsFocused] = useState(false); - const controls = useAnimation(); - const reducedMotion = useReducedMotion(); - - const { statusRegionRef, detailsRegionRef, refreshButtonRef, handleRefresh } = - useNetworkMonitor({ - autoCheck, - checkInterval, - onStatusChange, - showConnectionQuality, - enableScreenReaderSupport, - enableKeyboardNavigation, - announcementsEnabled, - }); - - const { status, latency, connectionType, errorMessage } = - useNetworkStatusStore(); - - const colors = getStatusColor(status); - const adaptiveTransition = getAdaptiveTransition({ duration: 0.3, ease: [0.16, 1, 0.3, 1] }); - - useEffect(() => { - if (!reducedMotion) controls.start("flash"); - }, [status, reducedMotion, controls]); - - return ( - setIsHovered(true)} - onHoverEnd={() => setIsHovered(false)} - onFocus={() => setIsFocused(true)} - onBlur={() => setIsFocused(false)} - > - {/* Background hover effect */} - {enableMicroInteractions && ( - - )} - - {/* Focus ring */} - {enableMicroInteractions && ( - - )} - - {/* Status change flash overlay */} - {!reducedMotion && ( - - )} - -
-
-
- {/* Enhanced status indicator dot */} -
- - - {/* Monitoring active pulse ring */} - {autoCheck && !reducedMotion && ( - - )} - - {/* Pulse effect for active states */} - {(status === "checking" || status === "slow") && !reducedMotion && ( - - )} -
- - {/* Enhanced status label */} - - - - - {colors.label} - - - - {showDetails && latency !== null && ( - - {latency}ms - {connectionType && connectionType !== "unknown" && ( - ({connectionType}) - )} - - )} - - -
- - {/* Enhanced refresh button */} - { - if (enableKeyboardNavigation && (e.key === "Enter" || e.key === " ")) { - e.preventDefault(); - handleRefresh(); - } - }} - > - - - - - {/* Loading indicator for refresh button */} - {status === "checking" && ( - - )} - - - {/* Hidden screen reader status announcements */} - {enableScreenReaderSupport && ( -
- {status === "checking" && ( -
Currently checking network status
- )} - {latency !== null && ( -
Current network latency: {latency} milliseconds
- )} - {connectionType && connectionType !== "unknown" && ( -
Connection type: {connectionType}
- )} - {errorMessage && ( -
Network error: {errorMessage}
- )} -
- )} -
- - {/* Enhanced connection quality indicator */} - {showConnectionQuality && latency !== null && ( - -
- Connection Quality: -
- -
- - {latency < 50 ? "Excellent" : latency < 150 ? "Good" : latency < 300 ? "Fair" : "Poor"} - -
-
- )} - - {/* Enhanced detailed information panel */} - {showDetails && ( - - {(errorMessage || latency !== null) && ( - -
- {latency !== null && ( - - - {t("network.latency") || "Latency"}: - {" "} - - {latency}ms - - - )} - - {connectionType && connectionType !== "unknown" && ( - - - {t("network.connection") || "Connection"}: - {" "} - {connectionType} - - )} - - {errorMessage && ( - - - {t("network.error") || "Error"}: - {" "} - {errorMessage} - - )} - - {status === "online" && !errorMessage && ( - - {t("network.lastChecked") || "Last checked"}:{" "} - {new Date().toLocaleTimeString()} - - )} -
-
- )} -
- )} -
-
- ); -}; - -export default NetworkStatusIndicator; +"use client"; + +/** + * NetworkStatusIndicator + * + * Displays the live health of the Stellar/Horizon network with rich + * interactive loading states: + * + * States handled: + * "unknown" — initial, never polled yet → skeleton-like pulse dot + * "checking" — in-flight request → spinning dot + translucent badge + * "operational" — green dot, fast latency + * "degraded" — amber dot, elevated latency + * "partial_outage" — orange dot, some services down + * "major_outage" — red dot, critical services down + * "offline" — red dot + error banner with retry + dismiss + * + * Variants: + * compact — pill badge for nav/sidebar (dot + text only) + * full — card with latency, ledger, last-checked, and expand/collapse + * + * Accessibility: + * - role="status" aria-live="polite" for health change announcements + * - aria-busy during in-flight checks + * - All interactive elements have translated aria-labels + * - Reduced-motion: spinner and pulse animations disabled + */ + +import React, { memo, useState, useCallback } from "react"; +import { useTranslations } from "next-intl"; +import { useNetworkStatus, type UseNetworkStatusOptions } from "@/hooks/useNetworkStatus"; +import { + healthToColor, + type NetworkHealth, +} from "@/lib/network-status"; + +// ── Sub-components ──────────────────────────────────────────────────────────── + +/** Animated status dot — CSS-only, no framer-motion. */ +const StatusDot = memo(function StatusDot({ + health, + dotClass, + prefersReducedMotion, +}: { + health: NetworkHealth; + dotClass: string; + prefersReducedMotion: boolean; +}) { + const isPulse = health === "operational" && !prefersReducedMotion; + const isSpin = health === "checking" && !prefersReducedMotion; + + return ( +