Пакет веб-багов: карточка, лента, вики-разметка, состояние модулей (#124) #125

Merged
aep merged 17 commits from feat/124-web-bugs into main 2026-10-26 00:55:02 +03:00
8 changed files with 89 additions and 12 deletions
Showing only changes of commit fbc14f6366 - Show all commits

View File

@ -60,6 +60,11 @@ describe("текст события", () => {
.toBe("комментарий: Итог Готово, см. протокол и @ivanov");
});
it("вложения в комментарии — именем, даже без списка вложений (I-1)", () => {
expect(eventText(ev({ kind: "comment", field: "comment", from: null, to: "см. !скрин.png|thumbnail! и [^отчёт.pdf]" })))
.toBe("комментарий: см. скрин.png и отчёт.pdf");
});
it("пустой комментарий — по-прежнему «—»", () => {
expect(eventText(ev({ kind: "comment", field: "comment", from: null, to: " " }))).toBe("комментарий: —");
});

View File

@ -224,7 +224,7 @@ describe("карточка окном", () => {
expect(await screen.findByRole("dialog", { name: "AURORA-2" })).toBeInTheDocument();
});
describe("задача не в зеркале после перехода (#103)", () => {
describe("задача не в зеркале после перехода (#42 M-5)", () => {
const mirror = () => mockFetch((_m, url) => {
const p = url.pathname;
if (p === "/issues/AURORA-1999") return json(404, {});
@ -263,7 +263,7 @@ describe("карточка окном", () => {
expect(within(dialog()).queryByRole("button", { name: "Назад" })).toBeNull();
});
it("«Назад» есть только после перехода: из списка — нет; из прямого адреса после перехода — есть", async () => {
it("«Назад» есть только после перехода: из списка — нет, после перехода из списка — есть", async () => {
mirror();
render(<App router={at(["/app/jira/issues"])} />);
await userEvent.click(await screen.findByRole("link", { name: "AURORA-9002" }));
@ -274,6 +274,54 @@ describe("карточка окном", () => {
expect(within(dialog()).getByRole("button", { name: "Назад" })).toBeInTheDocument();
});
it("двойной щелчок по «Назад» — один шаг, окно остаётся (M-1)", async () => {
mirror();
const router = at(["/app/jira/issues"]);
render(<App router={router} />);
await userEvent.click(await screen.findByRole("link", { name: "AURORA-9002" }));
await userEvent.click(await within(dialog()).findByRole("link", { name: "AURORA-1999" }));
await within(dialog()).findByText(msg);
const back = within(dialog()).getByRole("button", { name: "Назад" });
await act(async () => { fireEvent.click(back); fireEvent.click(back); });
expect(await within(dialog()).findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-9002");
});
it("двойной «Назад» из прямого адреса не уходит со страницы приложения (M-1)", async () => {
mirror();
const router = at(["/app/jira/issues/AURORA-9002"]);
render(<App router={router} />);
await userEvent.click(await within(await screen.findByRole("dialog")).findByRole("link", { name: "AURORA-1999" }));
await within(dialog()).findByText(msg);
const back = within(dialog()).getByRole("button", { name: "Назад" });
await act(async () => { fireEvent.click(back); fireEvent.click(back); });
expect(await within(dialog()).findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-9002");
});
it("ошибка загрузки после перехода: «Повторить», «Назад», «Закрыть» (T3.2)", async () => {
mockFetch((_m, url) => {
const p = url.pathname;
if (p === "/issues/AURORA-1999") return json(500, { detail: "сбой" });
if (p === "/issues/AURORA-9002") return json(200, issue("AURORA-9002", {
links_out: [{ target_key: "AURORA-1999", link_type: "ASUPR_Связанные", summary: "Тема", status: "Новая" }], links_in: [],
}));
if (p === "/settings") return json(200, settings);
if (p === "/projects") return json(200, { projects: [{ label: "AWPCA", name: "АРМ КА", board_id: null, board_type: null, created_at: "", issues_count: 9 }] });
if (p === "/issues") return json(200, { issues: rows });
if (p === "/batches") return json(200, { batches: [] });
return json(200, {});
});
const router = at(["/app/jira/issues"]);
render(<App router={router} />);
await userEvent.click(await screen.findByRole("link", { name: "AURORA-9002" }));
await userEvent.click(await within(dialog()).findByRole("link", { name: "AURORA-1999" }));
expect(await within(dialog()).findByRole("button", { name: "Повторить" })).toBeInTheDocument();
expect(within(dialog()).getByRole("button", { name: "Закрыть" })).toBeInTheDocument();
await userEvent.click(within(dialog()).getByRole("button", { name: "Назад" }));
expect(await within(dialog()).findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
});
it("переход из прямого адреса — «Назад» есть", async () => {
mirror();
render(<App router={at(["/app/jira/issues/AURORA-9002"])} />);

View File

@ -48,7 +48,14 @@ export function useIssueOverlay(): Overlay {
const abortClose = useCallback(() => { closingDirect.current = false; closingKey.current = null; }, []);
// «Назад» в окне — только если оно открыто переходом из другой карточки (из списка depth = 1; прямой адрес — direct, depth 0)
const onBack = card && (card.depth >= 2 || (card.direct && card.depth >= 1)) ? () => void navigate(-1) : undefined;
// повторный щелчок на той же записи истории игнорируется тем же closingKey (M-1)
const onBack = card && (card.depth >= 2 || (card.direct && card.depth >= 1))
? () => {
if (closingKey.current === location.key) return;
closingKey.current = location.key;
void navigate(-1);
}
: undefined;
const element = issueKey
? <CloseAbortContext value={abortClose}><IssueModal issueKey={issueKey} onClose={close} onBack={onBack} /></CloseAbortContext>

View File

@ -1,9 +1,10 @@
// Строчная вики-разметка Jira (design wiki-render #4, #5): выделение, {{моно}}, ссылки, упоминания, ключи задач.
// Вход — одна строка без переводов строк; разбор не бросает исключений, нераспознанное остаётся текстом как есть.
// вложения задачи для разбора ссылок `!имя!`, `[^имя]` (как у хаба: имя точно, при повторе имени — самое новое)
// вложения задачи для разбора ссылок `!имя!`, `[^имя]` (как у хаба: имя точно, при повторе имени — первое в порядке хаба, новые сверху)
export interface WikiAttachment { id: string; filename: string; created: string | null }
export interface WikiOptions { projectKey?: string; attachments?: WikiAttachment[] }
// anyAttachment — только для плоского текста (wikiPlain): `!имя!` и `[^имя]` — вложение, даже если имени нет в списке
export interface WikiOptions { projectKey?: string; attachments?: WikiAttachment[]; anyAttachment?: boolean }
type Mark = "strong" | "em" | "del" | "ins" | "sup" | "sub";
@ -34,7 +35,8 @@ function safeHref(raw: string): string | null {
// вложение по имени: несколько с одним именем — первое в порядке хаба
function findAttachment(opts: WikiOptions, name: string): string | null {
return (opts.attachments ?? []).find((a) => a.filename === name)?.id ?? null;
const found = (opts.attachments ?? []).find((a) => a.filename === name)?.id;
return found ?? (opts.anyAttachment ? "" : null);
}
function keyPattern(projectKey: string | undefined): RegExp | null {
@ -81,11 +83,11 @@ export function parseInline(line: string, opts: WikiOptions = {}, label = false)
text(c); i += 1; continue;
}
if (c === "!" && !label && opts.attachments?.length) {
if (c === "!" && !label && (opts.attachments?.length || opts.anyAttachment)) {
IMAGE.lastIndex = i;
const m = IMAGE.exec(line);
const id = m && !m[1].includes("://") ? findAttachment(opts, m[1]) : null;
if (m && id) { out.push({ t: "attachment", id, children: [{ t: "text", text: m[1] }] }); i += m[0].length; continue; }
if (m && id !== null) { out.push({ t: "attachment", id, children: [{ t: "text", text: m[1] }] }); i += m[0].length; continue; }
}
if (c === "h" && !label && prevOk(i)) {
@ -146,7 +148,7 @@ function bracket(body: string, opts: WikiOptions, isKey: (s: string) => boolean)
const ref = ATT_LINK.exec(body);
if (ref) {
const id = findAttachment(opts, ref[2]);
return id ? { t: "attachment", id, children: ref[1] ? parseInline(ref[1], opts, true) : [{ t: "text", text: ref[2] }] } : null;
return id !== null ? { t: "attachment", id, children: ref[1] ? parseInline(ref[1], opts, true) : [{ t: "text", text: ref[2] }] } : null;
}
const bar = body.indexOf("|");
const label = bar >= 0 ? body.slice(0, bar) : "";

View File

@ -92,6 +92,17 @@ describe("parseWiki: блоки-макросы", () => {
expect(table).toMatchObject({ t: "table" });
expect((table as { rows: { children: { text: string }[] }[][] }).rows[0].map((c) => c.children[0].text)).toEqual(["[draft", "итог", "готово"]);
});
it("непарная [ не берёт чужую ] из ссылки в следующей ячейке (T1.1)", () => {
const [table] = parseWiki("|[draft|итог|[док|https://x.ru]|");
const cells = (table as { rows: { children: Inline[] }[][] }).rows[0];
expect(cells).toHaveLength(3);
expect(cells[0].children[0]).toEqual({ t: "text", text: "[draft" });
expect(cells[1].children[0]).toEqual({ t: "text", text: "итог" });
expect(cells[2].children[0]).toMatchObject({ t: "link", href: "https://x.ru" });
});
it("{code} {code} в одной строке — пустой блок, не pre из пробелов (T1.2)", () => {
expect(parseWiki("{code} {code}")).toEqual([{ t: "pre", text: "" }]);
});
it("| внутри [текст|адрес] ячейку по-прежнему не делит", () => {
const [table] = parseWiki("|[док|https://x.ru]|b|");
expect((table as { rows: unknown[][] }).rows[0]).toHaveLength(2);

View File

@ -109,7 +109,8 @@ function closeMacro(lines: string[], start: number, open: RegExpExecArray): { na
const text = k === start ? rest : lines[k];
const at = text.indexOf(tag);
if (at < 0) { if (k !== start || text.trim()) body.push(text); continue; }
if (text.slice(0, at)) body.push(text.slice(0, at));
const head = text.slice(0, at);
if (head && (k !== start || head.trim())) body.push(head);
return { name, body, end: k, after: text.slice(at + tag.length) };
}
return null;
@ -126,7 +127,7 @@ function row(line: string, opts: WikiOptions): Cell[] {
let depth = 0;
// `|` внутри [ссылки|адрес] ячейку не режет
while (j < s.length && (s[j] !== "|" || depth > 0)) {
if (s[j] === "[") { if (s.indexOf("]", j + 1) >= 0) depth++; }
if (s[j] === "[") { if (/^[^[\]]*\]/.test(s.slice(j + 1))) depth++; }
else if (s[j] === "]" && depth > 0) depth--;
j++;
}

View File

@ -14,6 +14,9 @@ describe("wikiPlain", () => {
const opts = { projectKey: "AURORA", attachments: [{ id: "1", filename: "скрин.png", created: null }] };
expect(wikiPlain("см. AURORA-1 и !скрин.png!", opts).trim()).toBe("см. AURORA-1 и скрин.png");
});
it("вложение, которого нет в списке, — именем (I-1)", () => {
expect(wikiPlain("!скрин.png|width=50! [текст|^отчёт.pdf] [^а.txt]").trim()).toBe("скрин.png текст а.txt");
});
it("незакрытая и неизвестная разметка — как есть, пустой текст — пусто (Review Focus 3)", () => {
expect(wikiPlain("{color:red}важно *без конца").trim()).toBe("{color:red}важно *без конца");
expect(wikiPlain("")).toBe("");

View File

@ -31,5 +31,5 @@ function block(b: Block): string[] {
}
export function wikiPlain(text: string, opts: WikiOptions = {}): string {
return parseWiki(text, opts).flatMap(block).join(" ");
return parseWiki(text, { ...opts, anyAttachment: true }).flatMap(block).join(" ");
}