feat: initial commit from workspace-mcp
Some checks failed
Check Maintainer Edits Enabled / check-maintainer-edits (pull_request) Has been cancelled
Check Maintainer Edits Enabled / check-maintainer-edits-internal (pull_request) Has been cancelled
Docker Build and Push to GHCR / build-and-push (pull_request) Has been cancelled
Ruff / ruff (pull_request) Has been cancelled
Some checks failed
Check Maintainer Edits Enabled / check-maintainer-edits (pull_request) Has been cancelled
Check Maintainer Edits Enabled / check-maintainer-edits-internal (pull_request) Has been cancelled
Docker Build and Push to GHCR / build-and-push (pull_request) Has been cancelled
Ruff / ruff (pull_request) Has been cancelled
This commit is contained in:
0
tests/gdocs/__init__.py
Normal file
0
tests/gdocs/__init__.py
Normal file
455
tests/gdocs/test_docs_markdown.py
Normal file
455
tests/gdocs/test_docs_markdown.py
Normal file
@@ -0,0 +1,455 @@
|
||||
"""Tests for the Google Docs to Markdown converter."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
|
||||
|
||||
from gdocs.docs_markdown import (
|
||||
convert_doc_to_markdown,
|
||||
format_comments_appendix,
|
||||
format_comments_inline,
|
||||
parse_drive_comments,
|
||||
)
|
||||
|
||||
|
||||
# --- Fixtures ---
|
||||
|
||||
SIMPLE_DOC = {
|
||||
"title": "Simple Test",
|
||||
"body": {
|
||||
"content": [
|
||||
{"sectionBreak": {"sectionStyle": {}}},
|
||||
{
|
||||
"paragraph": {
|
||||
"elements": [
|
||||
{"textRun": {"content": "Hello world\n", "textStyle": {}}}
|
||||
],
|
||||
"paragraphStyle": {"namedStyleType": "NORMAL_TEXT"},
|
||||
}
|
||||
},
|
||||
{
|
||||
"paragraph": {
|
||||
"elements": [
|
||||
{"textRun": {"content": "This is ", "textStyle": {}}},
|
||||
{"textRun": {"content": "bold", "textStyle": {"bold": True}}},
|
||||
{"textRun": {"content": " and ", "textStyle": {}}},
|
||||
{
|
||||
"textRun": {
|
||||
"content": "italic",
|
||||
"textStyle": {"italic": True},
|
||||
}
|
||||
},
|
||||
{"textRun": {"content": " text.\n", "textStyle": {}}},
|
||||
],
|
||||
"paragraphStyle": {"namedStyleType": "NORMAL_TEXT"},
|
||||
}
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
HEADINGS_DOC = {
|
||||
"title": "Headings",
|
||||
"body": {
|
||||
"content": [
|
||||
{"sectionBreak": {"sectionStyle": {}}},
|
||||
{
|
||||
"paragraph": {
|
||||
"elements": [{"textRun": {"content": "Title\n", "textStyle": {}}}],
|
||||
"paragraphStyle": {"namedStyleType": "TITLE"},
|
||||
}
|
||||
},
|
||||
{
|
||||
"paragraph": {
|
||||
"elements": [
|
||||
{"textRun": {"content": "Heading one\n", "textStyle": {}}}
|
||||
],
|
||||
"paragraphStyle": {"namedStyleType": "HEADING_1"},
|
||||
}
|
||||
},
|
||||
{
|
||||
"paragraph": {
|
||||
"elements": [
|
||||
{"textRun": {"content": "Heading two\n", "textStyle": {}}}
|
||||
],
|
||||
"paragraphStyle": {"namedStyleType": "HEADING_2"},
|
||||
}
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
TABLE_DOC = {
|
||||
"title": "Table Test",
|
||||
"body": {
|
||||
"content": [
|
||||
{"sectionBreak": {"sectionStyle": {}}},
|
||||
{
|
||||
"table": {
|
||||
"rows": 2,
|
||||
"columns": 2,
|
||||
"tableRows": [
|
||||
{
|
||||
"tableCells": [
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"paragraph": {
|
||||
"elements": [
|
||||
{
|
||||
"textRun": {
|
||||
"content": "Name\n",
|
||||
"textStyle": {},
|
||||
}
|
||||
}
|
||||
],
|
||||
"paragraphStyle": {
|
||||
"namedStyleType": "NORMAL_TEXT"
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"paragraph": {
|
||||
"elements": [
|
||||
{
|
||||
"textRun": {
|
||||
"content": "Age\n",
|
||||
"textStyle": {},
|
||||
}
|
||||
}
|
||||
],
|
||||
"paragraphStyle": {
|
||||
"namedStyleType": "NORMAL_TEXT"
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableCells": [
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"paragraph": {
|
||||
"elements": [
|
||||
{
|
||||
"textRun": {
|
||||
"content": "Alice\n",
|
||||
"textStyle": {},
|
||||
}
|
||||
}
|
||||
],
|
||||
"paragraphStyle": {
|
||||
"namedStyleType": "NORMAL_TEXT"
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"paragraph": {
|
||||
"elements": [
|
||||
{
|
||||
"textRun": {
|
||||
"content": "30\n",
|
||||
"textStyle": {},
|
||||
}
|
||||
}
|
||||
],
|
||||
"paragraphStyle": {
|
||||
"namedStyleType": "NORMAL_TEXT"
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
LIST_DOC = {
|
||||
"title": "List Test",
|
||||
"lists": {
|
||||
"kix.list001": {
|
||||
"listProperties": {
|
||||
"nestingLevels": [
|
||||
{"glyphType": "GLYPH_TYPE_UNSPECIFIED", "glyphSymbol": "\u2022"},
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"body": {
|
||||
"content": [
|
||||
{"sectionBreak": {"sectionStyle": {}}},
|
||||
{
|
||||
"paragraph": {
|
||||
"elements": [
|
||||
{"textRun": {"content": "Item one\n", "textStyle": {}}}
|
||||
],
|
||||
"paragraphStyle": {"namedStyleType": "NORMAL_TEXT"},
|
||||
"bullet": {"listId": "kix.list001", "nestingLevel": 0},
|
||||
}
|
||||
},
|
||||
{
|
||||
"paragraph": {
|
||||
"elements": [
|
||||
{"textRun": {"content": "Item two\n", "textStyle": {}}}
|
||||
],
|
||||
"paragraphStyle": {"namedStyleType": "NORMAL_TEXT"},
|
||||
"bullet": {"listId": "kix.list001", "nestingLevel": 0},
|
||||
}
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# --- Converter tests ---
|
||||
|
||||
|
||||
class TestTextFormatting:
|
||||
def test_plain_text(self):
|
||||
md = convert_doc_to_markdown(SIMPLE_DOC)
|
||||
assert "Hello world" in md
|
||||
|
||||
def test_bold(self):
|
||||
md = convert_doc_to_markdown(SIMPLE_DOC)
|
||||
assert "**bold**" in md
|
||||
|
||||
def test_italic(self):
|
||||
md = convert_doc_to_markdown(SIMPLE_DOC)
|
||||
assert "*italic*" in md
|
||||
|
||||
|
||||
class TestHeadings:
|
||||
def test_title(self):
|
||||
md = convert_doc_to_markdown(HEADINGS_DOC)
|
||||
assert "# Title" in md
|
||||
|
||||
def test_h1(self):
|
||||
md = convert_doc_to_markdown(HEADINGS_DOC)
|
||||
assert "# Heading one" in md
|
||||
|
||||
def test_h2(self):
|
||||
md = convert_doc_to_markdown(HEADINGS_DOC)
|
||||
assert "## Heading two" in md
|
||||
|
||||
|
||||
class TestTables:
|
||||
def test_table_header(self):
|
||||
md = convert_doc_to_markdown(TABLE_DOC)
|
||||
assert "| Name | Age |" in md
|
||||
|
||||
def test_table_separator(self):
|
||||
md = convert_doc_to_markdown(TABLE_DOC)
|
||||
assert "| --- | --- |" in md
|
||||
|
||||
def test_table_row(self):
|
||||
md = convert_doc_to_markdown(TABLE_DOC)
|
||||
assert "| Alice | 30 |" in md
|
||||
|
||||
|
||||
class TestLists:
|
||||
def test_unordered(self):
|
||||
md = convert_doc_to_markdown(LIST_DOC)
|
||||
assert "- Item one" in md
|
||||
assert "- Item two" in md
|
||||
|
||||
|
||||
CHECKLIST_DOC = {
|
||||
"title": "Checklist Test",
|
||||
"lists": {
|
||||
"kix.checklist001": {
|
||||
"listProperties": {
|
||||
"nestingLevels": [
|
||||
{"glyphType": "GLYPH_TYPE_UNSPECIFIED"},
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"body": {
|
||||
"content": [
|
||||
{"sectionBreak": {"sectionStyle": {}}},
|
||||
{
|
||||
"paragraph": {
|
||||
"elements": [
|
||||
{"textRun": {"content": "Buy groceries\n", "textStyle": {}}}
|
||||
],
|
||||
"paragraphStyle": {"namedStyleType": "NORMAL_TEXT"},
|
||||
"bullet": {"listId": "kix.checklist001", "nestingLevel": 0},
|
||||
}
|
||||
},
|
||||
{
|
||||
"paragraph": {
|
||||
"elements": [
|
||||
{
|
||||
"textRun": {
|
||||
"content": "Walk the dog\n",
|
||||
"textStyle": {"strikethrough": True},
|
||||
}
|
||||
}
|
||||
],
|
||||
"paragraphStyle": {"namedStyleType": "NORMAL_TEXT"},
|
||||
"bullet": {"listId": "kix.checklist001", "nestingLevel": 0},
|
||||
}
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestChecklists:
|
||||
def test_unchecked(self):
|
||||
md = convert_doc_to_markdown(CHECKLIST_DOC)
|
||||
assert "- [ ] Buy groceries" in md
|
||||
|
||||
def test_checked(self):
|
||||
md = convert_doc_to_markdown(CHECKLIST_DOC)
|
||||
assert "- [x] Walk the dog" in md
|
||||
|
||||
def test_checked_no_strikethrough(self):
|
||||
"""Checked items should not have redundant ~~strikethrough~~ markdown."""
|
||||
md = convert_doc_to_markdown(CHECKLIST_DOC)
|
||||
assert "~~Walk the dog~~" not in md
|
||||
|
||||
def test_regular_bullet_not_checklist(self):
|
||||
"""Bullet lists with glyphSymbol should remain as plain bullets."""
|
||||
md = convert_doc_to_markdown(LIST_DOC)
|
||||
assert "[ ]" not in md
|
||||
assert "[x]" not in md
|
||||
|
||||
|
||||
class TestEmptyDoc:
|
||||
def test_empty(self):
|
||||
md = convert_doc_to_markdown({"title": "Empty", "body": {"content": []}})
|
||||
assert md.strip() == ""
|
||||
|
||||
|
||||
# --- Comment parsing tests ---
|
||||
|
||||
|
||||
class TestParseComments:
|
||||
def test_filters_resolved(self):
|
||||
response = {
|
||||
"comments": [
|
||||
{
|
||||
"content": "open",
|
||||
"resolved": False,
|
||||
"author": {"displayName": "A"},
|
||||
"replies": [],
|
||||
},
|
||||
{
|
||||
"content": "closed",
|
||||
"resolved": True,
|
||||
"author": {"displayName": "B"},
|
||||
"replies": [],
|
||||
},
|
||||
]
|
||||
}
|
||||
result = parse_drive_comments(response, include_resolved=False)
|
||||
assert len(result) == 1
|
||||
assert result[0]["content"] == "open"
|
||||
|
||||
def test_includes_resolved(self):
|
||||
response = {
|
||||
"comments": [
|
||||
{
|
||||
"content": "open",
|
||||
"resolved": False,
|
||||
"author": {"displayName": "A"},
|
||||
"replies": [],
|
||||
},
|
||||
{
|
||||
"content": "closed",
|
||||
"resolved": True,
|
||||
"author": {"displayName": "B"},
|
||||
"replies": [],
|
||||
},
|
||||
]
|
||||
}
|
||||
result = parse_drive_comments(response, include_resolved=True)
|
||||
assert len(result) == 2
|
||||
|
||||
def test_anchor_text(self):
|
||||
response = {
|
||||
"comments": [
|
||||
{
|
||||
"content": "note",
|
||||
"resolved": False,
|
||||
"author": {"displayName": "A"},
|
||||
"quotedFileContent": {"value": "highlighted text"},
|
||||
"replies": [],
|
||||
}
|
||||
]
|
||||
}
|
||||
result = parse_drive_comments(response)
|
||||
assert result[0]["anchor_text"] == "highlighted text"
|
||||
|
||||
|
||||
# --- Comment formatting tests ---
|
||||
|
||||
|
||||
class TestInlineComments:
|
||||
def test_inserts_footnote(self):
|
||||
md = "Some text here."
|
||||
comments = [
|
||||
{
|
||||
"author": "Alice",
|
||||
"content": "Note.",
|
||||
"anchor_text": "text",
|
||||
"replies": [],
|
||||
"resolved": False,
|
||||
}
|
||||
]
|
||||
result = format_comments_inline(md, comments)
|
||||
assert "text[^c1]" in result
|
||||
assert "[^c1]: **Alice**: Note." in result
|
||||
|
||||
def test_unmatched_goes_to_appendix(self):
|
||||
md = "No match."
|
||||
comments = [
|
||||
{
|
||||
"author": "Alice",
|
||||
"content": "Note.",
|
||||
"anchor_text": "missing",
|
||||
"replies": [],
|
||||
"resolved": False,
|
||||
}
|
||||
]
|
||||
result = format_comments_inline(md, comments)
|
||||
assert "## Comments" in result
|
||||
assert "> missing" in result
|
||||
|
||||
|
||||
class TestAppendixComments:
|
||||
def test_structure(self):
|
||||
comments = [
|
||||
{
|
||||
"author": "Alice",
|
||||
"content": "Note.",
|
||||
"anchor_text": "some text",
|
||||
"replies": [],
|
||||
"resolved": False,
|
||||
}
|
||||
]
|
||||
result = format_comments_appendix(comments)
|
||||
assert "## Comments" in result
|
||||
assert "> some text" in result
|
||||
assert "**Alice**: Note." in result
|
||||
|
||||
def test_empty(self):
|
||||
assert format_comments_appendix([]).strip() == ""
|
||||
139
tests/gdocs/test_paragraph_style.py
Normal file
139
tests/gdocs/test_paragraph_style.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
Tests for update_paragraph_style batch operation support.
|
||||
|
||||
Covers the helpers, validation, and batch manager integration.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
from gdocs.docs_helpers import (
|
||||
build_paragraph_style,
|
||||
create_update_paragraph_style_request,
|
||||
)
|
||||
from gdocs.managers.validation_manager import ValidationManager
|
||||
|
||||
|
||||
class TestBuildParagraphStyle:
|
||||
def test_no_params_returns_empty(self):
|
||||
style, fields = build_paragraph_style()
|
||||
assert style == {}
|
||||
assert fields == []
|
||||
|
||||
def test_heading_zero_maps_to_normal_text(self):
|
||||
style, fields = build_paragraph_style(heading_level=0)
|
||||
assert style["namedStyleType"] == "NORMAL_TEXT"
|
||||
|
||||
def test_heading_maps_to_named_style(self):
|
||||
style, _ = build_paragraph_style(heading_level=3)
|
||||
assert style["namedStyleType"] == "HEADING_3"
|
||||
|
||||
def test_heading_out_of_range_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
build_paragraph_style(heading_level=7)
|
||||
|
||||
def test_line_spacing_scaled_to_percentage(self):
|
||||
style, _ = build_paragraph_style(line_spacing=1.5)
|
||||
assert style["lineSpacing"] == 150.0
|
||||
|
||||
def test_dimension_field_uses_pt_unit(self):
|
||||
style, _ = build_paragraph_style(indent_start=36.0)
|
||||
assert style["indentStart"] == {"magnitude": 36.0, "unit": "PT"}
|
||||
|
||||
def test_multiple_params_combined(self):
|
||||
style, fields = build_paragraph_style(
|
||||
heading_level=2, alignment="CENTER", space_below=12.0
|
||||
)
|
||||
assert len(fields) == 3
|
||||
assert style["alignment"] == "CENTER"
|
||||
|
||||
|
||||
class TestCreateUpdateParagraphStyleRequest:
|
||||
def test_returns_none_when_no_styles(self):
|
||||
assert create_update_paragraph_style_request(1, 10) is None
|
||||
|
||||
def test_produces_correct_api_structure(self):
|
||||
result = create_update_paragraph_style_request(1, 10, heading_level=1)
|
||||
inner = result["updateParagraphStyle"]
|
||||
assert inner["range"] == {"startIndex": 1, "endIndex": 10}
|
||||
assert inner["paragraphStyle"]["namedStyleType"] == "HEADING_1"
|
||||
assert inner["fields"] == "namedStyleType"
|
||||
|
||||
|
||||
class TestValidateParagraphStyleParams:
|
||||
@pytest.fixture()
|
||||
def vm(self):
|
||||
return ValidationManager()
|
||||
|
||||
def test_all_none_rejected(self, vm):
|
||||
is_valid, _ = vm.validate_paragraph_style_params()
|
||||
assert not is_valid
|
||||
|
||||
def test_wrong_types_rejected(self, vm):
|
||||
assert not vm.validate_paragraph_style_params(heading_level=1.5)[0]
|
||||
assert not vm.validate_paragraph_style_params(alignment=123)[0]
|
||||
assert not vm.validate_paragraph_style_params(line_spacing="double")[0]
|
||||
|
||||
def test_negative_indent_start_rejected(self, vm):
|
||||
is_valid, msg = vm.validate_paragraph_style_params(indent_start=-5.0)
|
||||
assert not is_valid
|
||||
assert "non-negative" in msg
|
||||
|
||||
def test_negative_indent_first_line_allowed(self, vm):
|
||||
"""Hanging indent requires negative first-line indent."""
|
||||
assert vm.validate_paragraph_style_params(indent_first_line=-18.0)[0]
|
||||
|
||||
def test_batch_validation_wired_up(self, vm):
|
||||
valid_ops = [
|
||||
{
|
||||
"type": "update_paragraph_style",
|
||||
"start_index": 1,
|
||||
"end_index": 20,
|
||||
"heading_level": 2,
|
||||
},
|
||||
]
|
||||
assert vm.validate_batch_operations(valid_ops)[0]
|
||||
|
||||
no_style_ops = [
|
||||
{"type": "update_paragraph_style", "start_index": 1, "end_index": 20},
|
||||
]
|
||||
assert not vm.validate_batch_operations(no_style_ops)[0]
|
||||
|
||||
|
||||
class TestBatchManagerIntegration:
|
||||
@pytest.fixture()
|
||||
def manager(self):
|
||||
from gdocs.managers.batch_operation_manager import BatchOperationManager
|
||||
|
||||
return BatchOperationManager(Mock())
|
||||
|
||||
def test_build_request_and_description(self, manager):
|
||||
op = {
|
||||
"type": "update_paragraph_style",
|
||||
"start_index": 1,
|
||||
"end_index": 50,
|
||||
"heading_level": 2,
|
||||
"alignment": "CENTER",
|
||||
"line_spacing": 1.5,
|
||||
}
|
||||
request, desc = manager._build_operation_request(op, "update_paragraph_style")
|
||||
assert "updateParagraphStyle" in request
|
||||
assert "heading: H2" in desc
|
||||
assert "1.5x" in desc
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_end_to_end_execute(self, manager):
|
||||
manager._execute_batch_requests = AsyncMock(return_value={"replies": [{}]})
|
||||
success, message, meta = await manager.execute_batch_operations(
|
||||
"doc-123",
|
||||
[
|
||||
{
|
||||
"type": "update_paragraph_style",
|
||||
"start_index": 1,
|
||||
"end_index": 20,
|
||||
"heading_level": 1,
|
||||
}
|
||||
],
|
||||
)
|
||||
assert success
|
||||
assert meta["operations_count"] == 1
|
||||
Reference in New Issue
Block a user