plan/react-rewrite #1
@@ -0,0 +1,195 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useSearchHistory } from "./useSearchHistory";
|
||||
import type { SearchHistoryItem } from "./useSearchHistory";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// localStorage mock
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createMockLocalStorage() {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store.set(key, value);
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
store.delete(key);
|
||||
}),
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
key: vi.fn((index: number) => [...store.keys()][index] ?? null),
|
||||
clear: vi.fn(() => store.clear()),
|
||||
store,
|
||||
} satisfies Storage & { store: Map<string, string> };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const flightItem: SearchHistoryItem = {
|
||||
type: "flight-number",
|
||||
url: "/ru/onlineboard/flight/SU0001-20250528",
|
||||
label: "SU 0001",
|
||||
};
|
||||
|
||||
const routeItem: SearchHistoryItem = {
|
||||
type: "board-route",
|
||||
url: "/ru/onlineboard/route/SVO-JFK-20250528",
|
||||
label: "SVO - JFK",
|
||||
};
|
||||
|
||||
const scheduleItem: SearchHistoryItem = {
|
||||
type: "schedule-route",
|
||||
url: "/ru/schedule/route/SVO-LED/20250601-20250607",
|
||||
label: "SVO - LED",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("useSearchHistory", () => {
|
||||
let mockStorage: ReturnType<typeof createMockLocalStorage>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockStorage = createMockLocalStorage();
|
||||
vi.stubGlobal("localStorage", mockStorage);
|
||||
});
|
||||
|
||||
it("starts with empty history when nothing in storage", () => {
|
||||
const { result } = renderHook(() => useSearchHistory("ru"));
|
||||
expect(result.current.items).toEqual([]);
|
||||
});
|
||||
|
||||
it("loads existing history from storage on mount", () => {
|
||||
mockStorage.store.set(
|
||||
"afl_history_ru",
|
||||
JSON.stringify([flightItem]),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useSearchHistory("ru"));
|
||||
expect(result.current.items).toEqual([flightItem]);
|
||||
});
|
||||
|
||||
it("adds an item and persists to storage", () => {
|
||||
const { result } = renderHook(() => useSearchHistory("ru"));
|
||||
|
||||
act(() => {
|
||||
result.current.add(flightItem);
|
||||
});
|
||||
|
||||
expect(result.current.items).toEqual([flightItem]);
|
||||
expect(mockStorage.setItem).toHaveBeenCalledWith(
|
||||
"afl_history_ru",
|
||||
JSON.stringify([flightItem]),
|
||||
);
|
||||
});
|
||||
|
||||
it("prepends new items (most recent first)", () => {
|
||||
const { result } = renderHook(() => useSearchHistory("ru"));
|
||||
|
||||
act(() => {
|
||||
result.current.add(flightItem);
|
||||
});
|
||||
act(() => {
|
||||
result.current.add(routeItem);
|
||||
});
|
||||
|
||||
expect(result.current.items).toEqual([routeItem, flightItem]);
|
||||
});
|
||||
|
||||
it("deduplicates by URL — skips if most recent matches", () => {
|
||||
const { result } = renderHook(() => useSearchHistory("ru"));
|
||||
|
||||
act(() => {
|
||||
result.current.add(flightItem);
|
||||
});
|
||||
act(() => {
|
||||
result.current.add(flightItem);
|
||||
});
|
||||
|
||||
expect(result.current.items).toEqual([flightItem]);
|
||||
});
|
||||
|
||||
it("moves duplicate to front when URL exists in older position", () => {
|
||||
const { result } = renderHook(() => useSearchHistory("ru"));
|
||||
|
||||
act(() => {
|
||||
result.current.add(flightItem);
|
||||
});
|
||||
act(() => {
|
||||
result.current.add(routeItem);
|
||||
});
|
||||
act(() => {
|
||||
result.current.add(flightItem);
|
||||
});
|
||||
|
||||
expect(result.current.items).toEqual([flightItem, routeItem]);
|
||||
});
|
||||
|
||||
it("limits history to 10 items", () => {
|
||||
const { result } = renderHook(() => useSearchHistory("ru"));
|
||||
|
||||
for (let i = 0; i < 12; i++) {
|
||||
act(() => {
|
||||
result.current.add({
|
||||
type: "flight-number",
|
||||
url: `/flight-${i}`,
|
||||
label: `Flight ${i}`,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
expect(result.current.items).toHaveLength(10);
|
||||
// Most recent should be first
|
||||
expect(result.current.items[0]?.url).toBe("/flight-11");
|
||||
});
|
||||
|
||||
it("namespaces by language — different langs have separate history", () => {
|
||||
const { result: ruResult } = renderHook(() => useSearchHistory("ru"));
|
||||
const { result: enResult } = renderHook(() => useSearchHistory("en"));
|
||||
|
||||
act(() => {
|
||||
ruResult.current.add(flightItem);
|
||||
});
|
||||
|
||||
expect(ruResult.current.items).toEqual([flightItem]);
|
||||
expect(enResult.current.items).toEqual([]);
|
||||
});
|
||||
|
||||
it("clear removes all items and deletes from storage", () => {
|
||||
const { result } = renderHook(() => useSearchHistory("ru"));
|
||||
|
||||
act(() => {
|
||||
result.current.add(flightItem);
|
||||
result.current.add(routeItem);
|
||||
});
|
||||
act(() => {
|
||||
result.current.clear();
|
||||
});
|
||||
|
||||
expect(result.current.items).toEqual([]);
|
||||
expect(mockStorage.removeItem).toHaveBeenCalledWith("afl_history_ru");
|
||||
});
|
||||
|
||||
it("gracefully handles corrupted storage data", () => {
|
||||
mockStorage.store.set("afl_history_ru", "not-valid-json{{{");
|
||||
|
||||
const { result } = renderHook(() => useSearchHistory("ru"));
|
||||
expect(result.current.items).toEqual([]);
|
||||
});
|
||||
|
||||
it("gracefully handles invalid schema in storage", () => {
|
||||
mockStorage.store.set(
|
||||
"afl_history_ru",
|
||||
JSON.stringify([{ invalid: true }]),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useSearchHistory("ru"));
|
||||
expect(result.current.items).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Search history hook backed by localStorage via `@/shared/storage`.
|
||||
*
|
||||
* Persists search history per language with namespaced storage keys.
|
||||
* Angular equivalent: `SearchHistoryService` (ClientApp/src/app/shared/
|
||||
* services/history/search-history.service.ts), but enhanced with
|
||||
* localStorage persistence and per-language namespacing.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { z } from "zod";
|
||||
import { storage } from "@/shared/storage.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Search history item type — mirrors Angular's ISearchHistoryItemType */
|
||||
export type SearchHistoryItemType =
|
||||
| "board-route"
|
||||
| "schedule-route"
|
||||
| "flight-number";
|
||||
|
||||
/** Search history item */
|
||||
export interface SearchHistoryItem {
|
||||
type: SearchHistoryItemType;
|
||||
url: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface UseSearchHistoryResult {
|
||||
items: SearchHistoryItem[];
|
||||
add: (item: SearchHistoryItem) => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema (for validated storage round-tripping)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const searchHistoryItemSchema = z.object({
|
||||
type: z.enum(["board-route", "schedule-route", "flight-number"]),
|
||||
url: z.string(),
|
||||
label: z.string(),
|
||||
});
|
||||
|
||||
const searchHistorySchema = z.array(searchHistoryItemSchema);
|
||||
|
||||
type SearchHistoryData = z.infer<typeof searchHistorySchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Storage key
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MAX_HISTORY_ITEMS = 10;
|
||||
|
||||
function storageKey(lang: string): string {
|
||||
return `history_${lang}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Hook for search history persisted to localStorage via `@/shared/storage`.
|
||||
*
|
||||
* @param lang - Current language code (e.g. "ru", "en"). History is
|
||||
* namespaced per language so switching locales shows the appropriate
|
||||
* history.
|
||||
*/
|
||||
export function useSearchHistory(lang: string): UseSearchHistoryResult {
|
||||
const key = storageKey(lang);
|
||||
|
||||
const [items, setItems] = useState<SearchHistoryItem[]>(() => {
|
||||
return storage.get(key, searchHistorySchema) ?? [];
|
||||
});
|
||||
|
||||
const add = useCallback(
|
||||
(item: SearchHistoryItem) => {
|
||||
setItems((prev) => {
|
||||
// Deduplicate by URL — don't add if the most recent entry matches
|
||||
if (prev[0]?.url === item.url) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
// Remove any existing entry with same URL, then prepend
|
||||
const filtered = prev.filter((existing) => existing.url !== item.url);
|
||||
const next = [item, ...filtered].slice(0, MAX_HISTORY_ITEMS);
|
||||
|
||||
storage.set(key, next, searchHistorySchema);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[key],
|
||||
);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
storage.delete(key);
|
||||
setItems([]);
|
||||
}, [key]);
|
||||
|
||||
return { items, add, clear };
|
||||
}
|
||||
Reference in New Issue
Block a user