388 lines
25 KiB
Markdown
388 lines
25 KiB
Markdown
# Пакет D модуля wiki — план реализации
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** закрыть маркеры #94 T6.1–T6.5, T8.2, T8.3, T9.3 и показать скачанные картинки в тексте страницы «Вики» (sdlc-hub#135).
|
||
|
||
**Architecture:** точечные правки модуля `wiki`: перевод HTML (`convert.py`), постановка `wiki_sync` (`commands.py`),
|
||
вложения (`attachments.py`, `api.py`), MCP (`mcp.py`); во фронте — режим «показать» у скачивания вложения и хранилище
|
||
картинок страницы (`download.ts`, `PageView.tsx`). Схема базы и контракт с агентом не меняются.
|
||
|
||
**Tech Stack:** Python 3.13 (uv), pytest, BeautifulSoup + markdownify; React 19 + TypeScript, Vitest, Testing Library.
|
||
|
||
**Ветка:** `feat/135-wiki-package-d` (worktree `.wt/wiki-d`).
|
||
|
||
**Spec:** `openspec/changes/wiki-package-d/` (proposal.md, design.md — решения 1–10, specs/wiki, specs/mcp, specs/web-app).
|
||
|
||
## Global Constraints
|
||
|
||
- Только хаб `sdlc-hub`; eva-agent и jira-agent не меняются, `SCHEMA_VERSION` не меняется.
|
||
- Граница ядра: в `src/sdlc_hub/core/` правок нет (проверяют `tests/test_registry.py`).
|
||
- Тексты ошибок и комментарии — по-русски, в стиле соседнего кода.
|
||
- Расширения картинок: `.png .jpg .jpeg .gif .webp .bmp` без учёта регистра; SVG — не картинка.
|
||
- Пробел → `%20`, `(` → `%28`, `)` → `%29` — только во внешних адресах ссылок и картинок.
|
||
- Зелёным считается только весь набор: в конце `make check` (pytest + lint и тесты фронта).
|
||
- Соединения SQLite в тестах — через фикстуры `open_db`/`conn`, иначе явный `close()`.
|
||
|
||
## Review Focus
|
||
|
||
1. Ссылка на страницу, внутри которой и картинка-вложение, и текст (`<a href="project/Document/DOC-1"><img src=…>Схема</a>`) — вложение перед ссылкой, текст ссылки «Схема». Тест в Task 1.
|
||
2. Внешняя ссылка с уже закодированным адресом (`%20` в `href`) не кодируется повторно (`%2520`). Тест в Task 1.
|
||
3. Повтор упавшей синхронизации, когда идущих синхронизаций нет, проходит как раньше. Тест в Task 2.
|
||
4. Вложение `ready` с файлом на месте — `fetch` отвечает 200 `ready` без нового пакета (T8.2 не ломает обычный путь). Тест в Task 3.
|
||
5. Смена страницы, пока картинка грузится: поздний ответ `content` не кладёт картинку на новую страницу, objectURL старой отозваны. Тест в Task 5.
|
||
|
||
---
|
||
|
||
### Task 1: Перевод HTML — T6.1–T6.5
|
||
|
||
**Files:**
|
||
- Modify: `src/sdlc_hub/modules/wiki/convert.py` (`_Converter.__init__`, `convert_img`, `convert_a`, `_label`, новые `_href`, `_split_marks`)
|
||
- Test: `tests/test_wiki_convert.py`
|
||
|
||
**Interfaces:**
|
||
- Produces: `convert.to_markdown(html, attachments, links) -> (str, list[dict])` — сигнатура прежняя.
|
||
|
||
- [ ] **Step 1: Написать падающие тесты** (константы `FILE_REF`, `THUMB_REF`, `IMG_REF`, `FILE_URL` уже в файле; `IMG_URL = "https://eva.example.test" + IMG_REF`, `FILE = {"ref": FILE_REF, "url": FILE_URL, "name": "model.xlsx"}`, `THUMB = {"ref": THUMB_REF, "url": FILE_URL, "name": "model.xlsx"}`, `IMG = {"ref": IMG_REF, "url": IMG_URL, "name": "scheme.png"}`, `LINK = {"ref": "project/Document/DOC-1", "code": "DOC-1"}`)
|
||
|
||
```python
|
||
def test_thumbnail_in_wrapper_keeps_link(): # T6.1
|
||
md, atts = convert.to_markdown(f'<p><a href="{FILE_REF}"><span><img src="{THUMB_REF}"></span></a> текст</p>', [THUMB, FILE], [])
|
||
assert md == "[📎 model.xlsx](attachment:@0) текст\n" and len(atts) == 1
|
||
|
||
def test_other_attachment_inside_attachment_link(): # T6.1, design 1
|
||
md, atts = convert.to_markdown(f'<p><a href="{FILE_REF}"><img src="{IMG_REF}"></a></p>', [FILE, IMG], [])
|
||
assert md == "[📎 scheme.png](attachment:@0)[📎 model.xlsx](attachment:@1)\n"
|
||
assert [a["name"] for a in atts] == ["scheme.png", "model.xlsx"]
|
||
|
||
def test_attachment_inside_page_link(): # T6.2
|
||
md, _ = convert.to_markdown(f'<p><a href="project/Document/DOC-1"><img src="{IMG_REF}"></a></p>', [IMG], [LINK])
|
||
assert md == "[📎 scheme.png](attachment:@0) [DOC-1](page:DOC-1)\n"
|
||
|
||
def test_attachment_and_text_inside_page_link(): # Review Focus 1
|
||
md, _ = convert.to_markdown(f'<p><a href="project/Document/DOC-1"><img src="{IMG_REF}">Схема</a></p>', [IMG], [LINK])
|
||
assert md == "[📎 scheme.png](attachment:@0) [Схема](page:DOC-1)\n"
|
||
|
||
def test_attachment_inside_external_link(): # design 2
|
||
md, _ = convert.to_markdown(f'<p><a href="https://ex.com/x"><img src="{IMG_REF}"></a></p>', [IMG], [])
|
||
assert md == "[📎 scheme.png](attachment:@0) [https://ex.com/x](https://ex.com/x)\n"
|
||
|
||
def test_duplicate_ref_first_wins(): # T6.3
|
||
md, atts = convert.to_markdown(f'<p><img src="{IMG_REF}"></p>', [dict(IMG, name="first"), dict(IMG, url="u2", name="second")], [])
|
||
assert atts == [{"url": IMG_URL, "name": "first"}]
|
||
md, _ = convert.to_markdown('<p><a href="project/Document/DOC-1">т</a></p>', [], [LINK, dict(LINK, code="DOC-9")])
|
||
assert "(page:DOC-1)" in md
|
||
|
||
def test_external_href_spaces_and_parens_encoded(): # T6.4
|
||
md, _ = convert.to_markdown('<p><a href="http://ex.com/a b(c).html">док</a></p>', [], [])
|
||
assert md == "[док](http://ex.com/a%20b%28c%29.html)\n"
|
||
|
||
def test_external_href_already_encoded_untouched(): # Review Focus 2
|
||
md, _ = convert.to_markdown('<p><a href="http://ex.com/a%20b.html">док</a></p>', [], [])
|
||
assert "(http://ex.com/a%20b.html)" in md and "%2520" not in md
|
||
|
||
def test_trailing_backslash_in_page_link_text(): # T6.5
|
||
md, _ = convert.to_markdown('<p><a href="project/Document/DOC-1">путь C:\\</a></p>', [], [LINK])
|
||
assert md == "[путь C:\\\\](page:DOC-1)\n" # в Markdown: [путь C:\\](page:DOC-1)
|
||
|
||
def test_trailing_backslash_in_external_link_text(): # T6.5
|
||
md, _ = convert.to_markdown('<p><a href="http://ex.com/">путь C:\\</a></p>', [], [])
|
||
assert md == "[путь C:\\\\](http://ex.com/)\n"
|
||
```
|
||
|
||
Если markdownify уже экранирует `\` в тексте, удвоение в `_label` не нужно — решают тесты.
|
||
|
||
- [ ] **Step 2: Запустить — тесты падают**
|
||
|
||
Run: `uv run pytest tests/test_wiki_convert.py -q`
|
||
Expected: новые тесты FAIL, прежние PASS.
|
||
|
||
- [ ] **Step 3: Реализовать в `convert.py`**
|
||
- `_by_ref`, `_links` — первая запись по `ref` (`setdefault` в цикле).
|
||
- `convert_img`: предок — `el.find_parent("a")`; если его `href` в `_by_ref` и (`src` не в `_by_ref` или `url` записи `src` == `url` записи `href`) — `""`.
|
||
- `_split_marks(text: str) -> tuple[str, str]` — (все фрагменты `[📎 …<маркер>` подряд, остаток текста без них); фрагмент вложения — `"[📎 " + имя + _MARK_OPEN + n + _MARK_CLOSE`, искать регуляркой до `_MARK_CLOSE`.
|
||
- `convert_a`: для вложения — `marks + self._attachment(entry)` (маркеры из `text` сохраняются перед своей ссылкой); для ссылки на страницу — `marks + (" " if marks else "") + "[%s](page:%s)" % (_label(rest.strip()) or _name(code), code)`; для внешней — то же разбиение, ссылка через `super().convert_a` с `el["href"] = _href(href)` и текстом-остатком (пустой — адрес).
|
||
- `convert_img` для внешней картинки — `el["src"] = _href(src)` перед `super().convert_img`.
|
||
- `_href(url: str) -> str`: `url.replace(" ", "%20").replace("(", "%28").replace(")", "%29")`.
|
||
- `_label`: нечётная серия `\` в конце строки удваивается до чётной.
|
||
|
||
- [ ] **Step 4: Запустить — всё зелёное**
|
||
|
||
Run: `uv run pytest tests/test_wiki_convert.py tests/test_wiki_sync.py -q`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Коммит**
|
||
|
||
```bash
|
||
git add src/sdlc_hub/modules/wiki/convert.py tests/test_wiki_convert.py
|
||
git commit -m "Вики: перевод HTML — миниатюра в обёртке, вложение в ссылке, кодирование адресов (#135, T6.1–T6.5)"
|
||
```
|
||
|
||
### Task 2: Одна синхронизация на корень — T8.3
|
||
|
||
**Files:**
|
||
- Modify: `src/sdlc_hub/modules/wiki/commands.py` (`check_batch`, `enqueue_sync`, новая `_active_same_root`)
|
||
- Test: `tests/test_wiki_commands.py`, `tests/test_wiki_projects.py`
|
||
|
||
**Interfaces:**
|
||
- Produces: `commands._active_same_root(conn, label: str, root: str) -> int | None` — id пакета с `wiki_sync` проекта в `queued`/`claimed` и `params.root == root` (по `json_extract(c.params, '$.root')`), самый новый.
|
||
- Produces: `commands.enqueue_sync(conn, label, root, author) -> dict` — `{id, estimate_seconds, count}`; при идущей синхронизации того же корня — её пакет (`estimate_seconds` из `batch`, `count` — число команд пакета).
|
||
|
||
- [ ] **Step 1: Написать падающие тесты**
|
||
|
||
```python
|
||
# tests/test_wiki_commands.py
|
||
SYNC = [{"type": "wiki_sync", "params": {"label": "AWPCA", "root": "DOC-1"}}]
|
||
|
||
def test_check_batch_refuses_second_sync_same_root(conn):
|
||
b = queue.create_batch(conn, "t", "u", SYNC, project="AWPCA")
|
||
with pytest.raises(CommandError, match=f"#{b['id']}"):
|
||
queue.create_batch(conn, "t", "u", SYNC, project="AWPCA")
|
||
|
||
def test_check_batch_other_root_allowed(conn):
|
||
queue.create_batch(conn, "t", "u", SYNC, project="AWPCA")
|
||
other = [{"type": "wiki_sync", "params": {"label": "AWPCA", "root": "DOC-2"}}]
|
||
assert queue.create_batch(conn, "t", "u", other, project="AWPCA")["count"] == 1
|
||
|
||
def test_retry_failed_sync_without_active_passes(conn): # Review Focus 3
|
||
b = queue.create_batch(conn, "t", "u", SYNC, project="AWPCA")
|
||
conn.execute("UPDATE command SET status = 'failed' WHERE batch_id = ?", (b["id"],))
|
||
assert queue.create_batch(conn, "t", "u", SYNC, project="AWPCA")["count"] == 1
|
||
|
||
def test_enqueue_sync_returns_active_same_root(conn):
|
||
_root(conn)
|
||
first = commands.enqueue_sync(conn, "AWPCA", "DOC-1", "u")
|
||
again = commands.enqueue_sync(conn, "AWPCA", "DOC-1", "u")
|
||
assert again["id"] == first["id"] and again["count"] == 1 and _count(conn) == 1
|
||
```
|
||
|
||
```python
|
||
# tests/test_wiki_projects.py
|
||
def test_root_back_while_old_claimed(hub_server): # A → B → A
|
||
c = hub_server["client"]
|
||
c.post("/projects", {"label": "AWPCA", "name": "АРМ КА", "wiki_root": "DOC-12"})
|
||
conn = hub_server["hub"].conn()
|
||
try:
|
||
conn.execute("UPDATE command SET status = 'claimed' WHERE type = 'wiki_sync'")
|
||
finally:
|
||
conn.close()
|
||
c.patch("/projects/AWPCA", {"wiki_root": "DOC-21"})
|
||
c.patch("/projects/AWPCA", {"wiki_root": "DOC-12"}) # не 422
|
||
assert [(s, p["root"]) for _, s, p in _syncs(hub_server["hub"])] == [("claimed", "DOC-12"), ("cancelled", "DOC-21")]
|
||
|
||
def test_retry_while_sync_running_refused(hub_server): # сценарий spec «Повтор упавшей синхронизации…»
|
||
c = hub_server["client"]
|
||
c.post("/projects", {"label": "AWPCA", "name": "АРМ КА", "wiki_root": "DOC-1"})
|
||
conn = hub_server["hub"].conn()
|
||
try:
|
||
failed = conn.execute("SELECT id FROM command WHERE type = 'wiki_sync'").fetchone()["id"]
|
||
conn.execute("UPDATE command SET status = 'failed' WHERE id = ?", (failed,))
|
||
finally:
|
||
conn.close()
|
||
running = c.post("/wiki/sync", {"project": "AWPCA"})["batch_id"]
|
||
with pytest.raises(HubError) as e:
|
||
c.post(f"/commands/{failed}/retry", {})
|
||
assert e.value.status == 422 and f"#{running}" in str(e.value)
|
||
```
|
||
|
||
Статус отменённой команды в `_syncs` — каким его пишет `queue.cancel_batch` (исполнитель сверяет по
|
||
`test_patch_root_cancels_queued` и подставляет фактический). Атрибуты `HubError` (`status`, текст) — как в соседних тестах файла.
|
||
|
||
- [ ] **Step 2: Запустить — тесты падают**
|
||
|
||
Run: `uv run pytest tests/test_wiki_commands.py tests/test_wiki_projects.py -q`
|
||
Expected: новые FAIL.
|
||
|
||
- [ ] **Step 3: Реализовать `_active_same_root`, проверку в `check_batch` и поиск в `enqueue_sync`**
|
||
|
||
Текст ошибки `check_batch`: `f"команда {i}: у проекта {project} уже идёт синхронизация корня {root} (пакет #{bid})"`.
|
||
`enqueue_sync` и `check_batch` меняются в одном коммите (design 7).
|
||
|
||
- [ ] **Step 4: Запустить wiki-тесты**
|
||
|
||
Run: `uv run pytest tests/test_wiki_commands.py tests/test_wiki_projects.py tests/test_wiki_api.py tests/test_wiki_sync.py -q`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Коммит**
|
||
|
||
```bash
|
||
git add src/sdlc_hub/modules/wiki/commands.py tests/test_wiki_commands.py tests/test_wiki_projects.py
|
||
git commit -m "Вики: одна синхронизация на корень — повтор не ставит вторую (#135, T8.3)"
|
||
```
|
||
|
||
### Task 3: Вложение без файла и признак картинки — T8.2, `image`
|
||
|
||
**Files:**
|
||
- Modify: `src/sdlc_hub/modules/wiki/attachments.py` (`request_fetch`, новая `is_image`), `src/sdlc_hub/modules/wiki/api.py:77-79` (страница), `:125` (fetch)
|
||
- Test: `tests/test_wiki_api.py`
|
||
|
||
**Interfaces:**
|
||
- Produces: `attachments.request_fetch(conn, attachment_id: int, author: str | None, files_dir) -> dict` — новый обязательный параметр `files_dir` (путь или `None`; `None` — проверка файла пропускается).
|
||
- Produces: `attachments.is_image(url: str | None) -> bool` — последний сегмент `urlsplit(url).path`, `unquote`, `.lower().endswith((".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"))`.
|
||
- Produces: `GET /wiki/pages/{code}` → `attachments: [{id, name, state, size, error, image}]` (Task 5 читает `image`).
|
||
|
||
- [ ] **Step 1: Написать падающие тесты**
|
||
|
||
```python
|
||
def test_fetch_ready_file_missing_requeues(api): # T8.2
|
||
aid = synced(api)
|
||
api.client.post(f"/wiki/attachments/{aid}/fetch", {})
|
||
sha = upload(api, b"hello")["sha256"]
|
||
api.report(fetch_cmd(api), "done", {"file": sha, "size": 5})
|
||
(api.files_dir / sha[:2] / sha).unlink() # путь файла — как в core.files; сверить
|
||
r = api.client.post(f"/wiki/attachments/{aid}/fetch", {})
|
||
assert r["state"] == "fetching"
|
||
assert fetch_cmd(api)["params"]["attachment_id"] == aid
|
||
|
||
def test_fetch_ready_file_present_no_batch(api): # Review Focus 4
|
||
aid = synced(api)
|
||
api.client.post(f"/wiki/attachments/{aid}/fetch", {})
|
||
sha = upload(api, b"hello")["sha256"]
|
||
api.report(fetch_cmd(api), "done", {"file": sha, "size": 5})
|
||
n = len(api.rows("SELECT id FROM batch"))
|
||
assert api.client.post(f"/wiki/attachments/{aid}/fetch", {})["state"] == "ready"
|
||
assert len(api.rows("SELECT id FROM batch")) == n
|
||
|
||
def test_page_attachment_image_flag(api):
|
||
synced(api, attachments=[{"ref": FILE_REF, "url": FILE_URL, "name": "attachment"},
|
||
{"ref": IMG_REF, "url": "https://eva.example.test" + IMG_REF, "name": "Схема без расширения"}])
|
||
atts = api.client.get("/wiki/pages/DOC-3", project="AWPCA")["attachments"]
|
||
assert [a["image"] for a in atts] == [False, True]
|
||
|
||
@pytest.mark.parametrize("url,expected", [("https://e/files/a/x.PNG?version=1", True), ("https://e/files/a/x.svg", False),
|
||
("https://e/files/a/%D1%81.jpeg", True), (None, False), ("https://e/files/a/x.png.meta/", False)])
|
||
def test_is_image(url, expected):
|
||
assert attachments.is_image(url) is expected
|
||
```
|
||
|
||
Ключ ответа `POST /agent/files`, путь файла в `files/` и доступ к `files_dir` в фикстуре `api` — по `core/files.py` и
|
||
`tests/test_wiki_sync.py`; исполнитель подставляет фактические. HTML страницы в `test_page_attachment_image_flag`
|
||
должен содержать оба `ref` (`<a href=FILE_REF>` и `<img src=IMG_REF>`) — передать `name_html`.
|
||
|
||
- [ ] **Step 2: Запустить — тесты падают**
|
||
|
||
Run: `uv run pytest tests/test_wiki_api.py -q`
|
||
Expected: новые FAIL.
|
||
|
||
- [ ] **Step 3: Реализовать**
|
||
- `request_fetch`: ветка «ответить как есть» для `ready` — только если `files_dir is None or files.size(files_dir, row["file"]) is not None`.
|
||
- `h_attachment_fetch` передаёт `hub.files_dir`; прочие вызовы `request_fetch` (grep) — тоже.
|
||
- Страница: в выборке вложений добавить `url`, в ответ — `image: attachments.is_image(url)`, `url` в ответ страницы не добавлять.
|
||
|
||
- [ ] **Step 4: Запустить wiki-тесты**
|
||
|
||
Run: `uv run pytest tests/test_wiki_api.py tests/test_wiki_mcp.py -q`
|
||
Expected: PASS (прежний `test_status_tree_page` обновить: в ожидаемом словаре вложения — `"image": False`).
|
||
|
||
- [ ] **Step 5: Коммит**
|
||
|
||
```bash
|
||
git add src/sdlc_hub/modules/wiki/attachments.py src/sdlc_hub/modules/wiki/api.py tests/test_wiki_api.py
|
||
git commit -m "Вики: вложение без файла скачивается заново, признак картинки у вложений страницы (#135, T8.2)"
|
||
```
|
||
|
||
### Task 4: MCP — значения из Eva одной строкой (T9.3)
|
||
|
||
**Files:**
|
||
- Modify: `src/sdlc_hub/modules/wiki/mcp.py` (новая `_line`, места вывода из design 8)
|
||
- Test: `tests/test_wiki_mcp.py`
|
||
|
||
**Interfaces:**
|
||
- Produces: `mcp._line(v) -> str` — `core.value(v)`, затем `re.sub(r"\s*[\r\n]+\s*", " ", …)`.
|
||
|
||
- [ ] **Step 1: Написать падающие тесты** (маршруты `Fake` — как в соседних тестах `test_page_chunks`, `test_search`, `test_tree_depth_and_subtree`)
|
||
|
||
```python
|
||
def test_page_header_newlines_collapsed(hub):
|
||
hub({"/wiki/pages/DOC-3": {"code": "DOC-3", "title": "Статусы\nпуть: поддельный", "path": "Корень/\nСтатусы",
|
||
"url": "https://eva/x", "synced_at": "2026-10-24T10:00:00+00:00", "markdown": "строка1\nстрока2",
|
||
"attachments": [{"id": 7, "name": "a\nb.png", "state": "none", "size": None, "error": None, "image": True}]}})
|
||
out = asyncio.run(...) # вызов page как в test_page_chunks
|
||
lines = out.splitlines()
|
||
assert lines[0] == "DOC-3 — Статусы путь: поддельный"
|
||
assert sum(l.startswith("путь:") for l in lines) == 1
|
||
assert " #7 a b.png (none)" in lines and "строка1\nстрока2" in out
|
||
|
||
def test_search_and_tree_newlines_collapsed(hub):
|
||
# search: title "A\nB", path "P\nQ", snippet "x\ny" → одна строка «… A B — P Q» и строка « x y»
|
||
# tree: title "T\nU" → «DOC-2 T U (200 симв.)»
|
||
```
|
||
|
||
Исполнитель дописывает второй тест конкретными `Fake`-маршрутами и утверждениями из комментария — значения заданы.
|
||
|
||
- [ ] **Step 2: Запустить — тесты падают**
|
||
|
||
Run: `uv run pytest tests/test_wiki_mcp.py -q`
|
||
Expected: новые FAIL.
|
||
|
||
- [ ] **Step 3: Реализовать `_line` и применить** — `tree`, `page` (шапка и вложения), `_candidates` (названия и `title` запроса), `search`, `attachment` (имя, код страницы), `_summary`/`status` (название корня, ошибка). Тело страницы не трогать.
|
||
|
||
- [ ] **Step 4: Запустить**
|
||
|
||
Run: `uv run pytest tests/test_wiki_mcp.py -q`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Коммит**
|
||
|
||
```bash
|
||
git add src/sdlc_hub/modules/wiki/mcp.py tests/test_wiki_mcp.py
|
||
git commit -m "MCP вики: значения из Eva одной строкой (#135, T9.3)"
|
||
```
|
||
|
||
### Task 5: Картинки в тексте страницы «Вики»
|
||
|
||
**Files:**
|
||
- Modify: `web/src/modules/wiki/api/queries.ts` (`WikiAttachment.image`), `web/src/modules/wiki/download.ts` (режим `show`, хранилище картинок), `web/src/modules/wiki/PageView.tsx` (`AttachmentLink`), `web/src/modules/wiki/wiki.css`
|
||
- Test: `web/src/modules/wiki/download.test.tsx`, `web/src/modules/wiki/WikiPage.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `GET /wiki/pages/{code}` → `attachments[].image: boolean` (Task 3).
|
||
- Produces: `useWikiDownload(project, scope, images?: WikiAttachment[])` → `{ start(id: string, mode?: "save" | "show"): void; status(id): DownloadStatus; image(id): string | null }`; `image` — objectURL картинки или `null`. Объект стабилен при неизменных `start/status/image` (как сейчас).
|
||
- Тип blob: `new Blob([blob], { type: MIME_BY_EXT[ext] ?? "application/octet-stream" })`, `MIME_BY_EXT = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", bmp: "image/bmp" }`; расширение — из `name`, если его нет — `image/*` не ставится, браузер определит сам (blob без типа).
|
||
|
||
- [ ] **Step 1: Написать падающие тесты** (моки `api` и `URL.createObjectURL`/`revokeObjectURL` — как в `download.test.tsx`)
|
||
|
||
```tsx
|
||
it("ready-картинка при открытии страницы показывается без fetch", async () => {
|
||
// страница DOC-3, attachments: [{id: 7, name: "scheme.png", state: "ready", image: true}]
|
||
// ожидание: GET /wiki/attachments/7/content вызван, POST …/fetch — нет; в DOM img с src blob:… и alt «scheme.png»;
|
||
// под картинкой кнопка «scheme.png» (сохранение)
|
||
});
|
||
it("клик по нескачанной картинке: fetch, опрос, картинка вместо сохранения", async () => {
|
||
// state none → клик → POST /wiki/attachments/7/fetch → ready → GET content; img в DOM; save (a.download) не вызван
|
||
});
|
||
it("не картинка сохраняется как раньше", async () => {
|
||
// {id: 8, name: "model.xlsx", state: "ready", image: false} → клик → a.download === "model.xlsx", img нет
|
||
});
|
||
it("content 409 у ready-картинки — остаётся ссылка без ошибки, клик ставит скачивание", async () => {});
|
||
it("смена страницы: поздний content не показывается, objectURL отозваны", async () => { // Review Focus 5
|
||
});
|
||
it("появление картинки не сбрасывает busy другого вложения", async () => {});
|
||
```
|
||
|
||
Исполнитель дописывает тела по образцу соседних тестов `download.test.tsx`/`WikiPage.test.tsx`; ожидания заданы в комментариях.
|
||
|
||
- [ ] **Step 2: Запустить — тесты падают**
|
||
|
||
Run: `cd web && npx vitest run src/modules/wiki`
|
||
Expected: новые FAIL.
|
||
|
||
- [ ] **Step 3: Реализовать** по design 10: автозапрос `content` один раз на `ready`-картинку за жизнь `scope`; режим `show` кладёт objectURL в ту же карту; сброс карты и `revokeObjectURL` при смене `project/scope` и размонтировании; `AttachmentLink` — `<span className="wiki-att-img"><img src alt={name} /></span>` и кнопка `save` под ней, без картинки — кнопка `show` для `image`, `save` иначе. CSS: `.wiki-att-img{display:block}` и `.wiki-att-img img{display:block;max-width:100%;height:auto;border-radius:var(--radius-md)}`.
|
||
|
||
- [ ] **Step 4: Запустить фронт целиком**
|
||
|
||
Run: `cd web && npm run lint && npm test`
|
||
Expected: lint чистый, все тесты PASS.
|
||
|
||
- [ ] **Step 5: Коммит**
|
||
|
||
```bash
|
||
git add web/src/modules/wiki
|
||
git commit -m "Вики: скачанные картинки показываются в тексте страницы (#135)"
|
||
```
|
||
|
||
### Завершение
|
||
|
||
- [ ] `make check` — весь набор зелёный.
|
||
- [ ] Живая проверка на тестовом хабе или после выкатки (за владельцем): картинка `vgo-matching-1.png` (AWPCA, в `ready`) видна в тексте своей страницы.
|