[docs]  1# @Test suite for the source copies and pages as build state, TEST_SOURCE_PAGES_1, test, [IMPL_LNK_1]
  2"""The source copies and their pages are build state (#2070, the pages half of #2044).
  3
  4What to copy and page is recorded in the environment when a document is read (or when a
  5project without a directive is scanned), and every HTML build copies and pages from that
  6record at ``html-collect-pages`` -- whether or not the directive's document is read
  7again, under ``-j N`` too, and for HTML builders only.
  8
  9Every case builds a copy of ``doc_test/need_id_refs`` (see ``test_need_id_refs``) with
 10local URLs only, plus ``src/impl.cpp`` holding the one-line need ``IMPL_1``. On the
 11``directive`` path ``index`` traces the whole project, so ``src/refs.cpp`` (referenced
 12only) and ``src/impl.cpp`` (a need) are copied and paged; on the ``config-only`` path no
 13directive traces it, so only the referenced ``src/refs.cpp`` is.
 14"""
 15
 16import os
 17import pickle
 18import re
 19import shutil
 20from pathlib import Path
 21
 22import pytest
 23from sphinx.testing.util import SphinxTestApp
 24from sphinx.util.parallel import parallel_available
 25
 26from sphinx_needs_testkit import build_warnings
 27
 28from .test_config_only_refs import NO_DIRECTIVE, _edit, _status
 29from .test_need_id_refs import (
 30    _SHOWS_WARNING_TYPES,
 31    DANGLING,
 32    GITHUB,
 33    _build,
 34    _card_links,
 35    _MakeApp,
 36    _project,
 37)
 38from .test_rediscovery import _scoped
 39from .test_url_links import _card_links as _field_links
 40from .test_url_links import _project as _url_project
 41
 42IMPL = "// @first impl, IMPL_1, impl\nvoid impl() {}\n"
 43LOCAL_ONLY = ("set_remote_url = true", "set_remote_url = false")
 44#: the copies (and so the pages) each path makes
 45COPIES = {
 46    "directive": ["src/impl.cpp", "src/refs.cpp"],
 47    "config-only": ["src/refs.cpp"],
 48}
 49#: the ``[docs]`` back-link of a source page
 50DOCS_LINK = re.compile(r'<a class="viewcode-back" href="([^"]*)">\[docs\]</a>')
 51
 52
 53def _local_project(
 54    root: Path, path: str, append: dict[str, str] | None = None, **files: str
 55) -> None:
 56    """The fixture with local URLs only and ``src/impl.cpp``; ``index`` without its
 57    directive on the ``config-only`` path."""
 58    if path == "config-only":
 59        files = {**NO_DIRECTIVE, **files}
 60    _project(
 61        root,
 62        toml_replace=LOCAL_ONLY,
 63        files={"src/impl.cpp": IMPL, **files},
 64        append=append,
 65    )
 66
 67
 68def _copies(outdir: Path) -> list[str]:
 69    """The source copies in ``outdir``, POSIX and relative to it."""
 70    return sorted(p.relative_to(outdir).as_posix() for p in outdir.rglob("*.cpp"))
 71
 72
 73def _page(outdir: Path, copy: str, builder: str = "html") -> Path:
 74    """The page of ``copy``: ``src/x.html`` (``src/x/index.html`` for ``dirhtml``)."""
 75    stem = Path(outdir, copy).with_suffix("")
 76    return stem / "index.html" if builder == "dirhtml" else stem.with_suffix(".html")
 77
 78
 79def _paged(app: SphinxTestApp, builder: str = "html") -> list[str]:
 80    """The copies in the output that have a source page beside them."""
 81    outdir = Path(app.outdir)
 82    return [c for c in _copies(outdir) if _page(outdir, c, builder).is_file()]
 83
 84
 85def _recording(
 86    make_app: _MakeApp, root: Path, **kwargs: object
 87) -> tuple[SphinxTestApp, list[str]]:
 88    """A built application, and the source pages (``src/…``) it wrote."""
 89    app = make_app(srcdir=root / "docs", **kwargs)
 90    written: list[str] = []
 91
 92    def record(_app: object, pagename: str, *_args: object) -> None:
 93        if pagename.startswith("src/"):
 94            written.append(pagename)
 95
 96    app.connect("html-page-context", record)
 97    app.build()
 98    return app, written
 99
