Audit «Вы искали» search history per TZ 4.1.9.5
TZ §4.1.9.5 requires session-scoped history ("в рамках одной сессии").
Migrate useSearchHistory from localStorage to sessionStorage so history
clears on tab close / page reload.
Add schema-validated get/set/deleteNs helpers to sessionStore in storage.ts
so the hook stays under no-restricted-globals constraints.
Fix hover style in SearchHistory.scss: TZ specifies голубая подложка with
white text/icon on hover — replace the near-white tint with the full blue.
Add TZ §4.1.9.5 assertion tests: session storage target, dedup + bump-to-top,
most-recent-first ordering, item types, empty initial state.
This commit is contained in:
@@ -8,10 +8,10 @@ import { useSearchHistory } from "./useSearchHistory";
|
||||
import type { SearchHistoryItem } from "./useSearchHistory";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// localStorage mock
|
||||
// sessionStorage mock (TZ §4.1.9.5: session-scoped — cleared on reload)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createMockLocalStorage() {
|
||||
function createMockSessionStorage() {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store.get(key) ?? null),
|
||||
@@ -46,16 +46,28 @@ const routeItem: SearchHistoryItem = {
|
||||
label: "SVO - JFK",
|
||||
};
|
||||
|
||||
const scheduleItem: SearchHistoryItem = {
|
||||
type: "schedule-route",
|
||||
url: "/ru/schedule/SVO-LED-20260601-20260607",
|
||||
label: "SVO — LED",
|
||||
params: {
|
||||
departure: "SVO",
|
||||
arrival: "LED",
|
||||
dateFrom: "20260601",
|
||||
dateTo: "20260607",
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("useSearchHistory", () => {
|
||||
let mockStorage: ReturnType<typeof createMockLocalStorage>;
|
||||
let mockStorage: ReturnType<typeof createMockSessionStorage>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockStorage = createMockLocalStorage();
|
||||
vi.stubGlobal("localStorage", mockStorage);
|
||||
mockStorage = createMockSessionStorage();
|
||||
vi.stubGlobal("sessionStorage", mockStorage);
|
||||
});
|
||||
|
||||
it("starts with empty history when nothing in storage", () => {
|
||||
@@ -100,7 +112,8 @@ describe("useSearchHistory", () => {
|
||||
expect(result.current.items).toEqual([routeItem, flightItem]);
|
||||
});
|
||||
|
||||
it("deduplicates by URL — skips if most recent matches", () => {
|
||||
// TZ §4.1.9.5: dedup — same search must not appear twice
|
||||
it("4.1.9.5-R: deduplicates by URL — skips if most recent matches", () => {
|
||||
const { result } = renderHook(() => useSearchHistory("ru"));
|
||||
|
||||
act(() => {
|
||||
@@ -113,7 +126,8 @@ describe("useSearchHistory", () => {
|
||||
expect(result.current.items).toEqual([flightItem]);
|
||||
});
|
||||
|
||||
it("moves duplicate to front when URL exists in older position", () => {
|
||||
// TZ §4.1.9.5: re-running a saved search pushes it to top
|
||||
it("4.1.9.5-R: re-running an older search moves it to top (dedup + bump)", () => {
|
||||
const { result } = renderHook(() => useSearchHistory("ru"));
|
||||
|
||||
act(() => {
|
||||
@@ -190,4 +204,62 @@ describe("useSearchHistory", () => {
|
||||
const { result } = renderHook(() => useSearchHistory("ru"));
|
||||
expect(result.current.items).toEqual([]);
|
||||
});
|
||||
|
||||
// TZ §4.1.9.5: session-scoped — sessionStorage, NOT localStorage
|
||||
it("4.1.9.5-R: writes to sessionStorage, not localStorage", () => {
|
||||
const localStorageMock = createMockSessionStorage();
|
||||
vi.stubGlobal("localStorage", localStorageMock);
|
||||
|
||||
const { result } = renderHook(() => useSearchHistory("ru"));
|
||||
|
||||
act(() => {
|
||||
result.current.add(flightItem);
|
||||
});
|
||||
|
||||
// sessionStorage.setItem must have been called with the history key
|
||||
expect(mockStorage.setItem).toHaveBeenCalledWith(
|
||||
"afl_history_ru",
|
||||
expect.any(String),
|
||||
);
|
||||
// localStorage must NOT have been touched for history writes
|
||||
expect(localStorageMock.setItem).not.toHaveBeenCalledWith(
|
||||
"afl_history_ru",
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
|
||||
// TZ §4.1.9.5: supports board-route, schedule-route, flight-number types
|
||||
it("4.1.9.5-R: stores schedule-route items correctly", () => {
|
||||
const { result } = renderHook(() => useSearchHistory("ru"));
|
||||
|
||||
act(() => {
|
||||
result.current.add(scheduleItem);
|
||||
});
|
||||
|
||||
expect(result.current.items[0]).toMatchObject({
|
||||
type: "schedule-route",
|
||||
params: { departure: "SVO", arrival: "LED" },
|
||||
});
|
||||
});
|
||||
|
||||
// TZ §4.1.9.5: ordering — most-recent first
|
||||
it("4.1.9.5-R: items are ordered most-recent first", () => {
|
||||
const { result } = renderHook(() => useSearchHistory("ru"));
|
||||
|
||||
act(() => {
|
||||
result.current.add(flightItem);
|
||||
result.current.add(routeItem);
|
||||
result.current.add(scheduleItem);
|
||||
});
|
||||
|
||||
expect(result.current.items[0]?.type).toBe("schedule-route");
|
||||
expect(result.current.items[1]?.type).toBe("board-route");
|
||||
expect(result.current.items[2]?.type).toBe("flight-number");
|
||||
});
|
||||
|
||||
// TZ §4.1.9.5: block shows when there are items, hidden when empty
|
||||
it("4.1.9.5-R: returns empty array initially (block starts hidden)", () => {
|
||||
const { result } = renderHook(() => useSearchHistory("ru"));
|
||||
expect(result.current.items).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
/**
|
||||
* Search history hook backed by localStorage via `@/shared/storage`.
|
||||
* Search history hook backed by sessionStorage via `@/shared/storage`.
|
||||
*
|
||||
* Session-scoped per TZ §4.1.9.5: "last searches within one session".
|
||||
* Cleared on tab close / page reload — never persists across sessions.
|
||||
* History is namespaced per language so switching locales shows the
|
||||
* appropriate history.
|
||||
*
|
||||
* 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.
|
||||
* services/history/search-history.service.ts).
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { z } from "zod";
|
||||
import { storage } from "@/shared/storage.js";
|
||||
import { sessionStore } from "@/shared/storage.js";
|
||||
|
||||
// Custom event fired whenever any hook instance mutates search history.
|
||||
// Other live hook instances listen for it and re-read from storage so
|
||||
@@ -124,7 +127,7 @@ export function useSearchHistory(lang: string): UseSearchHistoryResult {
|
||||
const key = storageKey(lang);
|
||||
|
||||
const [items, setItems] = useState<SearchHistoryItem[]>(() => {
|
||||
return (storage.get(key, searchHistorySchema) ?? []) as SearchHistoryItem[];
|
||||
return (sessionStore.get(key, searchHistorySchema) ?? []) as SearchHistoryItem[];
|
||||
});
|
||||
|
||||
// Re-read storage whenever any instance broadcasts a change. Covers
|
||||
@@ -135,7 +138,7 @@ export function useSearchHistory(lang: string): UseSearchHistoryResult {
|
||||
if (typeof window === "undefined") return;
|
||||
const handler = (): void => {
|
||||
const fresh =
|
||||
(storage.get(key, searchHistorySchema) ?? []) as SearchHistoryItem[];
|
||||
(sessionStore.get(key, searchHistorySchema) ?? []) as SearchHistoryItem[];
|
||||
setItems(fresh);
|
||||
};
|
||||
window.addEventListener(SEARCH_HISTORY_CHANGED_EVENT, handler);
|
||||
@@ -154,7 +157,7 @@ export function useSearchHistory(lang: string): UseSearchHistoryResult {
|
||||
const filtered = prev.filter((existing) => existing.url !== item.url);
|
||||
const next = [item, ...filtered].slice(0, MAX_HISTORY_ITEMS);
|
||||
|
||||
storage.set(key, next, searchHistorySchema);
|
||||
sessionStore.set(key, next, searchHistorySchema);
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new Event(SEARCH_HISTORY_CHANGED_EVENT));
|
||||
}
|
||||
@@ -165,7 +168,7 @@ export function useSearchHistory(lang: string): UseSearchHistoryResult {
|
||||
);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
storage.delete(key);
|
||||
sessionStore.deleteNs(key);
|
||||
setItems([]);
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new Event(SEARCH_HISTORY_CHANGED_EVENT));
|
||||
|
||||
@@ -52,6 +52,9 @@ export const storage = {
|
||||
* Exists so the rest of the codebase can stay under the
|
||||
* `no-restricted-globals` rule that blocks direct sessionStorage access.
|
||||
*/
|
||||
|
||||
const SESSION_PREFIX = "afl_";
|
||||
|
||||
export const sessionStore = {
|
||||
getRaw(key: string): string | null {
|
||||
if (typeof sessionStorage === "undefined") return null;
|
||||
@@ -88,4 +91,28 @@ export const sessionStore = {
|
||||
// ignore disabled
|
||||
}
|
||||
},
|
||||
|
||||
/** Schema-validated get from sessionStorage under the `afl_` namespace. */
|
||||
get<T>(key: string, schema: import("zod").ZodSchema<T>): T | null {
|
||||
try {
|
||||
const raw = this.getRaw(SESSION_PREFIX + key);
|
||||
if (raw === null) return null;
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
const result = schema.safeParse(parsed);
|
||||
return result.success ? result.data : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/** Schema-validated set into sessionStorage under the `afl_` namespace. */
|
||||
set<T>(key: string, value: T, schema: import("zod").ZodSchema<T>): void {
|
||||
schema.parse(value);
|
||||
this.setRaw(SESSION_PREFIX + key, JSON.stringify(value));
|
||||
},
|
||||
|
||||
/** Delete a key from the `afl_` namespace in sessionStorage. */
|
||||
deleteNs(key: string): void {
|
||||
this.delete(SESSION_PREFIX + key);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -80,14 +80,17 @@
|
||||
border-top: 1px solid colors.$border;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
// Angular highlights search-history rows with $blue-extra-light
|
||||
// (#f3f9ff) on hover — same tint as the frame background.
|
||||
// TZ §4.1.9.5: hover → голубая подложка, текст и иконка становятся белыми.
|
||||
&:hover {
|
||||
background-color: colors.$blue-extra-light;
|
||||
background-color: colors.$blue;
|
||||
color: colors.$white;
|
||||
}
|
||||
|
||||
&:hover .search-history-item__icon {
|
||||
color: colors.$blue;
|
||||
&:hover .search-history-item__icon,
|
||||
&:hover .search-history-item__title,
|
||||
&:hover .search-history-item__city,
|
||||
&:hover .search-history-item__date {
|
||||
color: colors.$white;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
|
||||
Reference in New Issue
Block a user