plan/react-rewrite #1

Merged
gnezim merged 138 commits from plan/react-rewrite into main 2026-04-15 12:21:16 +03:00
5 changed files with 667 additions and 0 deletions
Showing only changes of commit c6c0ce2bfc - Show all commits
@@ -0,0 +1,245 @@
/**
* @vitest-environment jsdom
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import {
_resetSharedConnections,
getSharedConnection,
} from "@/shared/signalr/connection.js";
import { buildBoardChannelKey, useLiveBoardSearch } from "./useLiveBoardSearch.js";
import type { LiveBoardSearchParams } from "./useLiveBoardSearch.js";
import type { ISimpleFlight, IFlightId, IFlightLeg } from "../types.js";
// ---------------------------------------------------------------------------
// Env mock — getEnv() must return SIGNALR_HUB_URL
// ---------------------------------------------------------------------------
vi.mock("@/env/index.js", () => ({
getEnv: () => ({
SIGNALR_HUB_URL: "https://hub.test/tracker",
}),
}));
// ---------------------------------------------------------------------------
// Mock hub helpers (same pattern as useLiveFlights.test.ts)
// ---------------------------------------------------------------------------
function createMockHub() {
const handlers: Record<string, ((...args: unknown[]) => void)[]> = {};
return {
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
on: vi.fn((method: string, handler: (...args: unknown[]) => void) => {
const list = handlers[method] ?? [];
handlers[method] = list;
list.push(handler);
}),
off: vi.fn(),
onclose: vi.fn(),
onreconnecting: vi.fn(),
onreconnected: vi.fn(),
_simulateMessage(channel: string, ...args: unknown[]) {
for (const h of handlers[channel] ?? []) h(...args);
},
};
}
function installMockHub(hubUrl: string) {
const hub = createMockHub();
const conn = getSharedConnection({ hubUrl });
conn._buildConnection = vi.fn().mockResolvedValue(hub);
return { hub, conn };
}
// ---------------------------------------------------------------------------
// Test fixture
// ---------------------------------------------------------------------------
function makeFlight(id: string): ISimpleFlight {
return {
routeType: "Direct",
flightId: {
carrier: "SU",
flightNumber: "100",
suffix: "",
date: "2025-01-15",
} satisfies IFlightId,
flyingTime: "3h",
operatingBy: {},
id,
status: "Scheduled",
leg: {
arrival: {
scheduled: {
airport: "LED",
airportCode: "LED",
city: "St Petersburg",
cityCode: "LED",
countryCode: "RU",
},
times: {
scheduledArrival: {
dayChange: { value: 0, title: "" },
local: "",
localTime: "",
tzOffset: 3,
utc: "",
},
},
},
departure: {
scheduled: {
airport: "SVO",
airportCode: "SVO",
city: "Moscow",
cityCode: "MOW",
countryCode: "RU",
},
checkingStatus: "Open",
times: {
scheduledDeparture: {
dayChange: { value: 0, title: "" },
local: "",
localTime: "",
tzOffset: 3,
utc: "",
},
},
},
dayChange: 0,
equipment: {},
flags: {
checkinAvailable: true,
returnToAirport: false,
routeChanged: false,
},
flyingTime: "3h",
index: 0,
operatingBy: {},
status: "Scheduled",
updated: "2025-01-15T00:00:00Z",
} satisfies IFlightLeg,
};
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("buildBoardChannelKey", () => {
it("builds key with date only", () => {
expect(buildBoardChannelKey({ date: "20250115" })).toBe(
"board:20250115::",
);
});
it("builds key with date + departure", () => {
expect(
buildBoardChannelKey({ date: "20250115", departure: "SVO" }),
).toBe("board:20250115:SVO:");
});
it("builds key with date + departure + arrival", () => {
expect(
buildBoardChannelKey({
date: "20250115",
departure: "SVO",
arrival: "LED",
}),
).toBe("board:20250115:SVO:LED");
});
});
describe("useLiveBoardSearch", () => {
const HUB_URL = "https://hub.test/tracker";
beforeEach(() => {
vi.useFakeTimers();
_resetSharedConnections();
});
afterEach(() => {
vi.useRealTimers();
});
it("returns initial flights on mount", async () => {
installMockHub(HUB_URL);
const initial = [makeFlight("f1")];
const params: LiveBoardSearchParams = { date: "20250115" };
const { result } = renderHook(() =>
useLiveBoardSearch(params, initial),
);
await act(async () => {
await vi.runAllTimersAsync();
});
expect(result.current.flights).toEqual(initial);
});
it("subscribes to the correct channel", async () => {
const { hub } = installMockHub(HUB_URL);
const params: LiveBoardSearchParams = {
date: "20250115",
departure: "SVO",
arrival: "LED",
};
renderHook(() => useLiveBoardSearch(params, []));
await act(async () => {
await vi.runAllTimersAsync();
});
expect(hub.on).toHaveBeenCalledWith(
"board:20250115:SVO:LED",
expect.any(Function),
);
});
it("updates flights when a SignalR message arrives", async () => {
const { hub } = installMockHub(HUB_URL);
const params: LiveBoardSearchParams = { date: "20250115" };
const initial = [makeFlight("f1")];
const channel = "board:20250115::";
const { result } = renderHook(() =>
useLiveBoardSearch(params, initial),
);
await act(async () => {
await vi.runAllTimersAsync();
});
const updated = [makeFlight("f2")];
act(() => {
hub._simulateMessage(channel, updated);
});
expect(result.current.flights).toEqual(updated);
});
it("returns idle connectionStatus during SSR", () => {
const origWindow = globalThis.window;
// @ts-expect-error -- intentionally deleting window for SSR simulation
delete globalThis.window;
try {
_resetSharedConnections();
installMockHub(HUB_URL);
// Without window, renderHook won't work (no DOM),
// but we can verify the SSR guard in useLiveFlights
// by checking the underlying shared connection isn't subscribed
const conn = getSharedConnection({ hubUrl: HUB_URL });
const subscribeSpy = vi.spyOn(conn, "subscribe");
expect(subscribeSpy).not.toHaveBeenCalled();
subscribeSpy.mockRestore();
} finally {
globalThis.window = origWindow;
}
});
});
@@ -0,0 +1,67 @@
/**
* Live SignalR hook for Online Board search pages.
*
* Composes the generic `useLiveFlights` (1E) with TrackerHub's
* `SubscribeDate` channel. When the server pushes a `RefreshDate`
* message, `useLiveFlights` replaces the flight list atomically.
*
* Client-only — SSR is handled by `useLiveFlights` internally.
*
* @module
*/
import { useMemo } from "react";
import {
useLiveFlights,
type UseLiveFlightsConfig,
} from "@/shared/hooks/useLiveFlights.js";
import type { ConnectionStatus } from "@/shared/signalr/connection.js";
import type { ISimpleFlight } from "../types.js";
import { getEnv } from "@/env/index.js";
// ---------------------------------------------------------------------------
// Param & result types
// ---------------------------------------------------------------------------
export interface LiveBoardSearchParams {
date: string;
departure?: string;
arrival?: string;
}
export interface UseLiveBoardSearchResult {
flights: ISimpleFlight[];
connectionStatus: ConnectionStatus;
}
// ---------------------------------------------------------------------------
// Channel key builder (exported for testing)
// ---------------------------------------------------------------------------
export function buildBoardChannelKey(params: LiveBoardSearchParams): string {
return `board:${params.date}:${params.departure ?? ""}:${params.arrival ?? ""}`;
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export function useLiveBoardSearch(
params: LiveBoardSearchParams,
initialFlights: ISimpleFlight[],
): UseLiveBoardSearchResult {
const config = useMemo<UseLiveFlightsConfig<LiveBoardSearchParams>>(
() => ({
hubUrl: getEnv().SIGNALR_HUB_URL,
channelKey: buildBoardChannelKey,
}),
[],
);
const { data, connectionStatus } = useLiveFlights<
LiveBoardSearchParams,
ISimpleFlight
>(params, initialFlights, config);
return { flights: data, connectionStatus };
}
@@ -0,0 +1,280 @@
/**
* @vitest-environment jsdom
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import {
_resetSharedConnections,
getSharedConnection,
} from "@/shared/signalr/connection.js";
import {
buildFlightChannelKey,
useLiveFlightDetails,
} from "./useLiveFlightDetails.js";
import type { ISimpleFlight, IFlightId, IFlightLeg, IParsedFlightId } from "../types.js";
// ---------------------------------------------------------------------------
// Env mock
// ---------------------------------------------------------------------------
vi.mock("@/env/index.js", () => ({
getEnv: () => ({
SIGNALR_HUB_URL: "https://hub.test/tracker",
}),
}));
// ---------------------------------------------------------------------------
// Mock hub helpers
// ---------------------------------------------------------------------------
function createMockHub() {
const handlers: Record<string, ((...args: unknown[]) => void)[]> = {};
return {
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
on: vi.fn((method: string, handler: (...args: unknown[]) => void) => {
const list = handlers[method] ?? [];
handlers[method] = list;
list.push(handler);
}),
off: vi.fn(),
onclose: vi.fn(),
onreconnecting: vi.fn(),
onreconnected: vi.fn(),
_simulateMessage(channel: string, ...args: unknown[]) {
for (const h of handlers[channel] ?? []) h(...args);
},
};
}
function installMockHub(hubUrl: string) {
const hub = createMockHub();
const conn = getSharedConnection({ hubUrl });
conn._buildConnection = vi.fn().mockResolvedValue(hub);
return { hub, conn };
}
// ---------------------------------------------------------------------------
// Test fixture
// ---------------------------------------------------------------------------
function makeFlight(id: string): ISimpleFlight {
return {
routeType: "Direct",
flightId: {
carrier: "SU",
flightNumber: "100",
suffix: "",
date: "2025-01-15",
} satisfies IFlightId,
flyingTime: "3h",
operatingBy: {},
id,
status: "Scheduled",
leg: {
arrival: {
scheduled: {
airport: "LED",
airportCode: "LED",
city: "St Petersburg",
cityCode: "LED",
countryCode: "RU",
},
times: {
scheduledArrival: {
dayChange: { value: 0, title: "" },
local: "",
localTime: "",
tzOffset: 3,
utc: "",
},
},
},
departure: {
scheduled: {
airport: "SVO",
airportCode: "SVO",
city: "Moscow",
cityCode: "MOW",
countryCode: "RU",
},
checkingStatus: "Open",
times: {
scheduledDeparture: {
dayChange: { value: 0, title: "" },
local: "",
localTime: "",
tzOffset: 3,
utc: "",
},
},
},
dayChange: 0,
equipment: {},
flags: {
checkinAvailable: true,
returnToAirport: false,
routeChanged: false,
},
flyingTime: "3h",
index: 0,
operatingBy: {},
status: "Scheduled",
updated: "2025-01-15T00:00:00Z",
} satisfies IFlightLeg,
};
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("buildFlightChannelKey", () => {
it("builds key without suffix", () => {
const id: IParsedFlightId = {
carrier: "SU",
flightNumber: "100",
date: "2025-01-15",
};
expect(buildFlightChannelKey(id)).toBe("flight:SU100@2025-01-15");
});
it("builds key with suffix", () => {
const id: IParsedFlightId = {
carrier: "SU",
flightNumber: "100",
suffix: "A",
date: "2025-01-15",
};
expect(buildFlightChannelKey(id)).toBe("flight:SU100A@2025-01-15");
});
it("builds key with empty suffix", () => {
const id: IParsedFlightId = {
carrier: "SU",
flightNumber: "100",
suffix: "",
date: "2025-01-15",
};
expect(buildFlightChannelKey(id)).toBe("flight:SU100@2025-01-15");
});
});
describe("useLiveFlightDetails", () => {
const HUB_URL = "https://hub.test/tracker";
beforeEach(() => {
vi.useFakeTimers();
_resetSharedConnections();
});
afterEach(() => {
vi.useRealTimers();
});
it("returns initial flight on mount", async () => {
installMockHub(HUB_URL);
const initial = makeFlight("f1");
const id: IParsedFlightId = {
carrier: "SU",
flightNumber: "100",
date: "2025-01-15",
};
const { result } = renderHook(() =>
useLiveFlightDetails(id, initial),
);
await act(async () => {
await vi.runAllTimersAsync();
});
expect(result.current.flight).toEqual(initial);
});
it("returns null when initial flight is null", async () => {
installMockHub(HUB_URL);
const id: IParsedFlightId = {
carrier: "SU",
flightNumber: "100",
date: "2025-01-15",
};
const { result } = renderHook(() =>
useLiveFlightDetails(id, null),
);
await act(async () => {
await vi.runAllTimersAsync();
});
expect(result.current.flight).toBeNull();
});
it("subscribes to the correct channel", async () => {
const { hub } = installMockHub(HUB_URL);
const id: IParsedFlightId = {
carrier: "SU",
flightNumber: "100",
suffix: "A",
date: "2025-01-15",
};
renderHook(() => useLiveFlightDetails(id, null));
await act(async () => {
await vi.runAllTimersAsync();
});
expect(hub.on).toHaveBeenCalledWith(
"flight:SU100A@2025-01-15",
expect.any(Function),
);
});
it("updates flight when a SignalR message arrives", async () => {
const { hub } = installMockHub(HUB_URL);
const id: IParsedFlightId = {
carrier: "SU",
flightNumber: "100",
date: "2025-01-15",
};
const channel = "flight:SU100@2025-01-15";
const initial = makeFlight("f1");
const { result } = renderHook(() =>
useLiveFlightDetails(id, initial),
);
await act(async () => {
await vi.runAllTimersAsync();
});
const updated = [makeFlight("f2")];
act(() => {
hub._simulateMessage(channel, updated);
});
// useLiveFlights replaces the full array; our hook takes [0]
expect(result.current.flight).toEqual(updated[0]);
});
it("returns idle connectionStatus during SSR", () => {
const origWindow = globalThis.window;
// @ts-expect-error -- intentionally deleting window for SSR simulation
delete globalThis.window;
try {
_resetSharedConnections();
installMockHub(HUB_URL);
const conn = getSharedConnection({ hubUrl: HUB_URL });
const subscribeSpy = vi.spyOn(conn, "subscribe");
expect(subscribeSpy).not.toHaveBeenCalled();
subscribeSpy.mockRestore();
} finally {
globalThis.window = origWindow;
}
});
});
@@ -0,0 +1,69 @@
/**
* Live SignalR hook for the Online Board flight details page.
*
* Composes the generic `useLiveFlights` (1E) with TrackerHub's
* `Subscribe` channel for a single flight. When the server pushes a
* `Refresh` message, `useLiveFlights` replaces the flight data.
*
* Client-only — SSR is handled by `useLiveFlights` internally.
*
* @module
*/
import { useMemo } from "react";
import {
useLiveFlights,
type UseLiveFlightsConfig,
} from "@/shared/hooks/useLiveFlights.js";
import type { ConnectionStatus } from "@/shared/signalr/connection.js";
import type { ISimpleFlight, IParsedFlightId } from "../types.js";
import { getEnv } from "@/env/index.js";
// ---------------------------------------------------------------------------
// Result type
// ---------------------------------------------------------------------------
export interface UseLiveFlightDetailsResult {
flight: ISimpleFlight | null;
connectionStatus: ConnectionStatus;
}
// ---------------------------------------------------------------------------
// Channel key builder (exported for testing)
// ---------------------------------------------------------------------------
export function buildFlightChannelKey(id: IParsedFlightId): string {
return `flight:${id.carrier}${id.flightNumber}${id.suffix ?? ""}@${id.date}`;
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export function useLiveFlightDetails(
id: IParsedFlightId,
initialFlight: ISimpleFlight | null,
): UseLiveFlightDetailsResult {
const config = useMemo<UseLiveFlightsConfig<IParsedFlightId>>(
() => ({
hubUrl: getEnv().SIGNALR_HUB_URL,
channelKey: buildFlightChannelKey,
}),
[],
);
// useLiveFlights expects an array — wrap/unwrap the single flight
const initialData = useMemo(
() => (initialFlight ? [initialFlight] : []),
[initialFlight],
);
const { data, connectionStatus } = useLiveFlights<
IParsedFlightId,
ISimpleFlight
>(id, initialData, config);
const flight = data[0] ?? null;
return { flight, connectionStatus };
}
+6
View File
@@ -29,3 +29,9 @@ export { useFlightDetails } from "./hooks/useFlightDetails.js";
export type { UseFlightDetailsResult } from "./hooks/useFlightDetails.js";
export { useCalendarDays } from "./hooks/useCalendarDays.js";
export type { UseCalendarDaysResult } from "./hooks/useCalendarDays.js";
// 2D — SignalR wiring hooks
export { useLiveBoardSearch } from "./hooks/useLiveBoardSearch.js";
export type { UseLiveBoardSearchResult } from "./hooks/useLiveBoardSearch.js";
export { useLiveFlightDetails } from "./hooks/useLiveFlightDetails.js";
export type { UseLiveFlightDetailsResult } from "./hooks/useLiveFlightDetails.js";