File: test_comments.py

package info (click to toggle)
python-docx 1.2.0%2Bdfsg-1~exp1
  • links: PTS, VCS
  • area: main
  • in suites: experimental
  • size: 7,216 kB
  • sloc: xml: 25,323; python: 23,414; makefile: 175
file content (275 lines) | stat: -rw-r--r-- 10,068 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
# pyright: reportPrivateUsage=false

"""Unit test suite for the `docx.comments` module."""

from __future__ import annotations

import datetime as dt
from typing import cast

import pytest

from docx.comments import Comment, Comments
from docx.opc.constants import CONTENT_TYPE as CT
from docx.opc.packuri import PackURI
from docx.oxml.comments import CT_Comment, CT_Comments
from docx.oxml.ns import qn
from docx.package import Package
from docx.parts.comments import CommentsPart

from .unitutil.cxml import element
from .unitutil.mock import FixtureRequest, Mock, instance_mock


class DescribeComments:
    """Unit-test suite for `docx.comments.Comments` objects."""

    @pytest.mark.parametrize(
        ("cxml", "count"),
        [
            ("w:comments", 0),
            ("w:comments/w:comment", 1),
            ("w:comments/(w:comment,w:comment,w:comment)", 3),
        ],
    )
    def it_knows_how_many_comments_it_contains(self, cxml: str, count: int, package_: Mock):
        comments_elm = cast(CT_Comments, element(cxml))
        comments = Comments(
            comments_elm,
            CommentsPart(
                PackURI("/word/comments.xml"),
                CT.WML_COMMENTS,
                comments_elm,
                package_,
            ),
        )

        assert len(comments) == count

    def it_is_iterable_over_the_comments_it_contains(self, package_: Mock):
        comments_elm = cast(CT_Comments, element("w:comments/(w:comment,w:comment)"))
        comments = Comments(
            comments_elm,
            CommentsPart(
                PackURI("/word/comments.xml"),
                CT.WML_COMMENTS,
                comments_elm,
                package_,
            ),
        )

        comment_iter = iter(comments)

        comment1 = next(comment_iter)
        assert type(comment1) is Comment, "expected a `Comment` object"
        comment2 = next(comment_iter)
        assert type(comment2) is Comment, "expected a `Comment` object"
        with pytest.raises(StopIteration):
            next(comment_iter)

    def it_can_get_a_comment_by_id(self, package_: Mock):
        comments_elm = cast(
            CT_Comments,
            element("w:comments/(w:comment{w:id=1},w:comment{w:id=2},w:comment{w:id=3})"),
        )
        comments = Comments(
            comments_elm,
            CommentsPart(
                PackURI("/word/comments.xml"),
                CT.WML_COMMENTS,
                comments_elm,
                package_,
            ),
        )

        comment = comments.get(2)

        assert type(comment) is Comment, "expected a `Comment` object"
        assert comment._comment_elm is comments_elm.comment_lst[1]

    def but_it_returns_None_when_no_comment_with_that_id_exists(self, package_: Mock):
        comments_elm = cast(
            CT_Comments,
            element("w:comments/(w:comment{w:id=1},w:comment{w:id=2},w:comment{w:id=3})"),
        )
        comments = Comments(
            comments_elm,
            CommentsPart(
                PackURI("/word/comments.xml"),
                CT.WML_COMMENTS,
                comments_elm,
                package_,
            ),
        )

        comment = comments.get(4)

        assert comment is None, "expected None when no comment with that id exists"

    def it_can_add_a_new_comment(self, package_: Mock):
        comments_elm = cast(CT_Comments, element("w:comments"))
        comments_part = CommentsPart(
            PackURI("/word/comments.xml"),
            CT.WML_COMMENTS,
            comments_elm,
            package_,
        )
        now_before = dt.datetime.now(dt.timezone.utc).replace(microsecond=0)
        comments = Comments(comments_elm, comments_part)

        comment = comments.add_comment()

        now_after = dt.datetime.now(dt.timezone.utc).replace(microsecond=0)
        # -- a comment is unconditionally added, and returned for any further adjustment --
        assert isinstance(comment, Comment)
        # -- it is "linked" to the comments part so it can add images and hyperlinks, etc. --
        assert comment.part is comments_part
        # -- comment numbering starts at 0, and is incremented for each new comment --
        assert comment.comment_id == 0
        # -- author is a required attribut, but is the empty string by default --
        assert comment.author == ""
        # -- initials is an optional attribute, but defaults to the empty string, same as Word --
        assert comment.initials == ""
        # -- timestamp is also optional, but defaults to now-UTC --
        assert comment.timestamp is not None
        assert now_before <= comment.timestamp <= now_after
        # -- by default, a new comment contains a single empty paragraph --
        assert [p.text for p in comment.paragraphs] == [""]
        # -- that paragraph has the "CommentText" style, same as Word applies --
        comment_elm = comment._comment_elm
        assert len(comment_elm.p_lst) == 1
        p = comment_elm.p_lst[0]
        assert p.style == "CommentText"
        # -- and that paragraph contains a single run with the necessary annotation reference --
        assert len(p.r_lst) == 1
        r = comment_elm.p_lst[0].r_lst[0]
        assert r.style == "CommentReference"
        assert r[-1].tag == qn("w:annotationRef")

    def and_it_can_add_text_to_the_comment_when_adding_it(self, comments: Comments, package_: Mock):
        comment = comments.add_comment(text="para 1\n\npara 2")

        assert len(comment.paragraphs) == 3
        assert [p.text for p in comment.paragraphs] == ["para 1", "", "para 2"]
        assert all(p._p.style == "CommentText" for p in comment.paragraphs)

    def and_it_sets_the_author_and_their_initials_when_adding_a_comment_when_provided(
        self, comments: Comments, package_: Mock
    ):
        comment = comments.add_comment(author="Steve Canny", initials="SJC")

        assert comment.author == "Steve Canny"
        assert comment.initials == "SJC"

    # -- fixtures --------------------------------------------------------------------------------

    @pytest.fixture
    def comments(self, package_: Mock) -> Comments:
        comments_elm = cast(CT_Comments, element("w:comments"))
        comments_part = CommentsPart(
            PackURI("/word/comments.xml"),
            CT.WML_COMMENTS,
            comments_elm,
            package_,
        )
        return Comments(comments_elm, comments_part)

    @pytest.fixture
    def package_(self, request: FixtureRequest):
        return instance_mock(request, Package)


