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:
2026-04-21 21:55:30 +03:00
parent c509131649
commit 2b0a7ecbe7
4 changed files with 126 additions and 21 deletions
+79 -7
View File
@@ -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);
});
});
+12 -9
View File
@@ -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));
+27
View File
@@ -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);
},
};
+8 -5
View File
@@ -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 {