Files
Octop/tests/unit/utils/test_doc_edit.py
Georgyhongbo 22bff364e4 feat: 工作区支持 .docx 在线编辑(Markdown 往返,可扩展注册表)
专家工作区新增可编辑文档能力:.docx 以 Markdown 在 Monaco 中打开/保存,
后端保存时转回 docx 覆盖原文件,与 .md 编辑体验一致。

- 后端新增可编辑文档转换器注册表(infra/utils/doc_edit.py)与通用
  GET/PUT /workspace/doc 端点,按扩展名分发,与具体格式解耦
- write_file 对可编辑文档按二进制格式落盘,工作区新建空 docx 即为合法
  文档包,预览/编辑立即可用
- 前端 docKind.ts 新增 EDITABLE_DOCS 注册表(docx->markdown),
  CodeEditor/FileViewer/WorkspaceDrawer 通用路由编辑与保存
- DocumentPreview 对 0 字节 word 显示空状态
- 新增 8 个后端单测、6 个集成测试、5 个前端单测;引入 python-docx

后续加新后缀只需:后端注册一个转换器类 + 前端注册表加一行。
2026-08-11 21:57:13 +08:00

120 lines
3.8 KiB
Python

"""Tests for the editable-document registry and its Markdown round-trip.
Covers :mod:`octop.infra.utils.doc_edit`: the extension registry that powers
the generic ``/workspace/doc`` endpoints, plus the docx converter's
``to_markdown`` / ``from_markdown`` round-trip.
"""
from __future__ import annotations
from io import BytesIO
from typing import Any
from zipfile import BadZipFile
import pytest
from docx import Document
from octop.infra.utils.doc_edit import (
DocxDocConverter,
editable_doc_extensions,
get_doc_converter,
is_editable_doc,
)
def _docx_bytes(document: Any) -> bytes:
buffer = BytesIO()
document.save(buffer)
return buffer.getvalue()
def _build_sample_docx() -> bytes:
document = Document()
document.add_heading("Report Title", level=1)
paragraph = document.add_paragraph()
paragraph.add_run("Hello").bold = True
paragraph.add_run(" world")
document.add_paragraph("Listed item", style="List Bullet")
table = document.add_table(rows=2, cols=2)
table.rows[0].cells[0].text = "name"
table.rows[0].cells[1].text = "value"
table.rows[1].cells[0].text = "a"
table.rows[1].cells[1].text = "1"
return _docx_bytes(document)
def test_registry_lists_docx() -> None:
assert editable_doc_extensions() == ["docx"]
def test_is_editable_doc_by_extension() -> None:
assert is_editable_doc("report.docx")
assert is_editable_doc("/work/dir/sub/report.docx")
assert is_editable_doc("REPORT.DOCX")
assert not is_editable_doc("report.doc")
assert not is_editable_doc("report.txt")
assert not is_editable_doc("report")
def test_get_doc_converter_dispatch() -> None:
converter = get_doc_converter("report.docx")
assert converter is not None
assert converter.language == "markdown"
assert get_doc_converter("report.txt") is None
def test_to_markdown_maps_styles_and_runs() -> None:
markdown = DocxDocConverter.to_markdown(_build_sample_docx())
assert "# Report Title" in markdown
assert "**Hello** world" in markdown
assert "- Listed item" in markdown
assert "| name | value |" in markdown
assert "| --- | --- |" in markdown
def test_from_markdown_builds_docx() -> None:
markdown = (
"# Head One\n\n"
"Some **bold** and *italic* text\n\n"
"- Item one\n\n"
"| a | b |\n| --- | --- |\n| 1 | 2 |\n"
)
data = DocxDocConverter.from_markdown(markdown)
document = Document(BytesIO(data))
assert [p.text for p in document.paragraphs if p.text] == [
"Head One",
"Some bold and italic text",
"Item one",
]
styles = [p.style.name if p.style else "" for p in document.paragraphs if p.text]
assert styles[:2] == ["Heading 1", "Normal"]
assert "List Bullet" in styles
assert any(run.bold for p in document.paragraphs for run in p.runs)
assert any(run.italic for p in document.paragraphs for run in p.runs)
assert len(document.tables) == 1
table = document.tables[0]
assert table.rows[1].cells[1].text == "2"
def test_round_trip_preserves_structure() -> None:
markdown = "# Report Title\n\nHello **bold** and *italic*\n\n- Item one\n"
markdown = DocxDocConverter.to_markdown(DocxDocConverter.from_markdown(markdown))
assert "# Report Title" in markdown
assert "**bold**" in markdown
assert "*italic*" in markdown
assert "- Item one" in markdown
def test_to_markdown_empty_file_is_empty_document() -> None:
assert DocxDocConverter.to_markdown(b"") == ""
assert DocxDocConverter.to_markdown(b" \n\t ") == ""
# An empty Markdown round-trips into a valid (empty) docx.
data = DocxDocConverter.from_markdown("")
document = Document(BytesIO(data))
assert len(document.paragraphs) == 0
def test_to_markdown_rejects_non_zip() -> None:
with pytest.raises(BadZipFile):
DocxDocConverter.to_markdown(b"this is not a docx zip")