Финальные фиксы ревью: поиск следует за адресом, остановка опроса на 4xx, блокировка заметки, защита очистки e2e-хаба, раскладка без w, ошибка карточки с данными, спека R-10/R-18 (#21)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-15 00:00:33 +04:00
parent 51564854d6
commit d608a16a30
13 changed files with 128 additions and 27 deletions

View File

@ -61,7 +61,9 @@ SHALL очищать токен и показывать экран входа.
Результат построения модели, видимой выборки, критического пути и проблем SHALL совпадать с результатом `links.html`
на одних и тех же данных и фильтрах. У карточки задачи (не у заглушки) SHALL быть значок «Открыть карточку»,
ведущий на `/app/issues/{key}`; ключ карточки по-прежнему открывает задачу в Jira. Действия «Сменить токен» в меню
доски нет — оно в верхней панели приложения. Загрузка файла CSV/JSON доступна только после входа, с экрана входа её нет.
доски нет — оно в верхней панели приложения. Ещё два отличия от `links.html` (R-10, R-18): двойной щелчок по уголку
карточки меряет высоту по содержимому при новой ширине, а не при прежней; указатель при перетаскивании и растяжке
захватывается на нажатом элементе, а не на карточке, поэтому двойной щелчок по уголку сбрасывает размер. Загрузка файла CSV/JSON доступна только после входа, с экрана входа её нет.
#### Scenario: Раскладка переживает переход
- **WHEN** раскладка карточек сохранена страницей `links.html`, открыта `/app/board` с теми же данными

View File

@ -7,6 +7,11 @@ ROOT=$(cd "$WEB/.." && pwd)
export JIRA_HUB_HOME="${JIRA_HUB_HOME:-$WEB/.e2e-hub}"
PORT="${PORT:-8791}"
case "$JIRA_HUB_HOME" in "$HOME/.local/share/jira-hub"*) echo "отказ: это каталог живого хаба" >&2; exit 2;; esac
# чистим только каталог внутри web/.e2e-hub (T15.5): чужой путь из переменной окружения не удаляем
case "$JIRA_HUB_HOME" in
"$WEB/.e2e-hub"|"$WEB/.e2e-hub/"*) ;;
*) echo "отказ: JIRA_HUB_HOME=$JIRA_HUB_HOME вне $WEB/.e2e-hub, очистка не выполняется" >&2; exit 2;;
esac
rm -rf "$JIRA_HUB_HOME"
cd "$ROOT"
uv run jira-hub init --config tests/fixtures/config.example.json --force >/dev/null

View File