class DescribeComment:
    """Unit-test suite for `docx.comments.Comment`."""

    def it_knows_its_comment_id(self, comments_part_: Mock):
        comment_elm = cast(CT_Comment, element("w:comment{w:id=42}"))
        comment = Comment(comment_elm, comments_part_)

        assert comment.comment_id == 42

    def it_knows_its_author(self, comments_part_: Mock):
        comment_elm = cast(CT_Comment, element("w:comment{w:id=42,w:author=Steve Canny}"))
        comment = Comment(comment_elm, comments_part_)

        assert comment.author == "Steve Canny"

    def it_knows_the_initials_of_its_author(self, comments_part_: Mock):
        comment_elm = cast(CT_Comment, element("w:comment{w:id=42,w:initials=SJC}"))
        comment = Comment(comment_elm, comments_part_)

        assert comment.initials == "SJC"

    def it_knows_the_date_and_time_it_was_authored(self, comments_part_: Mock):
        comment_elm = cast(
            CT_Comment,
            element("w:comment{w:id=42,w:date=2023-10-01T12:34:56Z}"),
        )
        comment = Comment(comment_elm, comments_part_)

        assert comment.timestamp == dt.datetime(2023, 10, 1, 12, 34, 56, tzinfo=dt.timezone.utc)

    @pytest.mark.parametrize(
        ("cxml", "expected_value"),
        [
            ("w:comment{w:id=42}", ""),
            ('w:comment{w:id=42}/w:p/w:r/w:t"Comment text."', "Comment text."),
            (
                'w:comment{w:id=42}/(w:p/w:r/w:t"First para",w:p/w:r/w:t"Second para")',
                "First para\nSecond para",
            ),
            (
                'w:comment{w:id=42}/(w:p/w:r/w:t"First para",w:p,w:p/w:r/w:t"Second para")',
                "First para\n\nSecond para",
            ),
        ],
    )
    def it_can_summarize_its_content_as_text(
        self, cxml: str, expected_value: str, comments_part_: Mock
    ):
        assert Comment(cast(CT_Comment, element(cxml)), comments_part_).text == expected_value

    def it_provides_access_to_the_paragraphs_it_contains(self, comments_part_: Mock):
        comment_elm = cast(
            CT_Comment,
            element('w:comment{w:id=42}/(w:p/w:r/w:t"First para",w:p/w:r/w:t"Second para")'),
        )
        comment = Comment(comment_elm, comments_part_)

        paragraphs = comment.paragraphs

        assert len(paragraphs) == 2
        assert [para.text for para in paragraphs] == ["First para", "Second para"]

    def it_can_update_the_comment_author(self, comments_part_: Mock):
        comment_elm = cast(CT_Comment, element("w:comment{w:id=42,w:author=Old Author}"))
        comment = Comment(comment_elm, comments_part_)

        comment.author = "New Author"

        assert comment.author == "New Author"

    @pytest.mark.parametrize(
        "initials",
        [
            # -- valid initials --
            "XYZ",
            # -- empty string is valid
            "",
            # -- None is valid, removes existing initials
            None,
        ],
    )
    def it_can_update_the_comment_initials(self, initials: str | None, comments_part_: Mock):
        comment_elm = cast(CT_Comment, element("w:comment{w:id=42,w:initials=ABC}"))
        comment = Comment(comment_elm, comments_part_)

        comment.initials = initials

        assert comment.initials == initials

    # -- fixtures --------------------------------------------------------------------------------

    @pytest.fixture
    def comments_part_(self, request: FixtureRequest):
        return instance_mock(request, CommentsPart)