Чистка находок ревью задач: типы досок в любом статусе, мелкие правки тестов и веб-приложения (#61)
R-3/T6.1 типы досок из результата refresh пишутся в любом статусе отчёта; T5.1, T3.1 (author), T3.2, T2.1, T2.2, T7.2, T8.1, T8.2 (ошибка фонового рефетча проектов не сносит страницу), T8.4 (окно «Новый проект» не закрывается во время создания). Решения 22, 27 переписаны, добавлено 36 в design.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
57145b22c9
commit
1b441b79a5
@ -250,9 +250,9 @@
|
||||
раскладки AWPCA. Цена ошибки: схема ключей в `storage.ts`.
|
||||
21. 2026-10-19 — **`last_sync` после создания проекта — `null` до завершения его обновления, `sync_run.query` не
|
||||
переписывается.** Обоснование: прежний прогон новую метку не покрывал. Цена ошибки: временная надпись на доске.
|
||||
22. 2026-10-19 — **пустой `author` в `POST /projects` — как `api`, нестрока — 422 `bad_project`.** Обоснование: как у
|
||||
остальных маршрутов (`body.get("author") or "api"`); спека требует 422 для не-строки. Цена ошибки: пустая строка
|
||||
пишется автором `api`, а не отвергается.
|
||||
22. 2026-10-19 — **пустая строка и отсутствие `author` в `POST /projects` — как `api`; любое другое нестроковое значение (в том числе `0`, `false`, `[]`, `{}`) — 422 `bad_project`.** Обоснование: как у
|
||||
остальных маршрутов (пусто — `api`); спека требует 422 для не-строки, и `0`/`false` не должны молча становиться `api`. Цена ошибки: пустая
|
||||
строка пишется автором `api`, а не отвергается.
|
||||
23. 2026-10-19 — **`CommandError` из постановки refresh в `POST /projects` — 422 `bad_batch`, а не `bad_project`.**
|
||||
Обоснование: ошибка относится к пакету, а не к полям проекта; спека называет только `project_exists` и
|
||||
`bad_project`. Цена ошибки: код ошибки в ответе.
|
||||
@ -264,9 +264,11 @@
|
||||
26. 2026-10-19 — **`project` в `retry` / `POST /batches` не строка — 422 `bad_retry` / `bad_batch`; в `retry_command`
|
||||
сначала сверка `project` с проектом исходного, затем `create_batch` с проверками проекта.** Обоснование: «команда
|
||||
#N не из проекта P» точнее ошибки проверок. Цена ошибки: текст 422.
|
||||
27. 2026-10-19 — **типы досок из результата `refresh` пишутся только при `status = done`; результат без ключа `boards`
|
||||
или с мусорными значениями типы не трогает.** Обоснование: у проваленной команды результат неполон, а агент v11 ключа
|
||||
не присылает. Цена ошибки: одно условие в `report_result`.
|
||||
27. 2026-10-19 — **типы досок из результата `refresh` пишутся в любом статусе отчёта (`done` и `failed`), если в
|
||||
результате-словаре есть ключ `boards`; без ключа или с мусорными значениями типы не трогаются.** Обоснование: тип
|
||||
доски — факт о доске, а не часть выборки: проваленная выборка задач не делает присланный тип неверным; спека
|
||||
commands требует записать присланные типы по результату refresh. Поздний отчёт по уже терминальной команде типы не
|
||||
пишет (ветка терминальной команды возвращается раньше). Цена ошибки: одно условие в `report_result`.
|
||||
28. 2026-10-19 — **метки временного ключа для `ensure_sprint` берутся только из его `ensure_issue` в пакете, без
|
||||
обращения к зеркалу.** Обоснование: `new:<n>` в зеркале — не задача Jira; метки пакета (с дописанной меткой проекта)
|
||||
единственный надёжный источник. Цена ошибки: ветка в `_assign_boards`.
|
||||
@ -299,3 +301,5 @@
|
||||
показывает страницы; `findByTestId` ловил заглушку «Загрузка настроек», которую страница сменяет другим элементом с
|
||||
тем же `data-testid`, и проверка падала на оторванном узле. Цена ошибки: только тесты.
|
||||
35. 2026-10-19 — **в тесте `storage.test.ts` выражение `{…} as JsonImport` заменено на `as unknown as JsonImport`.** Обоснование: `tsc --noEmit` (шаг `make check`) отвергает прямое приведение частичного объекта к `JsonImport` (нет `model`, `dateColumns`, `collapsedLanes`); в тесте важны только `layout`, `layoutByDate`, `laneOrder`, `filters`, и `applyJsonSettings` читает только их. Цена ошибки: тест передаёт неполный импорт; если `applyJsonSettings` начнёт читать другие поля, тест выдаст неверный результат, а не ошибку типа.
|
||||
36. 2026-10-19 — **`POST /batches` с неизвестным `project`: сообщение «проекта {P} нет» без префикса «команда {i}».**
|
||||
Обоснование: ошибка пакетная, а не одной команды. Цена ошибки: только текст сообщения.
|
||||
|
||||
@ -418,8 +418,10 @@ def h_projects_get(hub, conn, m, q, body):
|
||||
def h_projects_post(hub, conn, m, q, body):
|
||||
"""Новый проект: проект и пакет «Новый проект <метка>» (один refresh по базовой JQL с новой меткой) — в одной
|
||||
транзакции; ошибка любого шага не оставляет ни проекта, ни пакета (hub-api, «Маршруты проектов»)."""
|
||||
author = body.get("author") or "api"
|
||||
if not isinstance(author, str):
|
||||
author = body.get("author")
|
||||
if author is None or author == "":
|
||||
author = "api"
|
||||
elif not isinstance(author, str):
|
||||
raise ApiError(422, "bad_project", "author — строка")
|
||||
try:
|
||||
with db.transaction(conn):
|
||||
|
||||
@ -362,8 +362,8 @@ def report_result(conn, command_id, status, result=None, error=None, jira_calls=
|
||||
if cmd["type"] == "refresh":
|
||||
conn.execute("UPDATE sync_run SET finished_at = ?, jira_calls = ?, rate_limited = ? WHERE command_id = ?",
|
||||
(now, int(jira_calls or 0), int(rate_limited or 0), command_id))
|
||||
if status == "done" and isinstance(result, dict) and "boards" in result:
|
||||
projects.record_board_types(conn, result["boards"]) # без ключа (агент v11) типы не трогаем
|
||||
if isinstance(result, dict) and "boards" in result:
|
||||
projects.record_board_types(conn, result["boards"]) # в любом статусе; без ключа (агент v11) типы не трогаем
|
||||
if status == "done" and is_full_refresh(conn, command_id):
|
||||
# полный прогон не обновил задачу — в базовую JQL она больше не входит: сосед. Пустой прогон (0 задач) —
|
||||
# почти всегда сбой выборки, а не «все задачи ушли»: соседей не трогаем (M-12)
|
||||
|
||||
@ -13,6 +13,7 @@ import pytest
|
||||
|
||||
from jira_hub import api, db, files, projects, queue, release, settings
|
||||
from jira_hub.client import HubClient, HubError
|
||||
from jira_hub.commands import CommandError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@ -274,7 +275,8 @@ def test_settings_put_protected_key_in_batch_rejects_whole_batch(hub):
|
||||
@pytest.mark.parametrize("body", [
|
||||
{"pacing": 5}, {"pacing": {"ensure_fields": "abc"}}, {"pacing": {"ensure_fields": -1}},
|
||||
{"pacing": {"неизвестная": 1}}, {"port": "x"}, {"port": 0}, {"port": 70000}, {"backup_keep": -1},
|
||||
{"auto_refresh_minutes": 1.5}, {"min_agent_version": "2"}, {"link_types": []}, {"labels": {}}, {"initialized": 1}, {"closed_statuses": "Готово"},
|
||||
{"auto_refresh_minutes": 1.5}, {"min_agent_version": "2"}, {"link_types": []},
|
||||
{"labels": {}}, {"initialized": 1}, {"closed_statuses": "Готово"},
|
||||
{"pacing": {"refresh": 3}, "backup_keep": -1}, # хорошее рядом с плохим не записывается
|
||||
])
|
||||
def test_settings_put_invalid_value_is_422(hub, body):
|
||||
@ -812,6 +814,10 @@ def test_create_project_without_board(hub):
|
||||
({"label": "STOCKS_DRRK", "name": " "}, 422, "name"),
|
||||
({"label": "STOCKS_DRRK", "name": "x", "board_id": "5120"}, 422, "board_id"),
|
||||
({"label": "STOCKS_DRRK", "name": "x", "author": 5}, 422, None),
|
||||
({"label": "STOCKS_DRRK", "name": "x", "author": 0}, 422, None),
|
||||
({"label": "STOCKS_DRRK", "name": "x", "author": False}, 422, None),
|
||||
({"label": "STOCKS_DRRK", "name": "x", "author": []}, 422, None),
|
||||
({"label": "STOCKS_DRRK", "name": "x", "author": {}}, 422, None),
|
||||
])
|
||||
def test_create_project_errors(hub, body, status, field):
|
||||
c = hub["client"]
|
||||
@ -823,6 +829,24 @@ def test_create_project_errors(hub, body, status, field):
|
||||
assert len(c.get("/projects")["projects"]) == 1 and len(c.get("/batches")["batches"]) == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize("author", [None, ""])
|
||||
def test_create_project_empty_author_is_api(hub, author):
|
||||
c = hub["client"]
|
||||
out = c.post("/projects", {"label": "STOCKS_DRRK", "name": "x", "author": author})
|
||||
assert c.get(f"/batches/{out['batch_id']}")["author"] == "api"
|
||||
|
||||
|
||||
def test_create_project_batch_error_rolls_back(hub, monkeypatch): # T3.2
|
||||
def boom(*a, **k):
|
||||
raise CommandError("не ставится")
|
||||
monkeypatch.setattr(queue, "create_batch", boom)
|
||||
c = hub["client"]
|
||||
with pytest.raises(HubError) as e:
|
||||
c.post("/projects", {"label": "STOCKS_DRRK", "name": "x"})
|
||||
assert e.value.status == 422 and e.value.error == "bad_batch"
|
||||
assert c.get("/projects")["projects"] == []
|
||||
|
||||
|
||||
def test_projects_need_client_token(hub):
|
||||
with pytest.raises(HubError) as e:
|
||||
hub["agent"].get("/projects")
|
||||
|
||||
@ -402,6 +402,8 @@ def test_unbound_session(live, monkeypatch): # сце
|
||||
mcp_server.batch_create("x", []), mcp_server.command_retry(1), asyncio.run(mcp_server.refresh(full=True))):
|
||||
assert out == mcp_server.NOT_BOUND
|
||||
assert calls == []
|
||||
for out in (mcp_server.batches(), mcp_server.sync_log(), mcp_server.settings_get(), asyncio.run(mcp_server.batch_wait(1))):
|
||||
assert out != mcp_server.NOT_BOUND # не зависящие от проекта — работают без привязки
|
||||
assert "AWPCA" in mcp_server.projects()
|
||||
assert mcp_server.agent_status().startswith("агент ") # служебные инструменты работают без привязки
|
||||
|
||||
|
||||
@ -929,9 +929,9 @@ def test_refresh_result_board_type_changes_scrum_to_kanban(conn, add_project):
|
||||
assert projects.get(conn, "STOCKS_DRRK")["board_type"] == "kanban"
|
||||
|
||||
|
||||
def test_refresh_failed_result_keeps_board_types(conn, add_project):
|
||||
def test_refresh_failed_result_records_board_types(conn, add_project): # R-3 / T6.1: тип доски — факт о доске, а не часть выборки
|
||||
add_project(conn, "STOCKS_DRRK", board_id=5120, board_type=None)
|
||||
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
|
||||
c = queue.claim(conn)
|
||||
queue.report_result(conn, c["id"], "failed", result={"boards": {"5120": "kanban"}}, error="e")
|
||||
assert projects.get(conn, "STOCKS_DRRK")["board_type"] is None
|
||||
assert projects.get(conn, "STOCKS_DRRK")["board_type"] == "kanban"
|
||||
|
||||
@ -55,7 +55,7 @@ def test_init_from_config(conn):
|
||||
cfg = json.loads(FIXTURE.read_text(encoding="utf-8"))
|
||||
keys = settings.init_from_config(conn, cfg)
|
||||
assert "transitions_map" in keys and "base_jql" not in keys and "board_id" not in keys
|
||||
assert settings.get(conn, "base_jql") is None and settings.get(conn, "board_id") is None
|
||||
assert "base_jql" not in settings.get_all(conn) and "board_id" not in settings.get_all(conn)
|
||||
assert settings.get(conn, "field_ids")["planned_end"] == "customfield_10802"
|
||||
assert settings.get(conn, "issue_types")["epic"] == {"name": "Фича", "id": "12300"}
|
||||
assert settings.get(conn, "pacing") == settings.DEFAULT_PACING
|
||||
|
||||
@ -22,7 +22,7 @@ export function NewProjectDialog({ onClose, onCreated }: { onClose(): void; onCr
|
||||
|
||||
return (
|
||||
<dialog ref={ref} className="project-dialog" aria-label="Новый проект"
|
||||
onCancel={(e) => { e.preventDefault(); onClose(); }}>
|
||||
onCancel={(e) => { e.preventDefault(); if (!create.isPending) onClose(); }}>
|
||||
<form onSubmit={submit}>
|
||||
<h2>Новый проект</h2>
|
||||
<label>Метка<input value={label} onChange={(e) => setLabel(e.target.value)} required /></label>
|
||||
@ -32,7 +32,7 @@ export function NewProjectDialog({ onClose, onCreated }: { onClose(): void; onCr
|
||||
</label>
|
||||
<div className="actions">
|
||||
<button className="btn" type="submit" disabled={create.isPending}>Создать</button>
|
||||
<button className="btn" type="button" onClick={onClose}>Отмена</button>
|
||||
<button className="btn" type="button" onClick={onClose} disabled={create.isPending}>Отмена</button>
|
||||
{text && <span className="warn" role="alert">{text}</span>}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
@ -18,7 +18,7 @@ const S = { label: "STOCKS_DRRK", name: "Запасы ДРРК", board_id: null,
|
||||
const ROW = { ...SNAP.issues[1], fetched_at: "" };
|
||||
|
||||
interface Call { method: string; url: URL; body: unknown }
|
||||
function hub(projects: unknown[] = [A, S], over: Record<string, (body: unknown) => Response> = {}) {
|
||||
function hub(projects: unknown[] = [A, S], over: Record<string, (body: unknown) => Response | Promise<Response>> = {}) {
|
||||
const calls: Call[] = [];
|
||||
const fetch = mockFetch((method, url, body) => {
|
||||
calls.push({ method, url, body });
|
||||
@ -49,6 +49,7 @@ describe("проект в приложении", () => {
|
||||
const h = hub();
|
||||
const router = open("/app/board");
|
||||
await waitFor(() => expect(router.state.location.search).toBe("?project=AWPCA"));
|
||||
expect(router.state.historyAction).toBe("REPLACE");
|
||||
await waitFor(() => expect(h.get("/board").length).toBeGreaterThan(0));
|
||||
expect(h.get("/board")[0].url.searchParams.get("project")).toBe("AWPCA");
|
||||
expect(localStorage.getItem(REMEMBER_KEY)).toBe("AWPCA");
|
||||
@ -59,6 +60,7 @@ describe("проект в приложении", () => {
|
||||
const router = open("/app/board?project=AWPCA");
|
||||
await screen.findByTestId("page-board");
|
||||
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "STOCKS_DRRK");
|
||||
expect(router.state.historyAction).toBe("PUSH");
|
||||
await waitFor(() => expect(h.get("/board").some((c) => c.url.searchParams.get("project") === "STOCKS_DRRK")).toBe(true));
|
||||
const link = screen.getByRole("link", { name: "Задачи" });
|
||||
expect(link).toHaveAttribute("href", "/app/issues?project=STOCKS_DRRK");
|
||||
@ -74,6 +76,7 @@ describe("проект в приложении", () => {
|
||||
hub();
|
||||
const router = open("/app/issues");
|
||||
await waitFor(() => expect(router.state.location.search).toBe("?project=STOCKS_DRRK"));
|
||||
expect(router.state.historyAction).toBe("REPLACE");
|
||||
});
|
||||
|
||||
it("неизвестный проект в адресе: сообщение и список проектов, без GET /issues", async () => {
|
||||
@ -135,6 +138,44 @@ describe("проект в приложении", () => {
|
||||
expect(dlg).toHaveAttribute("open");
|
||||
});
|
||||
|
||||
it("ошибка фонового обновления проектов не сносит страницу: остаётся последний известный список", async () => {
|
||||
let n = 0;
|
||||
const h = hub([A, S], {
|
||||
"GET /projects": () => (++n === 1 ? json(200, { projects: [A, S] }) : json(500, { error: "boom" })),
|
||||
"POST /projects": () => json(201, { project: S, batch_id: 7 }),
|
||||
});
|
||||
const router = open("/app/board?project=AWPCA");
|
||||
await screen.findByTestId("page-board");
|
||||
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…");
|
||||
const dlg = screen.getByRole("dialog", { name: "Новый проект" });
|
||||
await userEvent.type(within(dlg).getByLabelText("Метка"), "STOCKS_DRRK");
|
||||
await userEvent.type(within(dlg).getByLabelText("Название"), "Запасы ДРРК");
|
||||
await userEvent.click(within(dlg).getByRole("button", { name: "Создать" })); // инвалидация: повторный GET /projects падает
|
||||
await waitFor(() => expect(router.state.location.search).toBe("?project=STOCKS_DRRK"));
|
||||
expect(h.get("/projects").length).toBeGreaterThan(1);
|
||||
expect(screen.getByRole("combobox", { name: "Проект" })).toHaveValue("STOCKS_DRRK");
|
||||
expect(await screen.findByTestId("page-board")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("окно «Новый проект» во время создания не закрывается: «Отмена» заблокирована, Esc гасится", async () => {
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((r) => { release = r; });
|
||||
hub([A], { "POST /projects": async () => { await gate; return json(201, { project: S, batch_id: 7 }); } });
|
||||
open("/app/board?project=AWPCA");
|
||||
await screen.findByTestId("page-board");
|
||||
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…");
|
||||
const dlg = screen.getByRole("dialog", { name: "Новый проект" });
|
||||
await userEvent.type(within(dlg).getByLabelText("Метка"), "STOCKS_DRRK");
|
||||
await userEvent.type(within(dlg).getByLabelText("Название"), "Запасы ДРРК");
|
||||
await userEvent.click(within(dlg).getByRole("button", { name: "Создать" }));
|
||||
await waitFor(() => expect(within(dlg).getByRole("button", { name: "Создать" })).toBeDisabled());
|
||||
expect(within(dlg).getByRole("button", { name: "Отмена" })).toBeDisabled();
|
||||
fireEvent(dlg, new Event("cancel", { cancelable: true }));
|
||||
expect(screen.getByRole("dialog", { name: "Новый проект" })).toBeInTheDocument();
|
||||
release();
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Новый проект" })).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("карточка по прямому адресу: выбор проекта не переписывает адрес и не закрывает окно", async () => {
|
||||
const h = hub();
|
||||
const router = open("/app/issues/AURORA-9001");
|
||||
|
||||
@ -58,7 +58,7 @@ export function Layout() {
|
||||
const projects = useProjects();
|
||||
const urlLabel = new URLSearchParams(pageLocation.search).get("project");
|
||||
const state = useMemo<ProjectState>(() => projects.isPending ? { kind: "loading" }
|
||||
: projects.error ? { kind: "error", error: projects.error }
|
||||
: projects.error && !projects.data ? { kind: "error", error: projects.error } // ошибка фонового рефетча — остаётся последний список
|
||||
: resolveProject(urlLabel, rememberedProject(), projects.data), [projects.isPending, projects.error, projects.data, urlLabel]);
|
||||
const okLabel = state.kind === "ok" ? state.project.label : null;
|
||||
const fromUrl = state.kind === "ok" && state.fromUrl;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user