100
101@pytest.mark.parametrize("path", ["directive", "config-only"])
102def test_a_cleaned_output_directory_gets_its_copies_and_pages_back(
103    tmp_path: Path, make_app: _MakeApp, path: str
104) -> None:
105    """The HTML output directory removed, the doctrees kept: the next build reads
106    nothing, and every copy and page is back -- the card's local link resolves to a
107    file (it was a dead link)."""
108    _local_project(tmp_path, path)
109    first = _build(tmp_path, make_app)
110    assert _paged(first) == COPIES[path]
111    shutil.rmtree(first.outdir)
112
113    app = _build(tmp_path, make_app, freshenv=False)
114
115    assert "0 added, 0 changed, 0 removed" in _status(app)
116    assert _copies(Path(app.outdir)) == COPIES[path]
117    assert _paged(app) == COPIES[path]
118    [row] = _card_links(app, "index.html")
119    assert [href for href, _name in row] == ["src/refs.html#L-1", "src/refs.html#L-3"]
120    assert all(Path(app.outdir, href.split("#")[0]).is_file() for href, _ in row)
121    if path == "directive":
122        index = Path(app.outdir, "index.html").read_text(encoding="utf-8")
123        assert _field_links(index, "local-url") == ["src/impl.html#L-1"]
124
125
126@pytest.mark.parametrize("path", ["directive", "config-only"])
127def test_two_builders_sharing_the_doctrees_each_get_the_copies_and_pages(
128    tmp_path: Path, make_app: _MakeApp, path: str
129) -> None:
130    """``html``, then ``dirhtml`` on the same ``_build/doctrees`` (the Makefile
131    layout): the second builder's output has its copies and pages too."""
132    _local_project(tmp_path, path)
133    html = _build(tmp_path, make_app)
134    dirhtml = _build(tmp_path, make_app, buildername="dirhtml", freshenv=False)
135
136    assert _paged(html) == COPIES[path]
137    assert _copies(Path(dirhtml.outdir)) == COPIES[path]
138    assert _paged(dirhtml, "dirhtml") == COPIES[path]
139
140
141@pytest.mark.parametrize("builder", ["html", "dirhtml"])
142@pytest.mark.parametrize("path", ["directive", "config-only"])
143def test_an_unchanged_build_rewrites_no_copy_or_page(
144    tmp_path: Path, make_app: _MakeApp, path: str, builder: str
145) -> None:
146    """A build that reads nothing keeps every copy and page, and rewrites none of them:
147    the copies and pages are up to date -- ``dirhtml``'s pages at its own output path
148    (Sphinx 7.4 stops at "no targets are out of date" before collecting any page; 9.x
149    collects, and codelinks yields none)."""
150    _local_project(tmp_path, path)
151    first = _build(tmp_path, make_app, buildername=builder)
152    outdir = Path(first.outdir)
153    files = [outdir / c for c in COPIES[path]] + [
154        _page(outdir, c, builder) for c in COPIES[path]
155    ]
156    stamps = [f.stat().st_mtime_ns for f in files]
157
158    app, written = _recording(make_app, tmp_path, buildername=builder, freshenv=False)
159
160    assert "0 added, 0 changed, 0 removed" in _status(app)
161    assert _paged(app, builder) == COPIES[path]
162    assert written == []
163    assert [f.stat().st_mtime_ns for f in files] == stamps
164
165
166def test_a_page_whose_need_moved_to_another_document_is_written_again(
167    tmp_path: Path, make_app: _MakeApp
168) -> None:
169    """``page1`` defines ``IMPL_1`` and ``page2`` defers to it; ``page1`` removed, ``page2``
170    is read again and defines it. The source is untouched, yet the page is written again:
171    its ``[docs]`` link names the new document."""
172    trace = "\n.. src-trace::\n   :project: src\n   :file: impl.cpp\n"
173    _local_project(
174        tmp_path,
175        "directive",
176        append={"docs/page1.rst": trace, "docs/page2.rst": trace},
177        **_scoped(":file: refs.cpp"),
178    )
179    first = _build(tmp_path, make_app)
180    page = Path(first.outdir, "src", "impl.html")
181    assert DOCS_LINK.findall(page.read_text(encoding="utf-8")) == [
182        "../page1.html#IMPL_1"
183    ]
184
185    (tmp_path / "docs" / "page1.rst").unlink()
186    app = _build(tmp_path, make_app, freshenv=False)
187
188    assert re.search(r"reading sources\.\.\. \[[^\]]*\] page2\b", _status(app))
189    assert DOCS_LINK.findall(page.read_text(encoding="utf-8")) == [
190        "../page2.html#IMPL_1"
191    ]
192
193
194def test_the_source_page_css_is_on_source_pages_only(
195    tmp_path: Path, make_app: _MakeApp
196) -> None:
197    _local_project(tmp_path, "directive")
198    app = _build(tmp_path, make_app)
199
200    css = "_static/source_tracing/ub_sct.css"
201    assert css in Path(app.outdir, "src", "impl.html").read_text(encoding="utf-8")
202    assert css not in Path(app.outdir, "index.html").read_text(encoding="utf-8")
203
204
205@pytest.mark.parametrize("path", ["directive", "config-only"])
206def test_a_source_edit_shows_in_the_copy_and_the_page(
207    tmp_path: Path, make_app: _MakeApp, path: str
208) -> None:
209    _local_project(tmp_path, path)
210    _build(tmp_path, make_app)
211    source = tmp_path / "src" / "refs.cpp"
212    _edit(source, "return a + 1;", "return a + 12345;")
213
214    app = _build(tmp_path, make_app, freshenv=False)
215
216    assert Path(app.outdir, "src", "refs.cpp").read_bytes() == source.read_bytes()
217    page = Path(app.outdir, "src", "refs.html").read_text(encoding="utf-8")
218    assert "12345" in page
219
220
221@pytest.mark.parametrize("builder", ["html", "dirhtml"])
222def test_the_back_link_follows_the_builders_uri_rule(
223    tmp_path: Path, make_app: _MakeApp, builder: str
224) -> None:
225    """The ``[docs]`` link of a source page is the builder's own relative URI to the
226    need's document: ``dirhtml`` writes ``src/impl/index.html`` and ``sub/deep/``."""
227    _local_project(
228        tmp_path,
229        "directive",
230        **_scoped(":file: impl.cpp"),
231        **{"src/deep.cpp": "// @deep impl, IMPL_DEEP, impl\n"},
232    )
233    deep = tmp_path / "docs" / "sub" / "deep.rst"
234    deep.write_text(
235        deep.read_text(encoding="utf-8")
236        + "\n.. src-trace::\n   :project: src\n   :file: deep.cpp\n",
237        encoding="utf-8",
238    )
239    app = _build(tmp_path, make_app, buildername=builder)
240
241    def links(copy: str) -> list[str]:
242        page = _page(Path(app.outdir), copy, builder).read_text(encoding="utf-8")
243        return DOCS_LINK.findall(page)
244
245    if builder == "html":
246        assert links("src/impl.cpp") == ["../index.html#IMPL_1"]
247        assert links("src/deep.cpp") == ["../sub/deep.html#IMPL_DEEP"]
248    else:
249        assert links("src/impl.cpp") == ["../../#IMPL_1"]
250        assert links("src/deep.cpp") == ["../../sub/deep/#IMPL_DEEP"]
251
252
253def test_two_documents_paging_one_file_merge_their_anchors(
254    tmp_path: Path, make_app: _MakeApp
255) -> None:
256    """Two projects over one source directory, each with its own one-line style, trace
257    one file from two documents: one copy, one page, each need's ``[docs]`` link."""
258    _local_project(
259        tmp_path,
260        "directive",
261        **_scoped(":file: both.cpp"),
262        **{
263            "src/both.cpp": (
264                "// @in index, IMPL_1, impl\n// [[in later, IMPL_2, impl]]\nint x;\n"
265            )
266        },
267    )
268    toml = tmp_path / "docs" / "ubproject.toml"
269    toml.write_text(
270        toml.read_text(encoding="utf-8")
271        + (
272            "\n[codelinks.projects.two]\n"
273            f'remote_url_pattern = "{GITHUB}"\n'
274            "[codelinks.projects.two.source_discover]\n"
275            'src_dir = "../src"\n'
276            'comment_type = "cpp"\n'
277            "[codelinks.projects.two.analyse.oneline_comment_style]\n"
278            'start_sequence = "[["\n'
279            'end_sequence = "]]"\n'
280        ),
281        encoding="utf-8",
282    )
283    later = tmp_path / "docs" / "later.rst"
284    later.write_text(
285        later.read_text(encoding="utf-8")
286        + "\n.. src-trace::\n   :project: two\n   :file: both.cpp\n",
287        encoding="utf-8",
288    )
289    app = _build(tmp_path, make_app)
290
291    assert _paged(app) == ["src/both.cpp"]
292    page = Path(app.outdir, "src", "both.html").read_text(encoding="utf-8")
293    assert DOCS_LINK.findall(page) == ["../index.html#IMPL_1", "../later.html#IMPL_2"]
294
295
296#: project ``two``: ``src`` again, with its own one-line style (``[[...]]``), so that two
297#: documents create needs from one file
298TWO = (
299    "\n[codelinks.projects.two]\n"
300    "[codelinks.projects.two.source_discover]\n"
301    'src_dir = "../src"\n'
302    'comment_type = "cpp"\n'
303    "[codelinks.projects.two.analyse.oneline_comment_style]\n"
304    'start_sequence = "[["\n'
305    'end_sequence = "]]"\n'
306)
307BOTH = "// @in later, IMPL_1, impl\n// [[in another, IMPL_2, impl]]\nint x;\n"
308
309
310def _trace(project: str) -> str:
311    return f"\n.. src-trace::\n   :project: {project}\n   :file: both.cpp\n"
312
313
314def _shared_page(root: Path, *, page1: bool) -> None:
315    """``src/both.cpp`` paged from ``later`` (``IMPL_1``, project ``src``) and, with
316    ``page1``, from ``page1`` too (``IMPL_2``, project ``two``); ``index`` traces
317    nothing, so its re-reads (a glob toctree) touch no page."""
318    _local_project(
319        root,
320        "directive",
321        append={"docs/later.rst": _trace("src")}
322        | ({"docs/page1.rst": _trace("two")} if page1 else {}),
323        **NO_DIRECTIVE,
324        **{"src/both.cpp": BOTH},
325    )
326    toml = root / "docs" / "ubproject.toml"
327    toml.write_text(toml.read_text(encoding="utf-8") + TWO, encoding="utf-8")
328
329
330def _both_links(app: SphinxTestApp) -> list[str]:
331    page = Path(app.outdir, "src", "both.html").read_text(encoding="utf-8")
332    return DOCS_LINK.findall(page)
333
334
335def test_a_document_that_stops_tracing_a_shared_file_leaves_its_page(
336    tmp_path: Path, make_app: _MakeApp
337) -> None:
338    """``later`` and ``page1`` both page ``src/both.cpp``; ``page1``'s directive removed
339    (the document edited, the file untouched): the page is written again without
340    ``page1``'s ``[docs]`` link, although the remaining link names a document not read."""
341    _shared_page(tmp_path, page1=True)
342    first = _build(tmp_path, make_app)
343    assert _both_links(first) == ["../later.html#IMPL_1", "../page1.html#IMPL_2"]
344
345    page1 = tmp_path / "docs" / "page1.rst"
346    _edit(page1, _trace("two"), "")
347    app = _build(tmp_path, make_app, freshenv=False)
348
349    assert "0 added, 1 changed, 0 removed" in _status(app)
350    assert _both_links(app) == ["../later.html#IMPL_1"]
351
352
353def test_a_new_document_tracing_a_paged_file_adds_its_back_link(
354    tmp_path: Path, make_app: _MakeApp
355) -> None:
356    """``src/both.cpp`` is paged from ``later``; a NEW document traces it too (the file
357    untouched): the page is written again with the new document's ``[docs]`` link."""
358    _shared_page(tmp_path, page1=False)
359    first = _build(tmp_path, make_app)
360    assert _both_links(first) == ["../later.html#IMPL_1"]
361
362    (tmp_path / "docs" / "added.rst").write_text(
363        "Added\n=====\n" + _trace("two"), encoding="utf-8"
364    )
365    app = _build(tmp_path, make_app, freshenv=False)
366
367    assert "1 added" in _status(app)
368    assert _both_links(app) == ["../later.html#IMPL_1", "../added.html#IMPL_2"]
369
370
371@pytest.mark.parametrize("same_size", [True, False], ids=["same-size", "other-size"])
372@pytest.mark.parametrize("path", ["directive", "config-only"])
373def test_a_source_replaced_by_an_older_file_shows_in_the_copy_and_the_page(
374    tmp_path: Path, make_app: _MakeApp, path: str, same_size: bool
375) -> None:
376    """The source replaced by a file whose modification time is OLDER than the copy's
377    (``cp -p``, ``rsync -t``, ``tar x``, a restored cache): the analysis runs again, and
378    the copy and the page show the new content -- the copy carries its source's
379    modification time, so any difference counts, not only a newer one."""
380    _local_project(tmp_path, path)
381    first = _build(tmp_path, make_app)
382    outdir = Path(first.outdir)
383    source = tmp_path / "src" / "refs.cpp"
384    copy = outdir / "src" / "refs.cpp"
385    edit = "return a + 7;" if same_size else "return a + 12345;"
386    source.write_text(
387        source.read_text(encoding="utf-8").replace("return a + 1;", edit),
388        encoding="utf-8",
389    )
390    older = copy.stat().st_mtime - 100
391    os.utime(source, (older, older))
392
393    _build(tmp_path, make_app, freshenv=False)
394
395    assert copy.read_bytes() == source.read_bytes()
396    page = (outdir / "src" / "refs.html").read_text(encoding="utf-8")
397    assert ('<span class="mi">7</span>' if same_size else "12345") in page
398
399
400def _read_only_is_enforced(directory: Path) -> bool:
401    """Whether a read-only file here refuses to be written (not on Windows, not as
402    root)."""
403    probe = directory / "read-only-probe"
404    probe.write_text("", encoding="utf-8")
405    probe.chmod(0o444)
406    try:
407        with probe.open("w", encoding="utf-8"):
408            return False
409    except PermissionError:
410        return True
411    finally:
412        probe.chmod(0o644)
413        probe.unlink()
414
415
416@pytest.mark.parametrize("edit", ["other-size", "one-byte-more"])
417def test_a_read_only_source_that_changes_is_copied_and_paged_again(
418    tmp_path: Path, make_app: _MakeApp, edit: str
419) -> None:
420    """A read-only checkout (Perforce, a Nix or Bazel tree): the copy carries the
421    source's modification time but not its mode, so a changed source is copied over it
422    -- no ``Permission denied``, no stale copy or page."""
423    if os.name == "nt" or not _read_only_is_enforced(tmp_path):
424        pytest.skip("read-only modes are not enforced here")
425    _local_project(tmp_path, "directive")
426    source = tmp_path / "src" / "refs.cpp"
427    source.chmod(0o444)
428    outdir = Path(tmp_path, "docs", "_build", "html")
429    try:
430        _build(tmp_path, make_app)
431        source.chmod(0o644)  # p4 edit
432        new = "return a + 12345;" if edit == "other-size" else "return a + 1; "
433        _edit(source, "return a + 1;", new)
434        source.chmod(0o444)  # submitted: read-only again
435
436        app = _build(tmp_path, make_app, freshenv=False)
437
438        assert [w for w in build_warnings(app) if "source page" in w] == []
439        assert (outdir / "src" / "refs.cpp").read_bytes() == source.read_bytes()
440        if edit == "other-size":
441            page = (outdir / "src" / "refs.html").read_text(encoding="utf-8")
442            assert "12345" in page
443    finally:
444        source.chmod(0o644)
445        for path in outdir.rglob("*"):
446            if path.is_file():
447                path.chmod(0o644)
448
449
450def test_a_stale_scan_of_a_project_a_directive_now_owns_pages_nothing(
451    tmp_path: Path, make_app: _MakeApp
452) -> None:
453    """A config-only project gains a directive (over ``impl.cpp`` alone): its stored scan
454    stays, unused, and its page (``src/refs.cpp``) is no longer among the pages; the
455    directive removed again, the scan's page is back."""
456    from sphinx_codelinks.config import CodeLinksConfig
457    from sphinx_codelinks.sphinx_extension.rediscovery import (
458        config_only_refs_store,
459        effective_pages,
460    )
461
462    def targets(app: SphinxTestApp) -> list[str]:
463        config = CodeLinksConfig.from_sphinx(app.config)
464        return [page.target for page in effective_pages(app.env, config)]
465
466    _local_project(tmp_path, "config-only")
467    assert targets(_build(tmp_path, make_app)) == ["src/refs.cpp"]
468    index = tmp_path / "docs" / "index.rst"
469    text = index.read_text(encoding="utf-8")
470    _edit(
471        index, text, text + "\n.. src-trace::\n   :project: src\n   :file: impl.cpp\n"
472    )
473
474    owned = _build(tmp_path, make_app, freshenv=False)
475
476    assert [p.target for p in config_only_refs_store(owned.env)["src"].pages] == [
477        "src/refs.cpp"
478    ]
479    assert targets(owned) == ["src/impl.cpp"]
480    _edit(index, index.read_text(encoding="utf-8"), text)
481    assert targets(_build(tmp_path, make_app, freshenv=False)) == ["src/refs.cpp"]
482
483
484#: two projects whose source directories share their name: both copy to ``src/x.cpp``
485_SAME_BASENAME = """
486[codelinks]
487set_local_url = true
488set_remote_url = false
489
490[codelinks.projects.a.source_discover]
491src_dir = "../a/src"
492comment_type = "cpp"
493
494[codelinks.projects.b.source_discover]
495src_dir = "../b/src"
496comment_type = "cpp"
497"""
498
499
500def test_two_sources_for_one_page_warn(tmp_path: Path, make_app: _MakeApp) -> None:
501    """``a/src/x.cpp`` (from ``index``) and ``b/src/x.cpp`` (from ``later``) both copy to
502    ``src/x.cpp``: one ``codelinks.source_page`` warning names the target and both
503    sources; the first record's source (``index``'s) is the one paged."""
504    _project(
505        tmp_path,
506        files={
507            "docs/ubproject.toml": _SAME_BASENAME,
508            "docs/index.rst": "Index\n=====\n\n.. toctree::\n   :glob:\n\n   *\n   sub/*\n\n"
509            ".. src-trace::\n   :project: a\n",
510            "docs/later.rst": "Later\n=====\n\n.. src-trace::\n   :project: b\n",
511            "a/src/x.cpp": "// from A\n// @a need, IMPL_A, impl\n",
512            "b/src/x.cpp": "// from B\n// @b need, IMPL_B, impl\n",
513        },
514    )
515    app = _build(tmp_path, make_app)
516
517    warned = [w for w in build_warnings(app) if "source page" in w]
518    assert len(warned) == 1, build_warnings(app)
519    assert "'src/x.cpp'" in warned[0]
520    assert (tmp_path / "a" / "src" / "x.cpp").as_posix() in warned[0]
521    assert (tmp_path / "b" / "src" / "x.cpp").as_posix() in warned[0]
522    if _SHOWS_WARNING_TYPES:
523        assert warned[0].endswith("[codelinks.source_page]")
524    assert "from A" in Path(app.outdir, "src", "x.html").read_text(encoding="utf-8")
525
526
527def test_an_environment_from_before_the_pages_store_is_read_again(
528    tmp_path: Path, make_app: _MakeApp
529) -> None:
530    """An environment pickled by a release without the page records (no
531    ``sphinx_codelinks`` entry in its extension versions, no store): Sphinx starts a
532    fresh one, so a cleaned output gets its copies and pages back on the first build
533    after the upgrade -- every document is read once."""
534    _local_project(tmp_path, "directive")
535    first = _build(tmp_path, make_app)
536    pickled = Path(first.doctreedir, "environment.pickle")
537    env = pickle.loads(pickled.read_bytes())
538    env.version = {k: v for k, v in env.version.items() if k != "sphinx_codelinks"}
539    del env.codelinks_source_pages
540    pickled.write_bytes(pickle.dumps(env, pickle.HIGHEST_PROTOCOL))
541    shutil.rmtree(first.outdir)
542
543    app = _build(tmp_path, make_app, freshenv=False)
544
545    assert "0 added, 0 changed" not in _status(app)
546    assert _paged(app) == COPIES["directive"]
547
548
549_REMOVE_AT_COLLECT = """
550def setup(app):
551    from pathlib import Path
552
553    def remove(app):
554        (Path(app.srcdir).parent / "src" / "impl.cpp").unlink()
555        return []
556
557    app.connect("html-collect-pages", remove)
558"""
559
560
561def test_a_source_removed_before_the_pages_are_written_warns_once(
562    tmp_path: Path, make_app: _MakeApp
563) -> None:
564    """A recorded source that cannot be read when the pages are written (removed since
565    its document was read) warns once, naming it; the build goes on and pages the
566    rest."""
567    _local_project(tmp_path, "directive", append={"docs/conf.py": _REMOVE_AT_COLLECT})
568    app = _build(tmp_path, make_app)
569
570    pages = [w for w in build_warnings(app) if "source page" in w]
571    assert len(pages) == 1, build_warnings(app)
572    assert "src/impl.cpp" in pages[0]
573    if _SHOWS_WARNING_TYPES:
574        assert pages[0].endswith("[codelinks.source_page]")
575    assert [w for w in build_warnings(app) if w not in pages] == [DANGLING]
576    assert _paged(app) == ["src/refs.cpp"]
577    assert not Path(app.outdir, "src", "impl.html").exists()
578
579
580@pytest.mark.skipif(
581    not parallel_available, reason="Sphinx reads sources in parallel on POSIX only"
582)
583def test_parallel_build_pages_a_directives_sources(
584    tmp_path: Path, make_app: _MakeApp
585) -> None:
586    """Under ``-j 2`` the directive is read in a worker; its record reaches the main
587    process, so the page exists beside the copy (#2044: the copy only, before)."""
588    _url_project(tmp_path, patterns={"a": GITHUB}, pages=7)
589    app = make_app(srcdir=tmp_path / "docs", freshenv=True, parallel=2)
590    app.build()
591
592    assert re.search(r"reading sources\.\.\. \[\s*\d+%\] \S+ \.\. \S+", _status(app))
593    assert Path(app.outdir, "srca", "a.cpp").is_file()
594    page = Path(app.outdir, "srca", "a.html").read_text(encoding="utf-8")
595    assert DOCS_LINK.findall(page) == ["../index.html#IMPL_A"]
596
597
598def test_a_config_only_scan_pickled_before_pages_existed_still_loads() -> None:
599    """An environment pickled by an earlier release holds ``ConfigOnlyScan`` objects
600    without the ``pages`` field; they load, and page nothing."""
601    from sphinx_codelinks.sphinx_extension.rediscovery import ConfigOnlyScan
602
603    scan = ConfigOnlyScan(fingerprint=(), records=[])
604    object.__delattr__(scan, "pages")  # the state an older class pickled
605    assert "pages" not in vars(scan)
606
607    loaded = pickle.loads(pickle.dumps(scan))
608    assert loaded.pages == ()
609    assert loaded == ConfigOnlyScan(fingerprint=(), records=[])