diff --git a/web/src/modules/wiki/Summary.test.tsx b/web/src/modules/wiki/Summary.test.tsx
index cb7e620..5936642 100644
--- a/web/src/modules/wiki/Summary.test.tsx
+++ b/web/src/modules/wiki/Summary.test.tsx
@@ -17,4 +17,9 @@ describe("сводка вики", () => {
expect(screen.getByText("ещё не синхронизирована")).toBeInTheDocument();
expect(screen.queryByText(/0 страниц/)).toBeNull();
});
+
+ it("у проекта с корнем — ссылка «Правки» на ленту с источником вики", () => {
+ renderWithProviders();
+ expect(screen.getByRole("link", { name: "Правки" })).toHaveAttribute("href", "/feed?project=AWPCA&sources=wiki");
+ });
});
diff --git a/web/src/modules/wiki/Summary.tsx b/web/src/modules/wiki/Summary.tsx
index 70ed80e..43e205c 100644
--- a/web/src/modules/wiki/Summary.tsx
+++ b/web/src/modules/wiki/Summary.tsx
@@ -26,6 +26,7 @@ export function Summary({ project }: { project: Project }) {
{pages === null ? "ещё не синхронизирована" : `${pages} страниц`}
Вики
+ Правки
);
}
diff --git a/web/src/modules/wiki/api/queries.ts b/web/src/modules/wiki/api/queries.ts
index fd6cf2c..771db73 100644
--- a/web/src/modules/wiki/api/queries.ts
+++ b/web/src/modules/wiki/api/queries.ts
@@ -4,7 +4,7 @@ import { api } from "../../../api/client.ts";
export interface WikiBatch { id: number; status: string }
export interface WikiStatus {
- root: string; root_title: string | null; synced_at: string | null; pages: number; error: string | null; batch: WikiBatch | null;
+ root: string; root_title: string | null; synced_at: string | null; history_at?: string | null; pages: number; error: string | null; batch: WikiBatch | null;
}
export interface WikiNode { code: string; title: string | null; depth: number; parent_code: string | null; size: number | null }
export interface WikiAttachment { id: number; name: string | null; state: string; size: number | null; error?: string | null; image?: boolean }
@@ -14,6 +14,14 @@ export interface WikiPageData {
export interface WikiHit { code: string; title: string | null; path: string; snippet: string }
export interface WikiAttachmentState { id: number; name: string | null; state: string; error: string | null; size: number | null }
+export interface WikiChange {
+ at: string; page_code: string; title: string | null; path: string | null; version: string;
+ author: { login: string | null; name: string | null }; added: number; removed: number;
+}
+export interface WikiChanges {
+ since: string; until: string; synced_at: string | null; history_at: string | null; truncated: boolean; changes: WikiChange[];
+}
+
export const POLL_MS = 3000; // опрос хода синхронизации, пока пакет не завершён
export const SEARCH_LIMIT = 20;
@@ -24,6 +32,7 @@ export const wikiQk = {
tree: (p: string) => ["wiki", p, "tree"] as const,
page: (p: string, code: string) => ["wiki", p, "page", code] as const,
search: (p: string, q: string) => ["wiki", p, "search", q] as const,
+ changes: (p: string, period: string) => ["wiki", p, "changes", period] as const,
};
const terminal = (b: WikiBatch | null) => b === null || b.status === "done" || b.status === "cancelled";
@@ -62,3 +71,14 @@ export const useWikiSearch = (project: string, q: string) =>
queryKey: wikiQk.search(project, q), enabled: q !== "",
queryFn: () => api.get<{ results: WikiHit[] }>("/wiki/search", { project, q, limit: SEARCH_LIMIT }).then((r) => r.results),
});
+
+// правки за период для ленты; границы считает запрос в момент выполнения (перечитывание берёт «до сейчас»)
+export const WIKI_CHANGES_LIMIT = 2000;
+export const useWikiChanges = (project: string, period: string, bounds: () => { since: Date; until: Date }, enabled: boolean) =>
+ useQuery({
+ queryKey: wikiQk.changes(project, period), enabled, refetchOnWindowFocus: true,
+ queryFn: () => {
+ const { since, until } = bounds();
+ return api.get("/wiki/changes", { project, since: since.toISOString(), until: until.toISOString(), limit: WIKI_CHANGES_LIMIT });
+ },
+ });
diff --git a/web/src/modules/wiki/feed/WikiFeedGroup.tsx b/web/src/modules/wiki/feed/WikiFeedGroup.tsx
new file mode 100644
index 0000000..2948f74
--- /dev/null
+++ b/web/src/modules/wiki/feed/WikiFeedGroup.tsx
@@ -0,0 +1,31 @@
+import { Link, useLocation } from "react-router";
+import { Panel } from "../../../ui/index.ts";
+import { eventTime } from "../../../ui/format.ts";
+import { versionHref, wikiPageHref } from "../paths.ts";
+import { authorName, deltaText, type PageGroup } from "./group.ts";
+import "./feed.css";
+
+// группа ленты — одна страница вики: название ссылкой на страницу, путь и правки; правка открывает окно версии поверх ленты
+export function WikiFeedGroup({ project, group, now }: { project: string; group: PageGroup; now: Date }) {
+ const loc = useLocation();
+ const state = { background: { pathname: loc.pathname, search: loc.search }, depth: 1 };
+ return (
+
+
+ {group.title || group.code}
+ {group.path && {group.path}}
+
+
+ {group.changes.map((c) => (
+ -
+
+ {eventTime(c.at, now)}
+ {authorName(c.author)}
+ {deltaText(c)}
+
+
+ ))}
+
+
+ );
+}
diff --git a/web/src/modules/wiki/feed/feed.css b/web/src/modules/wiki/feed/feed.css
new file mode 100644
index 0000000..dc4ec53
--- /dev/null
+++ b/web/src/modules/wiki/feed/feed.css
@@ -0,0 +1,12 @@
+/* источник «Вики» в ленте (spec web-app «Правки вики в ленте»): только токены дизайн-системы */
+.wiki-feed-group{display:flex;flex-direction:column;gap:var(--space-3);min-width:0}
+.wiki-feed-group__head{display:flex;flex-wrap:wrap;align-items:baseline;gap:var(--space-1) var(--space-3)}
+.wiki-feed-group__title{font-weight:600;min-width:0;overflow-wrap:anywhere;color:var(--color-action-primary);text-decoration:none}
+.wiki-feed-group__title:hover{text-decoration:underline}
+.wiki-feed-group__path{min-width:0;overflow-wrap:anywhere;font-size:13px;color:var(--color-text-secondary)}
+.wiki-feed-edits{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:var(--space-2)}
+.wiki-feed-edit{display:flex;flex-wrap:wrap;gap:var(--space-1) var(--space-3);font-size:var(--fs-body);line-height:var(--lh-body);min-width:0;color:var(--color-text-primary);text-decoration:none}
+.wiki-feed-edit:hover .wiki-feed-edit__author{text-decoration:underline}
+.wiki-feed-edit__time{flex:0 0 auto;font-family:var(--font-mono);font-size:13px;color:var(--color-text-secondary);white-space:nowrap}
+.wiki-feed-edit__author{font-weight:600;min-width:0;overflow-wrap:anywhere}
+.wiki-feed-edit__delta{font-family:var(--font-mono);font-size:13px;color:var(--color-text-secondary);white-space:nowrap}
diff --git a/web/src/modules/wiki/feed/group.test.ts b/web/src/modules/wiki/feed/group.test.ts
new file mode 100644
index 0000000..e79b8d4
--- /dev/null
+++ b/web/src/modules/wiki/feed/group.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from "vitest";
+import type { WikiChange } from "../api/queries.ts";
+import { authorName, deltaText, groupByPage } from "./group.ts";
+
+const c = (page_code: string, version: string): WikiChange => ({
+ at: "2026-10-28T09:00:00+00:00", page_code, title: page_code, path: null, version,
+ author: { login: null, name: null }, added: 1, removed: 0,
+});
+
+describe("группировка правок вики", () => {
+ it("по странице, по первому появлению; правки — в порядке ответа", () => {
+ const g = groupByPage([c("DOC-3", "V3"), c("DOC-2", "V2"), c("DOC-3", "V1")]);
+ expect(g.map((x) => x.code)).toEqual(["DOC-3", "DOC-2"]);
+ expect(g[0].changes.map((x) => x.version)).toEqual(["V3", "V1"]);
+ });
+ it("deltaText: плюс, косая черта, минус U+2212", () => {
+ expect(deltaText({ added: 40, removed: 12 })).toBe("+40 / −12");
+ });
+ it("authorName: имя, иначе логин, иначе «—»", () => {
+ expect(authorName({ login: "x", name: null })).toBe("x");
+ expect(authorName({ login: "x", name: "Икс" })).toBe("Икс");
+ expect(authorName({ login: null, name: null })).toBe("—");
+ });
+});
diff --git a/web/src/modules/wiki/feed/group.ts b/web/src/modules/wiki/feed/group.ts
new file mode 100644
index 0000000..1675935
--- /dev/null
+++ b/web/src/modules/wiki/feed/group.ts
@@ -0,0 +1,18 @@
+import type { WikiChange } from "../api/queries.ts";
+
+export type PageGroup = { code: string; title: string | null; path: string | null; changes: WikiChange[] };
+
+// правки одной страницы вместе; группы — по первому появлению (ответ хаба идёт от новых к старым), правки — в порядке ответа
+export function groupByPage(changes: WikiChange[]): PageGroup[] {
+ const by = new Map();
+ for (const c of changes) {
+ let g = by.get(c.page_code);
+ if (!g) { g = { code: c.page_code, title: c.title, path: c.path, changes: [] }; by.set(c.page_code, g); }
+ g.changes.push(c);
+ }
+ return [...by.values()];
+}
+
+export const deltaText = (c: { added: number; removed: number }): string => `+${c.added} / −${c.removed}`;
+
+export const authorName = (a: { login: string | null; name: string | null }): string => a.name || a.login || "—";
diff --git a/web/src/modules/wiki/feed/source.tsx b/web/src/modules/wiki/feed/source.tsx
new file mode 100644
index 0000000..6f0299b
--- /dev/null
+++ b/web/src/modules/wiki/feed/source.tsx
@@ -0,0 +1,29 @@
+import { useMemo } from "react";
+import type { FeedResult, FeedSource, FeedSourceInput } from "../../types.ts";
+import { useWikiChanges } from "../api/queries.ts";
+import { groupByPage } from "./group.ts";
+import { WikiFeedGroup } from "./WikiFeedGroup.tsx";
+
+const EMPTY: FeedResult = { groups: [], count: 0, window: null, pending: false, error: null, truncated: false, notice: null };
+
+function useWikiFeed(input: FeedSourceInput): FeedResult {
+ const project = input.project.label;
+ const q = useWikiChanges(project, input.period, input.bounds, input.enabled);
+ const data = input.enabled ? q.data : undefined;
+ const updatedAt = q.dataUpdatedAt;
+ const groups = useMemo(() => {
+ const now = new Date(updatedAt || Date.now()); // «текущие сутки» для времени правок — на момент ответа
+ return groupByPage(data?.changes ?? []).map((g) => ({
+ key: g.code, at: g.changes[0]?.at ?? "", node: ,
+ }));
+ }, [data, updatedAt, project]);
+ if (!input.enabled) return EMPTY;
+ return {
+ groups, count: data?.changes.length ?? 0, window: data ? { since: data.since, until: data.until } : null,
+ pending: q.isPending, truncated: !!data?.truncated,
+ error: q.isError ? { message: (q.error as Error).message, retry: () => void q.refetch() } : null,
+ notice: data && data.history_at === null ? "история вики ещё не загружена — нужен eva-agent v2" : null,
+ };
+}
+
+export const wikiFeed: FeedSource = { available: (p) => !!p.wiki_root, useFeed: useWikiFeed };
diff --git a/web/src/modules/wiki/index.tsx b/web/src/modules/wiki/index.tsx
index 36d9a82..086d358 100644
--- a/web/src/modules/wiki/index.tsx
+++ b/web/src/modules/wiki/index.tsx
@@ -1,6 +1,7 @@
import { ProjectGate } from "../../projects/ProjectGate.tsx";
import { IconBook } from "../../ui/index.ts";
import type { HubModule } from "../types.ts";
+import { wikiFeed } from "./feed/source.tsx";
import { wikiStatus } from "./status.ts";
import { Summary } from "./Summary.tsx";
import { WikiPage } from "./WikiPage.tsx";
@@ -12,5 +13,6 @@ export const wikiModule: HubModule = {
nav: [{ to: "/wiki", label: "Вики", icon: , padded: true }],
status: wikiStatus,
Summary,
+ feed: wikiFeed,
projectFields: [{ name: "wiki_root", label: "Корень вики", hint: "DOC-… или ссылка на страницу Eva", kind: "text" }],
};
diff --git a/web/src/modules/wiki/paths.ts b/web/src/modules/wiki/paths.ts
new file mode 100644
index 0000000..5c73956
--- /dev/null
+++ b/web/src/modules/wiki/paths.ts
@@ -0,0 +1,10 @@
+// адреса вики: страница с выбранным кодом и окно версии (окно — поверх текущей страницы)
+export const VERSION_PATH = "/wiki/pages/:code/versions/:version";
+
+export function wikiPageHref(project: string, code: string): string {
+ return `/wiki?${new URLSearchParams({ project, page: code })}`;
+}
+
+export function versionHref(project: string, code: string, version: string): string {
+ return `/wiki/pages/${encodeURIComponent(code)}/versions/${encodeURIComponent(version)}?${new URLSearchParams({ project })}`;
+}
diff --git a/web/src/shell/feed/FeedPage.test.tsx b/web/src/shell/feed/FeedPage.test.tsx
index f3342e8..26b9bbe 100644
--- a/web/src/shell/feed/FeedPage.test.tsx
+++ b/web/src/shell/feed/FeedPage.test.tsx
@@ -8,6 +8,7 @@ import { act } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { App } from "../../App.tsx";
import type { ChangeEvent } from "../../modules/jira/api/types.ts";
+import type { WikiChange, WikiChanges } from "../../modules/wiki/api/queries.ts";
import { setToken } from "../../auth/token.ts";
import settings from "../../modules/jira/board/__fixtures__/settings.json";
import { routes } from "../../routes.tsx";
@@ -25,7 +26,12 @@ const ev = (over: Partial): ChangeEvent => ({
});
type Changes = { since?: string; until?: string; truncated?: boolean; changes: ChangeEvent[] } | Response;
-function hub(changes: () => Changes = () => ({ changes: [] })) {
+const wc = (over: Partial = {}): WikiChange => ({
+ at: minutesAgo(20), page_code: "DOC-101252", title: "Методика расчёта", path: "АРМ / Методика", version: "DOCH-2",
+ author: { login: "d.sosnov", name: "Дмитрий Соснов" }, added: 40, removed: 12, ...over,
+});
+type WikiBody = Partial | Response;
+function hub(changes: () => Changes = () => ({ changes: [] }), wiki: () => WikiBody = () => ({ changes: [] })) {
const calls: URL[] = [];
const wikiCalls: URL[] = [];
mockFetch((method, url) => {
@@ -37,7 +43,8 @@ function hub(changes: () => Changes = () => ({ changes: [] })) {
}
if (k === "GET /wiki/changes") {
wikiCalls.push(url);
- return json(200, { since: "", until: "", synced_at: null, history_at: null, truncated: false, changes: [] });
+ const r = wiki();
+ return r instanceof Response ? r : json(200, { since: "", until: "", synced_at: null, history_at: "2026-10-28T09:00:00+00:00", truncated: false, changes: [], ...r });
}
if (k === "GET /projects") return json(200, { projects: [A, B, NOWIKI] });
if (k === "GET /settings") return json(200, settings);
@@ -69,7 +76,14 @@ describe("лента изменений", () => {
expect(document.querySelector(".ui-btn--primary")).toBeNull();
});
- it.todo("шапка у проекта с вики: и «Обновить вики» (П-10) — источник вики появляется в задаче 6");
+ it("шапка у проекта с вики: и «Обновить из Jira», и «Обновить вики» (П-10), обе вторичные", async () => {
+ hub();
+ open("/app/feed?project=AWPCA");
+ await page();
+ expect(screen.getByRole("button", { name: "Обновить из Jira" })).toHaveClass("ui-btn--secondary");
+ expect(screen.getByRole("button", { name: "Обновить вики" })).toHaveClass("ui-btn--secondary");
+ expect(document.querySelector(".ui-btn--primary")).toBeNull();
+ });
it("«Проект без вики»: флажков источников нет, GET /wiki/changes нет", async () => {
const calls = hub(() => ({ changes: [ev({})] }));
@@ -80,9 +94,101 @@ describe("лента изменений", () => {
expect(calls.wiki).toHaveLength(0);
});
- it.todo("сняты все источники — «выберите хотя бы один источник», запросов нет, адрес sources=none — флажки источников появляются в задаче 6");
- it.todo("«Что закрылось за два часа» с источником «Вики»: sources=jira в адресе, GET /wiki/changes после снятия не было — задача 6");
- it.todo("«Ошибка одного источника»: /wiki/changes 500 — события Jira показаны, на месте вики — ошибка с «Повторить» — задача 6");
+ it("сняты все источники — «выберите хотя бы один источник», запросов нет, адрес sources=none", async () => {
+ const calls = hub();
+ const router = open("/app/feed?project=AWPCA");
+ await page();
+ await waitFor(() => { expect(calls.length).toBe(1); expect(calls.wiki.length).toBe(1); });
+ await userEvent.click(screen.getByRole("checkbox", { name: "Jira" }));
+ await userEvent.click(screen.getByRole("checkbox", { name: "Вики" }));
+ expect(await screen.findByText("выберите хотя бы один источник")).toBeInTheDocument();
+ expect(router.state.location.search).toBe("?project=AWPCA&sources=none");
+ expect(calls.length).toBe(1);
+ expect(calls.wiki.length).toBe(1);
+ });
+
+ it("«Что закрылось за два часа»: период 2h, только Статусы и только Jira — sources=jira, к вики запросов после снятия нет", async () => {
+ const calls = hub();
+ const router = open("/app/feed?project=AWPCA");
+ await page();
+ await waitFor(() => expect(calls.wiki.length).toBe(1));
+ await userEvent.click(screen.getByRole("radio", { name: "2 часа" }));
+ await waitFor(() => expect(calls.wiki.length).toBe(2));
+ await userEvent.click(screen.getByRole("checkbox", { name: "Вики" }));
+ for (const name of ["Описание", "Сроки", "Комментарии"]) await userEvent.click(screen.getByRole("checkbox", { name }));
+ await waitFor(() => expect(router.state.location.search).toBe("?project=AWPCA&period=2h&sources=jira&kinds=status"));
+ expect(router.state.location.pathname).toBe("/app/feed");
+ await waitFor(() => expect(calls.at(-1)!.searchParams.get("kinds")).toBe("status"));
+ const since = new Date(calls.at(-1)!.searchParams.get("since")!).getTime();
+ expect(Math.abs(Date.now() - 2 * 3600_000 - since)).toBeLessThan(60_000);
+ expect(calls.wiki.length).toBe(2); // после снятия «Вики» новых запросов нет
+ });
+
+ it("«Правка из ленты»: группа — название и путь, правка — время, автор, «+40 / −12»; запрос с project, since, limit", async () => {
+ const calls = hub(undefined, () => ({ changes: [wc({ at: new Date(new Date().setHours(12, 40, 0, 0)).toISOString() })] }));
+ open("/app/feed?project=AWPCA");
+ const group = await screen.findByTestId("feed-group");
+ const head = within(group).getByRole("link", { name: "Методика расчёта" });
+ expect(head).toHaveAttribute("href", "/app/wiki?project=AWPCA&page=DOC-101252");
+ expect(within(group).getByText("АРМ / Методика")).toBeInTheDocument();
+ const edit = within(group).getByRole("link", { name: /Дмитрий Соснов/ });
+ expect(edit).toHaveAttribute("href", "/app/wiki/pages/DOC-101252/versions/DOCH-2?project=AWPCA");
+ expect(edit.textContent).toContain("+40 / −12");
+ const q = calls.wiki[0].searchParams;
+ expect(q.get("project")).toBe("AWPCA");
+ const midnight = new Date(); midnight.setHours(0, 0, 0, 0);
+ expect(new Date(q.get("since")!).getTime()).toBe(midnight.getTime());
+ expect(q.get("limit")).toBe("2000");
+ expect(screen.getByText(/1 событие/)).toBeInTheDocument();
+ });
+
+ it("«Jira и Вики вперемешку»: группы обоих источников по времени последнего события, новые сверху", async () => {
+ const today = (h: number) => new Date(new Date().setHours(h, 0, 0, 0)).toISOString();
+ hub(() => ({ changes: [ev({ at: today(12), key: "AURORA-2", summary: "Вторая" }), ev({ at: today(10), key: "AURORA-1", summary: "Первая" })] }),
+ () => ({ changes: [wc({ at: today(11), page_code: "DOC-3", title: "Третья" })] }));
+ open("/app/feed?project=AWPCA");
+ await waitFor(() => expect(screen.getAllByTestId("feed-group")).toHaveLength(3));
+ const names = screen.getAllByTestId("feed-group").map((g) => within(g).getAllByRole("link")[0].textContent);
+ expect(names).toEqual(["AURORA-2", "Третья", "AURORA-1"]);
+ });
+
+ it("«История не загружена»: history_at null — подсказка про eva-agent v2, события Jira на месте", async () => {
+ hub(() => ({ changes: [ev({})] }), () => ({ history_at: null }));
+ open("/app/feed?project=AWPCA");
+ expect(await screen.findByText("история вики ещё не загружена — нужен eva-agent v2")).toBeInTheDocument();
+ expect(await screen.findAllByTestId("feed-group")).toHaveLength(1);
+ });
+
+ it("«Ошибка одного источника»: /wiki/changes 500 — события Jira показаны, «Вики: …» и «Повторить» перезапрашивает только вики", async () => {
+ let fail = true;
+ const calls = hub(() => ({ changes: [ev({})] }), () => fail ? json(500, { error: "boom", detail: "сбой" }) : { changes: [wc()] });
+ open("/app/feed?project=AWPCA");
+ expect(await screen.findAllByTestId("feed-group")).toHaveLength(1);
+ expect(await screen.findByText("Вики: хаб ответил 500")).toBeInTheDocument();
+ const jiraCalls = calls.length;
+ fail = false;
+ await userEvent.click(screen.getByRole("button", { name: "Повторить" }));
+ expect(await screen.findByText("Методика расчёта")).toBeInTheDocument();
+ expect(calls.length).toBe(jiraCalls);
+ expect(screen.queryByText("Вики: хаб ответил 500")).toBeNull();
+ });
+
+ it("«Пустой период»: обе ленты пусты — «за период изменений нет»", async () => {
+ hub();
+ open("/app/feed?project=AWPCA");
+ expect(await screen.findByText("за период изменений нет")).toBeInTheDocument();
+ });
+
+ it("смена проекта на sfm_kedr при sources=wiki — источник Jira, флажков нет, к вики запросов нет", async () => {
+ const calls = hub(() => ({ changes: [ev({})] }));
+ open("/app/feed?project=AWPCA&sources=wiki");
+ await waitFor(() => expect(calls.wiki.length).toBe(1));
+ expect(calls.length).toBe(0);
+ await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "sfm_kedr");
+ expect(await screen.findAllByTestId("feed-group")).toHaveLength(1);
+ expect(screen.queryByRole("group", { name: "Источники" })).toBeNull();
+ expect(calls.wiki.length).toBe(1);
+ });
it("прежний /app/jira/feed → /app/feed с тем же search, replace (прежние закладки)", async () => {
hub();
@@ -130,20 +236,6 @@ describe("лента изменений", () => {
expect(screen.getByText(/3 события/)).toBeInTheDocument();
});
- it("«Что закрылось за два часа»: период и виды в адресе и в запросе", async () => {
- const calls = hub();
- const router = open("/app/feed?project=AWPCA");
- await page();
- await userEvent.click(screen.getByRole("radio", { name: "2 часа" }));
- for (const name of ["Описание", "Сроки", "Комментарии"]) await userEvent.click(screen.getByRole("checkbox", { name }));
- await waitFor(() => expect(router.state.location.search).toBe("?project=AWPCA&period=2h&kinds=status")); // источник один (вики — в задаче 6): sources в адрес не пишется
- expect(router.state.location.pathname).toBe("/app/feed");
- expect(calls.wiki).toHaveLength(0);
- await waitFor(() => expect(calls.at(-1)!.searchParams.get("kinds")).toBe("status"));
- const since = new Date(calls.at(-1)!.searchParams.get("since")!).getTime();
- expect(Math.abs(Date.now() - 2 * 3600_000 - since)).toBeLessThan(60_000);
- });
-
it("карточка из ленты — окно поверх, закрытие возвращает ленту с тем же периодом", async () => {
hub(() => ({ changes: [ev({})] }));
const router = open("/app/feed?project=AWPCA&period=7d");