@ -4,14 +4,15 @@ import { JIRA_URL } from "./constants.ts";
import { colWidths, foldedSet, laneOrder, savedLayout } from "./storage.ts";
import type { BoardModel, Filters, Visible } from "./types.ts";
type Saved = Record<string, { x: number; ry: number; w: number; h?: number }>;
type Saved = Record<string, { x: number; ry: number; w?: number; h?: number }>;
export function exportJson(model: BoardModel, vis: Visible, f: Filters, now: string): unknown {
const savedPos = (savedLayout(false) || {}) as Saved, savedDate = (savedLayout(true) || {}) as Saved;
const shown = new Set(vis.nodes.map((n) => n.key));
const fromSaved = (saved: Saved, k: string) => {
const r = saved[k]; if (!r) return null;
const o: { x: number; ry: number; w: number; h?: number } = { x: r.x, ry: r.ry, w: r.w };
const o: { x: number; ry: number; w?: number; h?: number } = { x: r.x, ry: r.ry };
if (typeof r.w === "number") o.w = r.w; // запись без w допустима (как в эталоне)
if (typeof r.h === "number") o.h = r.h;
return o;
};

View File

@ -74,6 +74,8 @@ describe("хранилище = links.html@3931a88", () => {
it("раскладка неверной формы отбрасывается при чтении", () => {
localStorage.setItem("graph-pos:board", JSON.stringify({ "AURORA-1": null, "AURORA-2": { x: "1" }, "AURORA-3": { x: 1, ry: 2, w: 240, h: 90 } }));
expect(savedLayout(false)).toEqual({ "AURORA-3": { x: 1, ry: 2, w: 240, h: 90 } });
localStorage.setItem("graph-pos:board", JSON.stringify({ "AURORA-9003": { x: 30, ry: 50 } }));
expect(savedLayout(false)).toEqual({ "AURORA-9003": { x: 30, ry: 50 } }); // M-1: без w запись принимается (w и h проверяет computeLayout)
localStorage.setItem("graph-pos:board", "[1]");
expect(savedLayout(false)).toBeNull();
});

View File

@ -52,12 +52,12 @@ export function colWidths(): Record<string, number> {
}
const layoutKey = (byDate: boolean): string => fileKey(byDate ? "posdate" : "pos"); // у обычной раскладки и раскладки «по датам» свои хранилища
// раскладка из хранилища; записи неверной формы (не объект, нет чисел x/ry/w) отбрасываются
// раскладка из хранилища; записи неверной формы (не объект, нет чисел x/ry) отбрасываются; w и h проверяет computeLayout
export function savedLayout(byDate: boolean): unknown {
const v = store.json<unknown>(layoutKey(byDate), null);
if (!isObj(v)) return null;
const out: Record<string, unknown> = {};
Object.entries(v).forEach(([k, b]) => { if (isObj(b) && num(b.x) && num(b.ry) && num(b.w) && (b.h === undefined || num(b.h))) out[k] = b; });
Object.entries(v).forEach(([k, b]) => { if (isObj(b) && num(b.x) && num(b.ry)) out[k] = b; });
return out;
}
// сохраняем поверх уже записанного: карточки, скрытые текущим фильтром, не теряют свои места

View File

@ -112,6 +112,20 @@ describe("useBoardSource", () => {
expect(h.count("GET /batches/7")).toBe(n);
});
for (const status of [400, 422]) {
it(`T10.2: ${status} на опросе — опрос остановлен, batch-lost с detail, refreshing сброшен`, async () => {
const h = hub({ "GET /batches/:id": () => json(status, { error: "bad_request", detail: "плохой пакет" }) });
const { result } = hook();
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
await act(() => result.current.refreshFromJira());
await waitFor(() => expect(result.current.status).toMatchObject({ kind: "batch-lost", id: 7, reason: expect.stringContaining("плохой пакет") }));
expect(result.current.refreshing).toBe(false);
const n = h.count("GET /batches/7");
await sleep(50);
expect(h.count("GET /batches/7")).toBe(n);
});
}
it("M-4: 5 ошибок «хаб не отвечает» подряд — опрос остановлен", async () => {
const h = hub({ "GET /batches/:id": () => { throw new TypeError("Failed to fetch"); } });
const { result } = hook();

View File

@ -65,7 +65,7 @@ export function useBoardSource(dicts: Dicts | null, { pollMs = POLL_MS }: { poll
return () => { gone = true; };
}, [board.dataUpdatedAt, active]);
// M-2, M-4: один опрос пакета; остановка на завершении, 404, 401 и после MAX_POLL_ERRORS ошибок подряд
// M-2, M-4: один опрос пакета; остановка на завершении, любой 4xx (T10.2), 401 и после MAX_POLL_ERRORS ошибок подряд
const batch = useQuery({
queryKey: ["batch", active?.id ?? 0],
enabled: active !== null,
@ -84,7 +84,7 @@ export function useBoardSource(dicts: Dicts | null, { pollMs = POLL_MS }: { poll
refetchInterval: (q) => {
const e = q.state.error;
if (terminal(q.state.data)) return false;
if (e instanceof AuthError || (e instanceof ApiError && e.status === 404)) return false;
if (e instanceof AuthError || e instanceof ApiError) return false; // любая 4xx не лечится повтором
if (failures.current >= MAX_POLL_ERRORS) return false;
return pollMs;
},
@ -114,8 +114,8 @@ export function useBoardSource(dicts: Dicts | null, { pollMs = POLL_MS }: { poll
remaining: b.remaining_seconds, agentOffline: active.agentOffline, retrying: false });
return;
}
if (e instanceof ApiError && e.status === 404) {
setEvent({ kind: "batch-lost", id: active.id, reason: "пакета нет в хабе" });
if (e instanceof ApiError) {
setEvent({ kind: "batch-lost", id: active.id, reason: e.status === 404 ? "пакета нет в хабе" : e.detail ?? e.message });
finish(active.id);
} else if (e instanceof HubDownError) {
if (failures.current >= MAX_POLL_ERRORS) {

View File

@ -142,4 +142,40 @@ describe("карточка задачи", () => {
expect(screen.getByLabelText("Заметка владельца")).toHaveValue("");
expect(screen.getByRole("button", { name: "Сохранить" })).toBeDisabled();
});
it("T14.1: поля заметки заблокированы на время сохранения, ответ не затирает ввод", async () => {
const slowPut = deferred<Response>();
mockFetch((m, url) => {
if (url.pathname === "/settings") return json(200, settings);
if (m === "PUT") return slowPut.promise;
return json(200, issue("AURORA-9001"));
});
open("/issues/AURORA-9001");
await userEvent.type(await screen.findByLabelText("Заметка владельца"), "раз");
await userEvent.click(screen.getByRole("button", { name: "Сохранить" }));
const box = screen.getByLabelText("Заметка владельца");
await waitFor(() => expect(box).toBeDisabled());
slowPut.resolve(json(200, note({ owner_note: "раз", updated_by: "web" })));
await waitFor(() => expect(box).toBeEnabled());
expect(box).toHaveValue("раз");
});
it("M-2: ошибка повторной загрузки при уже имеющихся данных — данные остаются, строка с «Повторить»", async () => {
const client = makeQueryClient();
client.setQueryData(qk.issue("AURORA-9001"), issue("AURORA-9001"));
let down = true;
mockFetch((_m, url) => {
if (url.pathname === "/settings") return json(200, settings);
if (down) throw new TypeError("Failed to fetch");
return json(200, issue("AURORA-9001"));
});
open("/issues/AURORA-9001", client);
expect(await screen.findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument();
await act(async () => { await client.invalidateQueries({ queryKey: qk.issue("AURORA-9001") }); }); // повторная загрузка при данных в кэше
expect(await screen.findByRole("alert")).toHaveTextContent("хаб не отвечает");
expect(screen.getByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument();
down = false;
await userEvent.click(screen.getByRole("button", { name: "Повторить" }));
await waitFor(() => expect(screen.queryByRole("alert")).toBeNull());
});
});

View File

@ -19,13 +19,13 @@ export function IssuePage() {
if (issue.error instanceof ApiError && issue.error.status === 404) {
return <p data-testid="page-issue">Задачи {key} нет в зеркале хаба. <Link to="/issues">к списку задач</Link></p>;
}
if (issue.isError) {
if (issue.isError && !issue.data) {
return (
<p className="warn" role="alert" data-testid="page-issue">{issue.error.message}{" "}
<button className="btn sm" type="button" onClick={() => void issue.refetch()}>Повторить</button></p>
);
}
const i = issue.data;
const i = issue.data!;
const fields: [string, string | null][] = [
["Тип", i.issue_type], ["Статус Jira", i.status], ["Приоритет", i.priority], ["Исполнитель", i.assignee],
["Автор", i.reporter], ["Спринт", i.sprint], ["Срок", dueOf(i) || null], ["Плановое начало", i.planned_start],
@ -34,6 +34,10 @@ export function IssuePage() {
];
return (
<div data-testid="page-issue" className="issue">
{issue.isError && (
<p className="warn" role="alert">{issue.error.message}{" "}
<button className="btn sm" type="button" onClick={() => void issue.refetch()}>Повторить</button></p>
)}
<p><Link to="/issues">← задачи</Link></p>
<h1>{i.key} · {i.summary}</h1>
<p><a href={`${JIRA_URL}/browse/${i.key}`} target="_blank" rel="noopener">Открыть в Jira</a></p>

View File

@ -1,4 +1,4 @@
import { screen, waitFor } from "@testing-library/react";
import { act, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { readFileSync } from "node:fs";
import { join } from "node:path";
@ -85,4 +85,27 @@ describe("список задач", () => {
renderWithProviders(<IssuesPage />, { route: "/issues", path: "/issues" });
expect(await screen.findByRole("cell", { name: "Тестовый эпик" })).toBeInTheDocument();
});
it("T13.2: внешняя смена адреса обновляет поле поиска и не перезаписывается через 300 мс", async () => {
hub(() => [row(1)]);
const { router } = renderWithProviders(<IssuesPage />, { route: "/issues?q=свод", path: "/issues" });
const box = await screen.findByLabelText("Поиск по ключу или теме");
expect(box).toHaveValue("свод");
await act(async () => { await router.navigate("/issues"); });
await waitFor(() => expect(box).toHaveValue(""));
await new Promise((r) => setTimeout(r, 450));
expect(router.state.location.search).toBe("");
expect(box).toHaveValue("");
});
it("T13.2: ввод поиска пишется в адрес через replace (шаг «назад» не застревает)", async () => {
hub(() => [row(1)]);
const { router } = renderWithProviders(<IssuesPage />, { route: "/issues", path: "/issues" });
await screen.findByRole("link", { name: "AURORA-9001" });
const before = router.state.historyAction;
await userEvent.type(screen.getByLabelText("Поиск по ключу или теме"), "свод");
await waitFor(() => expect(router.state.location.search).toBe("?q=%D1%81%D0%B2%D0%BE%D0%B4"));
expect(before).toBe("POP");
expect(router.state.historyAction).toBe("REPLACE");
});
});

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Link, useSearchParams } from "react-router";
import { ISSUE_FILTER_NAMES, useBoard, useIssues, useSettings, type IssueFilters } from "../api/queries.ts";
import { dueOf, epicNames, filterOptions } from "./options.ts";
@ -16,9 +16,20 @@ export function IssuesPage() {
Object.fromEntries(ISSUE_FILTER_NAMES.map((n) => [n, params.get(n) ?? ""]).filter(([, v]) => v)), [params]);
const set = (name: string, value: string) => setParams((p) => { const n = new URLSearchParams(p); if (value) n.set(name, value); else n.delete(name); return n; });
const [q, setQ] = useState(filters.q ?? "");
const written = useRef(filters.q ?? ""); // последнее значение q, согласованное с адресом (записанное нами или принятое из него)
useEffect(() => {
// адрес изменён извне (назад, щелчок по «Задачи»): поле поиска следует за ним
const fromUrl = filters.q ?? "";
if (fromUrl === written.current) return;
written.current = fromUrl;
setQ(fromUrl);
}, [filters.q]);
useEffect(() => {
if (q === (filters.q ?? "")) return;
const t = setTimeout(() => setParams((p) => { const n = new URLSearchParams(p); if (q) n.set("q", q); else n.delete("q"); return n; }), Q_DELAY);
const t = setTimeout(() => {
written.current = q;
setParams((p) => { const n = new URLSearchParams(p); if (q) n.set("q", q); else n.delete("q"); return n; }, { replace: true });
}, Q_DELAY);
return () => clearTimeout(t);
}, [q, filters.q, setParams]);

View File

@ -48,19 +48,21 @@ export function NoteForm({ issue }: { issue: IssueDetail }) {
return (
<form className="note" onSubmit={(e) => { e.preventDefault(); submit(); }}>
<h3>Служебные заметки</h3>
<label>Вид работы
<select value={values.work_kind} onChange={set("work_kind")}>
<option value="">—</option>
{kindOptions.map((k) => <option key={k} value={k}>{k}</option>)}
</select>
</label>
<label>Статус по факту
<input list="fact-statuses" value={values.fact_status} onChange={set("fact_status")} />
<datalist id="fact-statuses">{facts.map((f) => <option key={f} value={f} />)}</datalist>
</label>
{TEXTS.map(([f, label]) => (
<label key={f}>{label}<textarea rows={3} value={values[f]} onChange={set(f)} /></label>
))}
<fieldset className="note-fields" disabled={save.isPending}>
<label>Вид работы
<select value={values.work_kind} onChange={set("work_kind")}>
<option value="">—</option>
{kindOptions.map((k) => <option key={k} value={k}>{k}</option>)}
</select>
</label>
<label>Статус по факту
<input list="fact-statuses" value={values.fact_status} onChange={set("fact_status")} />
<datalist id="fact-statuses">{facts.map((f) => <option key={f} value={f} />)}</datalist>
</label>
{TEXTS.map(([f, label]) => (
<label key={f}>{label}<textarea rows={3} value={values[f]} onChange={set(f)} /></label>
))}
</fieldset>
{err && <p className="err" role="alert">{err}</p>}
<div className="row">
<button className="btn" type="submit" disabled={!dirty || save.isPending}>Сохранить</button>

View File

@ -14,6 +14,7 @@
.issue .description{white-space:pre-wrap;font:inherit;background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:10px}
.issue .links{padding-left:18px}
.note{display:flex;flex-direction:column;gap:10px;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px;align-self:start}
.note fieldset{display:contents;border:0;margin:0;padding:0;min-width:0}
.note label{display:flex;flex-direction:column;gap:4px;font-size:13px;color:var(--muted)}
.note input,.note select,.note textarea{font:inherit;font-size:14px;color:var(--fg);background:var(--card);border:1px solid var(--line);border-radius:6px;padding:6px 8px}
.note .err{color:var(--p-crit)}