feat: add SyncStatus component with visual indicator
Displays sync state with: - Green dot for synced (with relative time) - Spinner for syncing - Amber dot for stale (auto-detected after 15min) - Red dot for error (with retry button) - Gray dot for offline Includes 23 tests covering all states, time formatting, button visibility, and click handlers. bd-2or Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
195
tests/components/SyncStatus.test.tsx
Normal file
195
tests/components/SyncStatus.test.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { SyncStatus } from "@/components/SyncStatus";
|
||||
|
||||
describe("SyncStatus", () => {
|
||||
beforeEach(() => {
|
||||
// Mock Date.now for consistent time-based tests
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-02-26T12:00:00Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("synced state", () => {
|
||||
it("shows green indicator when synced", () => {
|
||||
const lastSync = new Date("2026-02-26T11:58:00Z"); // 2 minutes ago
|
||||
render(<SyncStatus status="synced" lastSync={lastSync} />);
|
||||
|
||||
const indicator = screen.getByTestId("sync-indicator");
|
||||
expect(indicator).toHaveClass("bg-green-500");
|
||||
});
|
||||
|
||||
it("shows relative time for recent sync", () => {
|
||||
const lastSync = new Date("2026-02-26T11:58:00Z"); // 2 minutes ago
|
||||
render(<SyncStatus status="synced" lastSync={lastSync} />);
|
||||
|
||||
expect(screen.getByText(/synced 2m ago/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows seconds for very recent sync", () => {
|
||||
const lastSync = new Date("2026-02-26T11:59:30Z"); // 30 seconds ago
|
||||
render(<SyncStatus status="synced" lastSync={lastSync} />);
|
||||
|
||||
expect(screen.getByText(/synced.*30s ago/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'just now' for immediate sync", () => {
|
||||
const lastSync = new Date("2026-02-26T12:00:00Z"); // right now
|
||||
render(<SyncStatus status="synced" lastSync={lastSync} />);
|
||||
|
||||
expect(screen.getByText(/synced just now/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncing state", () => {
|
||||
it("shows spinner when syncing", () => {
|
||||
render(<SyncStatus status="syncing" />);
|
||||
|
||||
expect(screen.getByTestId("sync-spinner")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Syncing...' text", () => {
|
||||
render(<SyncStatus status="syncing" />);
|
||||
|
||||
expect(screen.getByText("Syncing...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show indicator dot when syncing", () => {
|
||||
render(<SyncStatus status="syncing" />);
|
||||
|
||||
expect(screen.queryByTestId("sync-indicator")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("stale state", () => {
|
||||
it("shows amber indicator when explicitly stale", () => {
|
||||
const lastSync = new Date("2026-02-26T11:40:00Z"); // 20 minutes ago
|
||||
render(<SyncStatus status="stale" lastSync={lastSync} />);
|
||||
|
||||
const indicator = screen.getByTestId("sync-indicator");
|
||||
expect(indicator).toHaveClass("bg-amber-500");
|
||||
});
|
||||
|
||||
it("auto-detects stale when synced status but time > 15 minutes", () => {
|
||||
const lastSync = new Date("2026-02-26T11:40:00Z"); // 20 minutes ago
|
||||
render(<SyncStatus status="synced" lastSync={lastSync} />);
|
||||
|
||||
// Should show amber (stale) even though status is "synced"
|
||||
const indicator = screen.getByTestId("sync-indicator");
|
||||
expect(indicator).toHaveClass("bg-amber-500");
|
||||
});
|
||||
|
||||
it("shows 'Last sync' text for stale", () => {
|
||||
const lastSync = new Date("2026-02-26T11:40:00Z"); // 20 minutes ago
|
||||
render(<SyncStatus status="synced" lastSync={lastSync} />);
|
||||
|
||||
expect(screen.getByText(/last sync 20m ago/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows refresh button when stale", () => {
|
||||
const lastSync = new Date("2026-02-26T11:40:00Z");
|
||||
render(<SyncStatus status="synced" lastSync={lastSync} onRetry={() => {}} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: /refresh/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("error state", () => {
|
||||
it("shows red indicator for error", () => {
|
||||
render(<SyncStatus status="error" error="lore command failed" />);
|
||||
|
||||
const indicator = screen.getByTestId("sync-indicator");
|
||||
expect(indicator).toHaveClass("bg-red-500");
|
||||
});
|
||||
|
||||
it("shows error message", () => {
|
||||
render(<SyncStatus status="error" error="lore command failed" />);
|
||||
|
||||
expect(screen.getByText(/lore command failed/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows retry button", () => {
|
||||
render(<SyncStatus status="error" error="failed" onRetry={() => {}} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onRetry when retry button clicked", async () => {
|
||||
// Use real timers for this test since userEvent needs them
|
||||
vi.useRealTimers();
|
||||
const user = userEvent.setup();
|
||||
const onRetry = vi.fn();
|
||||
render(<SyncStatus status="error" error="failed" onRetry={onRetry} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /retry/i }));
|
||||
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
// Restore fake timers for subsequent tests
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-02-26T12:00:00Z"));
|
||||
});
|
||||
|
||||
it("shows generic message when no error provided", () => {
|
||||
render(<SyncStatus status="error" />);
|
||||
|
||||
expect(screen.getByText(/sync failed/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("offline state", () => {
|
||||
it("shows gray indicator when offline", () => {
|
||||
render(<SyncStatus status="offline" />);
|
||||
|
||||
const indicator = screen.getByTestId("sync-indicator");
|
||||
expect(indicator).toHaveClass("bg-gray-400");
|
||||
});
|
||||
|
||||
it("shows 'lore unavailable' message", () => {
|
||||
render(<SyncStatus status="offline" />);
|
||||
|
||||
expect(screen.getByText(/lore unavailable/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show retry button when offline", () => {
|
||||
render(<SyncStatus status="offline" onRetry={() => {}} />);
|
||||
|
||||
// Offline doesn't get a retry button (user needs to fix lore first)
|
||||
expect(screen.queryByRole("button", { name: /retry/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("relative time formatting", () => {
|
||||
it("formats hours correctly", () => {
|
||||
const lastSync = new Date("2026-02-26T10:00:00Z"); // 2 hours ago
|
||||
render(<SyncStatus status="stale" lastSync={lastSync} />);
|
||||
|
||||
expect(screen.getByText(/2h ago/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("formats days correctly", () => {
|
||||
const lastSync = new Date("2026-02-24T12:00:00Z"); // 2 days ago
|
||||
render(<SyncStatus status="stale" lastSync={lastSync} />);
|
||||
|
||||
expect(screen.getByText(/2d ago/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("no onRetry callback", () => {
|
||||
it("hides retry button when no onRetry provided for error", () => {
|
||||
render(<SyncStatus status="error" error="failed" />);
|
||||
|
||||
expect(screen.queryByRole("button", { name: /retry/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides refresh button when no onRetry provided for stale", () => {
|
||||
const lastSync = new Date("2026-02-26T11:40:00Z");
|
||||
render(<SyncStatus status="synced" lastSync={lastSync} />);
|
||||
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user