diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index 2649eedbe..3f266b1c4 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -123,13 +123,76 @@ jobs: echo "README.md Hugo version references are consistent" + # ---------------------------------------------------------------------------- + # Run the bibliography JSON-LD test suite against a test-environment Hugo + # build that mounts tests/fixtures/bibliography/ alongside the real content. + # This job must pass before changes can be merged or deployed. + # + test: + needs: [check, validate-docs] + if: always() && (github.event_name == 'push' || github.event_name == 'pull_request' || needs.check.outputs.cacheHit != 'true') && (needs.validate-docs.result == 'success' || needs.validate-docs.result == 'skipped') && (needs.check.result == 'success' || needs.check.result == 'skipped') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + fetch-depth: 0 + + - name: Cache Zotero Bibliography + id: cache-bib + uses: actions/cache@v5 + with: + path: | + static/data/bibliography.json + static/data/bibItems + content/en/history/bibliography + key: bib-${{ needs.check.outputs.zoteroVersion }} + + - name: Install Bibliography + if: steps.cache-bib.outputs.cache-hit != 'true' + run: | + echo "Retrieve bibliography" + cd scripts + chmod +x ./update_bibliography.sh + chmod +x ./bibSplit.pl + ./update_bibliography.sh + + - name: Install Hugo CLI + run: | + wget -O ${{ runner.temp }}/hugo.deb https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb \ + && sudo dpkg -i ${{ runner.temp }}/hugo.deb + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: 'npm' + + - name: Install Node dependencies + run: npm ci + + - name: Build test site + env: + HUGO_CACHEDIR: ${{ runner.temp }}/hugo_cache + TZ: America/New_York + run: hugo --environment testing --destination tests/public_test + + - name: Install pytest + run: pip install pytest pyyaml pytest-cov + + - name: Run content-integrity tests + run: pytest tests/test_content_integrity.py -v --cov-report=term-missing + + - name: Run JSON-LD tests + run: pytest tests/test_bibliography_jsonld.py -v + # ---------------------------------------------------------------------------- # Build the website. This job is conditional, we will always run it on a # push or if on a scheduled run the cache was determined to be out of date. # build: - needs: [check, validate-docs] - if: always() && (github.event_name == 'push' || github.event_name == 'pull_request' || needs.check.outputs.cacheHit != 'true') && (needs.validate-docs.result == 'success' || needs.validate-docs.result == 'skipped') && (needs.check.result == 'success' || needs.check.result == 'skipped') + needs: [check, validate-docs, test] + if: always() && (github.event_name == 'push' || github.event_name == 'pull_request' || needs.check.outputs.cacheHit != 'true') && (needs.validate-docs.result == 'success' || needs.validate-docs.result == 'skipped') && (needs.check.result == 'success' || needs.check.result == 'skipped') && (needs.test.result == 'success') runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -183,6 +246,12 @@ jobs: TZ: America/New_York run: hugo --cleanDestinationDir -e $HUGO_ENVIRONMENT + - name: Install pytest + run: pip install pytest pytest-cov + + - name: Run build-integrity tests + run: pytest tests/test_hugo_build.py -v + - name: Upload artifact uses: actions/upload-pages-artifact@v5.0.0 with: @@ -194,7 +263,7 @@ jobs: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest - needs: build + needs: [build, test] steps: - name: Deploy to GitHub Pages diff --git a/.gitignore b/.gitignore index 66e482551..2a1a5fd2c 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ _site .sass-cache .jekyll-metadata public/ +tests/public_test/ node_modules/ _gen/ .hugo_build.lock diff --git a/config/_default/params.yaml b/config/_default/params.yaml index 0ccd9e4da..c4f0ba91c 100644 --- a/config/_default/params.yaml +++ b/config/_default/params.yaml @@ -163,9 +163,6 @@ ui: # Sidebar generation is slow otherwise sidebar_cache_limit: 100 - # We have almost 200 attributes; don't truncate the sidebar to max 50 contents. - sidebar_menu_truncate: 0 - # Set to true to disable breadcrumb navigation. breadcrumb_disable: false diff --git a/config/testing/hugo.yaml b/config/testing/hugo.yaml new file mode 100644 index 000000000..f625566c3 --- /dev/null +++ b/config/testing/hugo.yaml @@ -0,0 +1,50 @@ +# Hugo testing environment configuration +# +# Used exclusively when running the bibliography JSON-LD test suite: +# +# hugo --environment testing --destination tests/public_test +# +# This environment mounts the test fixtures from tests/fixtures/bibliography/ +# alongside the real bibliography content, so the JSON-LD template is exercised +# against controlled data without those pages ever appearing in a production build. +# +# The module.mounts block must re-declare all standard project-level mounts +# because adding any mount replaces Hugo's defaults for the project. +# Theme (Docsy) mounts defined within that module are unaffected. + +baseURL: "http://localhost/" + +publishDir: tests/public_test + +# Broken refs in unrelated content files must not block the test build. +refLinksErrorLevel: WARNING + +# Explicit mounts (see module section below) require this to be re-stated +# so that English content URLs do not gain an /en/ prefix. +defaultContentLanguageInSubdir: false + +module: + mounts: + # ── Standard project mounts (preserve defaults) ────────────────────────── + # Mount content/en/ directly as the English language content root so that + # URLs are generated without an /en/ prefix (matching production behavior). + - source: content/en + target: content + lang: en + - source: static + target: static + - source: layouts + target: layouts + - source: data + target: data + - source: assets + target: assets + - source: i18n + target: i18n + - source: archetypes + target: archetypes + # ── Test fixtures ───────────────────────────────────────────────────────── + # Mount alongside the real bibliography content for the English language. + - source: tests/fixtures/bibliography + target: content/history/bibliography + lang: en diff --git a/content/en/history/bibliography/_index.md b/content/en/history/bibliography/_index.md index a4ad1df13..4a4998312 100644 --- a/content/en/history/bibliography/_index.md +++ b/content/en/history/bibliography/_index.md @@ -4,6 +4,7 @@ heading: Interlisp Bibliography type: bibliography cascade: type: bibliography + toc_hide: true weight: 5 aliases: - /bibliography/ diff --git a/layouts/_partials/bibliography-date.html b/layouts/_partials/bibliography-date.html new file mode 100644 index 000000000..69c1bc9c5 --- /dev/null +++ b/layouts/_partials/bibliography-date.html @@ -0,0 +1,27 @@ +{{- /* + bibliography-date.html + Resolves a bibliography page's publication date. + + The front-matter `date` param is a plain YYYY-MM-DD string produced by + bibSplit.pl. Hugo's built-in .Date can be a full datetime parsed from git + history; this partial prefers the explicit param when it is well-formed. + + Accepts: page context (.) + Returns: dict + dateISO – YYYY-MM-DD string; "0001-01-01" when no date is known + hasDate – true when dateISO is not the zero value + dateLabel – .Params.readabledate when present, otherwise dateISO +*/ -}} +{{- $dateTime := .Date -}} +{{- with .Params.date -}} + {{- $iso := trim . " " -}} + {{- if and (ne $iso "") (findRE `^\d{4}-\d{2}-\d{2}$` $iso) -}} + {{- $dateTime = time $iso -}} + {{- end -}} +{{- end -}} +{{- $dateISO := $dateTime.Format "2006-01-02" -}} +{{- return dict + "dateISO" $dateISO + "hasDate" (ne $dateISO "0001-01-01") + "dateLabel" (or .Params.readabledate $dateISO) +-}} diff --git a/layouts/_partials/bibliography-json-ld.html b/layouts/_partials/bibliography-json-ld.html new file mode 100644 index 000000000..e353ed8da --- /dev/null +++ b/layouts/_partials/bibliography-json-ld.html @@ -0,0 +1,280 @@ +{{- /* + bibliography-json-ld.html + Emits a " ($ld | jsonify (dict "indent" " ")) | safeHTML -}} + +{{- else if .IsSection -}} + +{{- /* ── COLLECTION PAGE (bibliography index) ──────────────────────────── */ -}} +{{- $pages := .RegularPages -}} +{{- $pages = sort $pages "Title" "asc" -}} + +{{- $itemList := slice -}} +{{- range $i, $p := $pages -}} + {{- $itemList = append (dict + "@type" "ListItem" + "position" (add $i 1) + "name" $p.Title + "url" $p.Permalink + ) $itemList -}} +{{- end -}} + +{{- $desc := .Description | default "A bibliography of publications, patents, reports, and other artifacts related to the Interlisp programming environment." -}} + +{{- $ld := dict + "@context" "https://schema.org" + "@type" "CollectionPage" + "name" .Title + "url" .Permalink + "description" $desc + "inLanguage" "en-US" + "mainEntity" (dict + "@type" "ItemList" + "name" .Title + "numberOfItems" (len $pages) + "itemListElement" $itemList + ) +-}} + +{{- printf "" ($ld | jsonify (dict "indent" " ")) | safeHTML -}} + +{{- end -}} + +{{- end -}} diff --git a/layouts/_partials/hooks/head-end.html b/layouts/_partials/hooks/head-end.html index 9daa28c04..f800ce9ff 100644 --- a/layouts/_partials/hooks/head-end.html +++ b/layouts/_partials/hooks/head-end.html @@ -1,2 +1,3 @@ +{{ partial "bibliography-json-ld.html" . }} diff --git a/layouts/bibliography/single.html b/layouts/bibliography/single.html index 62dd9a4e4..7f6a0a9be 100644 --- a/layouts/bibliography/single.html +++ b/layouts/bibliography/single.html @@ -30,18 +30,10 @@

{{ .Title }}

{{- end -}}

- {{- $dateTime := .Date -}} - {{- with .Params.date -}} - {{- $isoDate := trim . " " -}} - {{- if and (ne $isoDate "") (findRE `^\d{4}-\d{2}-\d{2}` $isoDate) -}} - {{- $dateTime = time $isoDate -}} - {{- end -}} - {{- end -}} - {{- $dateISO := $dateTime.Format "2006-01-02" -}} - {{- $dateLabel := or .Params.readabledate $dateISO -}} + {{- $date := partial "bibliography-date.html" . -}} {{- /* Don't display bogus date */ -}} - {{- if ne $dateISO "0001-01-01" -}} - + {{- if $date.hasDate -}} + {{- end -}}

diff --git a/scripts/bibSplit.pl b/scripts/bibSplit.pl index 8a9b77be2..950235ace 100755 --- a/scripts/bibSplit.pl +++ b/scripts/bibSplit.pl @@ -58,10 +58,12 @@ sub sanitize_text { my $type = $obj->{type} // ''; - # Titles can have colons and other special characters. Place YAML keyword on one line - # and follow it with the title indented on subsequent line + # Titles can have colons and other special characters. Use a YAML double-quoted scalar + # so the decoded value has no trailing newline (the literal-block `|` style would add one). my $itemTitle = sanitize_text($obj->{title} // ''); - my $title = $itemTitle eq '' ? "title: ''" : "title: |\n $itemTitle\n"; + (my $escapedTitle = $itemTitle) =~ s/\\/\\\\/g; + $escapedTitle =~ s/"/\\"/g; + my $title = $itemTitle eq '' ? "title: ''" : "title: \"$escapedTitle\""; # Abstracts can be multi-line and contain multiple paragraphs. Place YAML keyword on # one line and follow it with the abstract indented on subsequent lines. @@ -78,13 +80,13 @@ sub sanitize_text { my $itemAuthors = ''; if (ref($obj->{authorsFormatted}) eq 'ARRAY' && @{$obj->{authorsFormatted}}) { $itemAuthors = "\n"; - for my $a (@{$obj->{authorsFormatted}}) { + for my $author (@{$obj->{authorsFormatted}}) { # The encode_json is where extended unicode chars get corrupted, e.g., "Emanuelson, Pär" # There may be other things that now don't work!! # my $quoted = encode_json($a // ''); # sanitize_text seems to handle them correctly - my $san = sanitize_text($a // ''); - $itemAuthors .= " - \"$san\"\n"; + my $san_author = sanitize_text($author // ''); + $itemAuthors .= " - \"$san_author\"\n"; } $itemAuthors =~ s/\n$//u; # strip trailing newline } @@ -92,11 +94,11 @@ sub sanitize_text { my $itemEditors = ''; if (ref($obj->{editorsFormatted}) eq 'ARRAY' && @{$obj->{editorsFormatted}}) { $itemEditors = "\n"; - for my $a (@{$obj->{editorsFormatted}}) { + for my $editor (@{$obj->{editorsFormatted}}) { # as above... - # my $quoted = encode_json($a // ''); - my $san = sanitize_text($a // ''); - $itemEditors .= " - \"$san\"\n"; + # my $quoted = encode_json($editor // ''); + my $san_editor = sanitize_text($editor // ''); + $itemEditors .= " - \"$san_editor\"\n"; } $itemEditors =~ s/\n$//u; # strip trailing newline } diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 000000000..a1b18776d --- /dev/null +++ b/setup.cfg @@ -0,0 +1,37 @@ +[tool:pytest] +# Default test paths +testpaths = tests + +# Coverage is opt-in (run with --cov to enable) but the target and report +# format are pre-configured so the flags don't need to be repeated each time. +# Usage: +# pytest --cov # term-missing report (default) +# pytest --cov --cov-report=html # HTML report in htmlcov/ +# pytest --cov --cov-report=xml # Cobertura XML (for CI) +addopts = + --cov-report=term-missing + +[coverage:run] +# Measure coverage of the test infrastructure only. +# The Hugo template (the real "code under test") is not Python and cannot +# be measured by coverage.py; its branch coverage is tracked structurally +# by having one fixture file per Schema.org type. +source = tests +omit = + tests/public_test/* + +[coverage:report] +# Lines that are only reachable when the test infrastructure itself fails +# (e.g., "built page not found" guards, JSONDecodeError handlers) are +# expected to be uncovered during a normal green run. Exclude them so +# the coverage percentage reflects meaningful, reachable logic. +exclude_lines = + # Standard pragma + pragma: no cover + # pytest skip/fail paths – only reached when setup is broken + pytest\.skip\( + pytest\.fail\( + # Defensive parser error handler – only reached on malformed HTML + except json\.JSONDecodeError + # YAML parse errors – only reached on malformed front matter + return \{ diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..b90beda73 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,153 @@ +""" +tests/conftest.py +================= +Shared test infrastructure for the Interlisp bibliography test suite. + +Contains: + - Path constants consumed by all test modules + - FIXTURE_SLUGS: canonical list of every test fixture slug + - ``hugo_build``: session-scoped fixture — builds the *test* environment + (``tests/public_test/``) used by test_bibliography_jsonld.py + - ``production_build``: session-scoped fixture — builds the *production* + site (``public/``) used by test_hugo_build.py and test_html_validation.py + +Non-fixture utilities (JSON-LD extraction, assertion helpers) live in the +test module that uses them. Only things needed by *multiple* test files, or +the pytest fixture infrastructure itself, belong here. +""" + +import os +import subprocess +from pathlib import Path + +import pytest + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- + +REPO_ROOT: Path = Path(__file__).parent.parent +PROD_PUBLIC: Path = REPO_ROOT / "public" +TEST_PUBLIC: Path = REPO_ROOT / "tests" / "public_test" +BIB_PUBLIC: Path = TEST_PUBLIC / "history" / "bibliography" +FIXTURES_DIR: Path = REPO_ROOT / "tests" / "fixtures" / "bibliography" +CONTENT_DIR: Path = REPO_ROOT / "content" / "en" / "history" / "bibliography" + +# Hugo lowercases filenames when generating URL slugs. +FIXTURE_SLUGS: tuple[str, ...] = ( + "ztest-journal", + "ztest-magazine", + "ztest-conference", + "ztest-book", + "ztest-chapter", + "ztest-report", + "ztest-thesis", + "ztest-patent", + "ztest-webpage", + "ztest-blog", + "ztest-video", + "ztest-software", + "ztest-encyclopedia", + "ztest-message", + "ztest-no-urls", +) + + +# --------------------------------------------------------------------------- +# Shared staleness helper +# --------------------------------------------------------------------------- + + +def _is_stale(output_dir: Path, *source_dirs: Path) -> bool: + """Return True when *output_dir* is absent, empty, or older than any file + in *source_dirs*. Always returns True when ``PYTEST_FORCE_HUGO_BUILD=1`` + is set. + """ + if os.environ.get("PYTEST_FORCE_HUGO_BUILD"): + return True + + html_files = list(output_dir.rglob("index.html")) + if not html_files: + return True + + oldest_output = min(f.stat().st_mtime for f in html_files) + + for source in source_dirs: + for path in source.rglob("*"): + if path.is_file() and path.stat().st_mtime > oldest_output: + return True + + return False + + +def _run_hugo(*extra_args: str) -> subprocess.CompletedProcess: + """Run Hugo with *extra_args* and return the CompletedProcess. Raises a + pytest failure immediately if Hugo exits with a non-zero return code. + """ + result = subprocess.run( + ["hugo", *extra_args], + cwd=str(REPO_ROOT), + capture_output=True, + text=True, + ) + if result.returncode != 0: + pytest.fail( + f"Hugo build failed (exit {result.returncode}):\n" + f"--- stdout ---\n{result.stdout}\n" + f"--- stderr ---\n{result.stderr}" + ) + return result + + +# --------------------------------------------------------------------------- +# Session-scoped fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="session", autouse=True) +def hugo_build() -> None: + """Build the Hugo *test* site (``tests/public_test/``) when stale. + + Uses ``--environment testing`` so that ``config/testing/hugo.yaml`` mounts + the fixture content without touching production content. + + Set ``PYTEST_FORCE_HUGO_BUILD=1`` to force a rebuild unconditionally. + """ + if not _is_stale( + BIB_PUBLIC, + REPO_ROOT / "layouts", + REPO_ROOT / "config" / "testing", + FIXTURES_DIR, + ): + return + + result = _run_hugo("--environment", "testing", "--destination", "tests/public_test") + if result.stderr: + print(f"\n[hugo test-build warnings]\n{result.stderr}", flush=True) + + +@pytest.fixture(scope="session") +def production_build() -> subprocess.CompletedProcess: + """Build the *production* Hugo site (``public/``) when stale and return + the ``CompletedProcess`` so callers can inspect stdout/stderr. + + Tests that need the production build should declare this fixture as a + parameter. It is *not* autouse — only tests that explicitly request it + will trigger a production build. + """ + if not _is_stale( + PROD_PUBLIC, + REPO_ROOT / "layouts", + REPO_ROOT / "content", + REPO_ROOT / "data", + REPO_ROOT / "config" / "_default", + ): + # Site is up-to-date; run a no-op build to capture stderr for tests. + return subprocess.run( + ["hugo", "--logLevel", "warn"], + cwd=str(REPO_ROOT), + capture_output=True, + text=True, + ) + + return _run_hugo("--cleanDestinationDir", "--logLevel", "warn") diff --git a/tests/fixtures/bibliography/ZTEST-BLOG.md b/tests/fixtures/bibliography/ZTEST-BLOG.md new file mode 100644 index 000000000..02928951c --- /dev/null +++ b/tests/fixtures/bibliography/ZTEST-BLOG.md @@ -0,0 +1,20 @@ +--- +title: "Test Blog Post" +date: 2021-08-20 +readabledate: 2021-08-20 +type: bibliography +item_type: post-weblog +authors: + - "Blogger, Test" +editors: +abstract: | + A test abstract for a blog post used by automated test fixtures. + +blog_title: "Test Tech Blog" + +tags: +url_source: "https://example.com/blog/post" +zotero_url: "https://www.zotero.org/groups/test/items/ZTESTBLG" + +lastmod: 2023-08-01T00:00:00Z +--- diff --git a/tests/fixtures/bibliography/ZTEST-BOOK.md b/tests/fixtures/bibliography/ZTEST-BOOK.md new file mode 100644 index 000000000..b0ecd9b45 --- /dev/null +++ b/tests/fixtures/bibliography/ZTEST-BOOK.md @@ -0,0 +1,20 @@ +--- +title: "Test Book Title" +date: 1995-01-01 +readabledate: 1995 +type: bibliography +item_type: book +authors: + - "Author, Test" +editors: +abstract: | + A test abstract for a book used by automated test fixtures. + +publisher: "Test Publisher" + +tags: +url_source: "https://example.com/book" +zotero_url: "https://www.zotero.org/groups/test/items/ZTESTBOOK" + +lastmod: 2022-01-01T00:00:00Z +--- diff --git a/tests/fixtures/bibliography/ZTEST-CHAPTER.md b/tests/fixtures/bibliography/ZTEST-CHAPTER.md new file mode 100644 index 000000000..5fee537bf --- /dev/null +++ b/tests/fixtures/bibliography/ZTEST-CHAPTER.md @@ -0,0 +1,23 @@ +--- +title: "Test Chapter Title" +date: 2000-01-01 +readabledate: 2000 +type: bibliography +item_type: chapter +authors: + - "Writer, Test" +editors: + - "Editor, Test" +abstract: | + A test abstract for a book chapter used by automated test fixtures. + +book_title: "Test Book of Chapters" +publisher: "Chapter Publisher" +pages: "100-120" + +tags: +url_source: "https://example.com/chapter" +zotero_url: "https://www.zotero.org/groups/test/items/ZTESTCHAP" + +lastmod: 2022-03-01T00:00:00Z +--- diff --git a/tests/fixtures/bibliography/ZTEST-CONFERENCE.md b/tests/fixtures/bibliography/ZTEST-CONFERENCE.md new file mode 100644 index 000000000..14cc9176d --- /dev/null +++ b/tests/fixtures/bibliography/ZTEST-CONFERENCE.md @@ -0,0 +1,23 @@ +--- +title: "Test Conference Paper" +date: 2019-07-20 +readabledate: 2019-07-20 +type: bibliography +item_type: paper-conference +authors: + - "Brown, Carol K." + - "White, David" +editors: +abstract: | + A test abstract for a conference paper used by automated test fixtures. + +proceedings_title: "Proceedings of the Test Conference 2019" +publisher: "ACM" +place: "San Francisco, CA" + +tags: +url_source: "https://example.com/conference-paper" +zotero_url: "https://www.zotero.org/groups/test/items/ZTESTCONF" + +lastmod: 2022-08-15T00:00:00Z +--- diff --git a/tests/fixtures/bibliography/ZTEST-ENCYCLOPEDIA.md b/tests/fixtures/bibliography/ZTEST-ENCYCLOPEDIA.md new file mode 100644 index 000000000..93a35792d --- /dev/null +++ b/tests/fixtures/bibliography/ZTEST-ENCYCLOPEDIA.md @@ -0,0 +1,20 @@ +--- +title: "Test Encyclopedia Entry" +date: 2003-01-01 +readabledate: 2003 +type: bibliography +item_type: entry-encyclopedia +authors: + - "Scholar, Test" +editors: +abstract: | + A test abstract for an encyclopedia entry used by automated test fixtures. + +encyclopedia_title: "Encyclopedia of Test Computing" + +tags: +url_source: "https://example.com/encyclopedia/entry" +zotero_url: "https://www.zotero.org/groups/test/items/ZTESTENC" + +lastmod: 2023-11-01T00:00:00Z +--- diff --git a/tests/fixtures/bibliography/ZTEST-JOURNAL.md b/tests/fixtures/bibliography/ZTEST-JOURNAL.md new file mode 100644 index 000000000..8168b7752 --- /dev/null +++ b/tests/fixtures/bibliography/ZTEST-JOURNAL.md @@ -0,0 +1,24 @@ +--- +title: "Test Scholarly Article" +date: 2020-03-15 +readabledate: 2020-03-15 +type: bibliography +item_type: article-journal +authors: + - "Smith, Alice J." + - "Jones, Bob" +editors: +abstract: | + A test abstract for a journal article used by automated test fixtures. + +publication_title: "Journal of Test Science" +volume: "5" +issue: "2" +pages: "45-67" + +tags: +url_source: "https://example.com/journal-article" +zotero_url: "https://www.zotero.org/groups/test/items/ZTESTJNL" + +lastmod: 2023-01-10T00:00:00Z +--- diff --git a/tests/fixtures/bibliography/ZTEST-MAGAZINE.md b/tests/fixtures/bibliography/ZTEST-MAGAZINE.md new file mode 100644 index 000000000..bb560eb1f --- /dev/null +++ b/tests/fixtures/bibliography/ZTEST-MAGAZINE.md @@ -0,0 +1,23 @@ +--- +title: "Test Magazine Article" +date: 2021-06-01 +readabledate: 2021-06-01 +type: bibliography +item_type: article-magazine +authors: + - "Doe, Jane" +editors: +abstract: | + A test abstract for a magazine article used by automated test fixtures. + +publication_title: "Computing Monthly" +volume: "10" +issue: "6" +pages: "12-14" + +tags: +url_source: "https://example.com/magazine-article" +zotero_url: "https://www.zotero.org/groups/test/items/ZTESTMAG" + +lastmod: 2023-02-01T00:00:00Z +--- diff --git a/tests/fixtures/bibliography/ZTEST-MESSAGE.md b/tests/fixtures/bibliography/ZTEST-MESSAGE.md new file mode 100644 index 000000000..b75745e44 --- /dev/null +++ b/tests/fixtures/bibliography/ZTEST-MESSAGE.md @@ -0,0 +1,18 @@ +--- +title: "Test Personal Communication" +date: 2012-07-04 +readabledate: 2012-07-04 +type: bibliography +item_type: personal_communication +authors: + - "Sender, Test" +editors: +abstract: | + A test abstract for a personal communication used by automated test fixtures. + +tags: +url_source: "https://example.com/communication" +zotero_url: "https://www.zotero.org/groups/test/items/ZTESTMSG" + +lastmod: 2023-12-01T00:00:00Z +--- diff --git a/tests/fixtures/bibliography/ZTEST-NO-URLS.md b/tests/fixtures/bibliography/ZTEST-NO-URLS.md new file mode 100644 index 000000000..fe6764125 --- /dev/null +++ b/tests/fixtures/bibliography/ZTEST-NO-URLS.md @@ -0,0 +1,21 @@ +--- +title: "Test Entry Without URLs" +date: 2008-06-15 +readabledate: 2008-06-15 +type: bibliography +item_type: report +authors: + - "Nobody, Test" +editors: +abstract: | + A test abstract for a report with no external URLs, used to verify that + the sameAs field is omitted when neither url_source nor zotero_url is set. + +publisher: "Test Publisher" + +tags: +url_source: +zotero_url: + +lastmod: 2024-01-01T00:00:00Z +--- diff --git a/tests/fixtures/bibliography/ZTEST-PATENT.md b/tests/fixtures/bibliography/ZTEST-PATENT.md new file mode 100644 index 000000000..33c23e828 --- /dev/null +++ b/tests/fixtures/bibliography/ZTEST-PATENT.md @@ -0,0 +1,23 @@ +--- +title: "Test Patent for Widget" +date: 2015-03-10 +readabledate: 2015-03-10 +type: bibliography +item_type: patent +authors: + - "Inventor, Test" +editors: +abstract: | + A test abstract for a patent used by automated test fixtures. + +assignee: "Test Corporation" +application_number: "US12345678" +patent_number: "US9876543B2" +issuing_authority: "United States" + +tags: +url_source: "https://patents.google.com/patent/US9876543B2" +zotero_url: "https://www.zotero.org/groups/test/items/ZTESTPAT" + +lastmod: 2023-06-01T00:00:00Z +--- diff --git a/tests/fixtures/bibliography/ZTEST-REPORT.md b/tests/fixtures/bibliography/ZTEST-REPORT.md new file mode 100644 index 000000000..9cad7896b --- /dev/null +++ b/tests/fixtures/bibliography/ZTEST-REPORT.md @@ -0,0 +1,20 @@ +--- +title: "Test Technical Report" +date: 2005-09-01 +readabledate: 2005-09-01 +type: bibliography +item_type: report +authors: + - "Researcher, Test" +editors: +abstract: | + A test abstract for a technical report used by automated test fixtures. + +publisher: "Test Research Lab" + +tags: +url_source: "https://example.com/report" +zotero_url: "https://www.zotero.org/groups/test/items/ZTESTRPT" + +lastmod: 2023-04-01T00:00:00Z +--- diff --git a/tests/fixtures/bibliography/ZTEST-SOFTWARE.md b/tests/fixtures/bibliography/ZTEST-SOFTWARE.md new file mode 100644 index 000000000..c0ab51dc8 --- /dev/null +++ b/tests/fixtures/bibliography/ZTEST-SOFTWARE.md @@ -0,0 +1,19 @@ +--- +title: "Test Software" +date: 2016-02-14 +readabledate: 2016-02-14 +type: bibliography +item_type: software +authors: + - "Developer, Test" + - "Singularity" +editors: +abstract: | + A test abstract for a software entry used by automated test fixtures. + +tags: +url_source: "https://github.com/example/test-software" +zotero_url: "https://www.zotero.org/groups/test/items/ZTESTSOFT" + +lastmod: 2023-10-01T00:00:00Z +--- diff --git a/tests/fixtures/bibliography/ZTEST-THESIS.md b/tests/fixtures/bibliography/ZTEST-THESIS.md new file mode 100644 index 000000000..bd41d49fc --- /dev/null +++ b/tests/fixtures/bibliography/ZTEST-THESIS.md @@ -0,0 +1,20 @@ +--- +title: "Test Doctoral Thesis" +date: 2010-05-01 +readabledate: 2010-05-01 +type: bibliography +item_type: thesis +authors: + - "Student, Test" +editors: +abstract: | + A test abstract for a doctoral thesis used by automated test fixtures. + +university: "Test University" + +tags: +url_source: "https://example.com/thesis" +zotero_url: "https://www.zotero.org/groups/test/items/ZTESTTHES" + +lastmod: 2023-05-01T00:00:00Z +--- diff --git a/tests/fixtures/bibliography/ZTEST-VIDEO.md b/tests/fixtures/bibliography/ZTEST-VIDEO.md new file mode 100644 index 000000000..6a3b08335 --- /dev/null +++ b/tests/fixtures/bibliography/ZTEST-VIDEO.md @@ -0,0 +1,22 @@ +--- +title: "Test Motion Picture" +date: 2018-04-01 +readabledate: 2018-04-01 +type: bibliography +item_type: motion_picture +authors: + - "Director, Test" +editors: +abstract: | + A test abstract for a motion picture used by automated test fixtures. + +studio: "Test Film Studio" +video_recording_format: "MP4" +series_title: "Test Film Series" + +tags: +url_source: "https://example.com/video" +zotero_url: "https://www.zotero.org/groups/test/items/ZTESTVID" + +lastmod: 2023-09-01T00:00:00Z +--- diff --git a/tests/fixtures/bibliography/ZTEST-WEBPAGE.md b/tests/fixtures/bibliography/ZTEST-WEBPAGE.md new file mode 100644 index 000000000..220418fd5 --- /dev/null +++ b/tests/fixtures/bibliography/ZTEST-WEBPAGE.md @@ -0,0 +1,20 @@ +--- +title: "Test Web Page" +date: 2022-11-15 +readabledate: 2022-11-15 +type: bibliography +item_type: webpage +authors: + - "Webber, Test" +editors: +abstract: | + A test abstract for a web page used by automated test fixtures. + +website_title: "Test Website" + +tags: +url_source: "https://example.com/page" +zotero_url: "https://www.zotero.org/groups/test/items/ZTESTWEB" + +lastmod: 2023-07-01T00:00:00Z +--- diff --git a/tests/test_bibliography_jsonld.py b/tests/test_bibliography_jsonld.py new file mode 100644 index 000000000..0bb5803ff --- /dev/null +++ b/tests/test_bibliography_jsonld.py @@ -0,0 +1,715 @@ +""" +tests/test_bibliography_jsonld.py +================================== +Validates the JSON-LD structured data emitted by +``layouts/_partials/bibliography-json-ld.html`` for every Schema.org type +used in the Interlisp bibliography. + +Design +------ +Each ``ZTEST-*.md`` file in ``tests/fixtures/bibliography/`` is the **mock +input**: controlled Hugo front matter that the template converts to JSON-LD. +The session-scoped ``hugo_build`` fixture (defined in ``conftest.py``) builds +those fixtures into ``tests/public_test/`` via the ``--environment testing`` +Hugo configuration, which never touches production content. + +Each per-type test class declares an ``ld`` fixture that loads the parsed +JSON-LD dict for that class's fixture slug. Because ``_load_jsonld`` is +wrapped with ``functools.lru_cache``, each HTML file is read exactly once per +session regardless of how many tests reference it. + +Cross-cutting invariants (context, language, date format, key isolation) are +expressed as module-level parametrized functions so that pytest generates one +test ID per slug or (slug, key) combination, making failures immediately +identifiable. + +Fixture → Schema.org type map +------------------------------ + ZTEST-JOURNAL.md article-journal → ScholarlyArticle + ZTEST-MAGAZINE.md article-magazine → Article + ZTEST-CONFERENCE.md paper-conference → ScholarlyArticle + ZTEST-BOOK.md book → Book + ZTEST-CHAPTER.md chapter → Chapter + ZTEST-REPORT.md report → Report + ZTEST-THESIS.md thesis → Thesis + ZTEST-PATENT.md patent → CreativeWork (+additionalType) + ZTEST-WEBPAGE.md webpage → WebPage + ZTEST-BLOG.md post-weblog → BlogPosting + ZTEST-VIDEO.md motion_picture → VideoObject + ZTEST-SOFTWARE.md software → SoftwareSourceCode + ZTEST-ENCYCLOPEDIA.md entry-encyclopedia → Article + ZTEST-MESSAGE.md personal_communication → Message + ZTEST-NO-URLS.md report (no URLs) → edge-case fixture + +Usage +----- + # From the repository root: + pytest tests/test_bibliography_jsonld.py -v + + # Force a fresh Hugo test build first: + PYTEST_FORCE_HUGO_BUILD=1 pytest tests/test_bibliography_jsonld.py -v +""" + +import functools +import json +from html.parser import HTMLParser +from pathlib import Path + +import pytest + +from conftest import BIB_PUBLIC, FIXTURE_SLUGS, TEST_PUBLIC + +# --------------------------------------------------------------------------- +# HTML extraction +# --------------------------------------------------------------------------- + + +class _JSONLDExtractor(HTMLParser): + """Collect the raw text of every ``application/ld+json`` script element.""" + + def __init__(self) -> None: + super().__init__() + self._in_jsonld: bool = False + self.blocks: list[str] = [] + self._buf: list[str] = [] + + def handle_starttag(self, tag: str, attrs: list) -> None: + if tag == "script" and dict(attrs).get("type") == "application/ld+json": + self._in_jsonld = True + self._buf = [] + + def handle_endtag(self, tag: str) -> None: + if tag == "script" and self._in_jsonld: + self._in_jsonld = False + self.blocks.append("".join(self._buf)) + + def handle_data(self, data: str) -> None: + if self._in_jsonld: + self._buf.append(data) + + +def _parse_jsonld(path: Path, *, type_filter: str | None = None) -> dict: + """Return the first Schema.org JSON-LD block from *path*. + + If *type_filter* is given, return the block whose ``@type`` matches it + (used for the CollectionPage section index). Otherwise return the first + block whose ``@context`` is ``https://schema.org``. + + Raises a pytest failure (not an exception) if no matching block is found. + """ + parser = _JSONLDExtractor() + parser.feed(path.read_text(encoding="utf-8")) + + for raw in parser.blocks: + try: + data = json.loads(raw.strip()) + except json.JSONDecodeError: + continue + if not isinstance(data, dict): + continue + if type_filter: + if data.get("@type") == type_filter: + return data + elif data.get("@context") == "https://schema.org": + return data + + pytest.fail(f"No matching JSON-LD block found in {path}") + + +# --------------------------------------------------------------------------- +# Cached page loaders +# --------------------------------------------------------------------------- + + +@functools.lru_cache(maxsize=None) +def _load_jsonld(slug: str) -> dict: + """Return the Schema.org JSON-LD dict for the bibliography page *slug*. + + Results are cached (lru_cache) so each HTML file is read exactly once per + test session regardless of how many test methods reference the same slug. + + Skips (does not fail) when the built page is absent so that collection + errors clearly identify the missing file rather than producing confusing + assertion failures. + """ + path = BIB_PUBLIC / slug / "index.html" + if not path.exists(): + pytest.skip( + f"Built page not found: {path}\n" + "Run 'hugo --environment testing --destination tests/public_test' " + "from the repo root, then re-run the tests." + ) + return _parse_jsonld(path) + + +@functools.lru_cache(maxsize=1) +def _load_jsonld_section() -> dict: + """Return the CollectionPage JSON-LD dict from the bibliography index page.""" + path = TEST_PUBLIC / "history" / "bibliography" / "index.html" + if not path.exists(): + pytest.skip(f"Section index page not found: {path}") + return _parse_jsonld(path, type_filter="CollectionPage") + + +# --------------------------------------------------------------------------- +# Shared assertion helpers +# --------------------------------------------------------------------------- + + +def _assert_base(ld: dict, expected_type: str, expected_name: str) -> None: + """Assert the fields that must appear on every single bibliography page.""" + assert ld.get("@context") == "https://schema.org", ( + f"@context must be 'https://schema.org', got {ld.get('@context')!r}" + ) + assert ld.get("@type") == expected_type, ( + f"@type must be {expected_type!r}, got {ld.get('@type')!r}" + ) + assert ld.get("name") == expected_name, ( + f"name must be {expected_name!r}, got {ld.get('name')!r}" + ) + assert ld.get("inLanguage") == "en-US", ( + f"inLanguage must be 'en-US', got {ld.get('inLanguage')!r}" + ) + assert ld.get("url", "").endswith("/"), ( + f"url must end with '/', got {ld.get('url')!r}" + ) + + +def _assert_person( + person: dict, + expected_name: str, + family: str | None = None, + given: str | None = None, +) -> None: + """Assert a Schema.org Person object.""" + assert person.get("@type") == "Person", ( + f"@type must be 'Person', got {person.get('@type')!r}" + ) + assert person.get("name") == expected_name, ( + f"name must be {expected_name!r}, got {person.get('name')!r}" + ) + if family is not None: + assert person.get("familyName") == family, ( + f"familyName must be {family!r}, got {person.get('familyName')!r}" + ) + if given is not None: + assert person.get("givenName") == given, ( + f"givenName must be {given!r}, got {person.get('givenName')!r}" + ) + + +def _assert_org(obj: dict, expected_name: str) -> None: + """Assert a Schema.org Organization object.""" + assert obj.get("@type") == "Organization", ( + f"@type must be 'Organization', got {obj.get('@type')!r}" + ) + assert obj.get("name") == expected_name, ( + f"name must be {expected_name!r}, got {obj.get('name')!r}" + ) + + +# =========================================================================== +# Per-type test classes +# +# Each class declares an ``ld`` fixture (autouse=True) that loads the JSON-LD +# for that class's fixture page into ``self.ld``. All test methods access the +# data via ``self.ld`` rather than calling ``_load_jsonld`` independently, +# making the data dependency explicit. ``_load_jsonld`` is cached so the +# fixture is effectively free after the first call. +# =========================================================================== + + +class TestScholarlyArticle: + """article-journal → ScholarlyArticle. Fixture: ZTEST-JOURNAL.md + + Also exercises author name splitting, sameAs ordering, periodical + isPartOf, and pagination fields. + """ + + @pytest.fixture(autouse=True) + def ld(self): + self.ld = _load_jsonld("ztest-journal") + + def test_base_fields(self): + _assert_base(self.ld, "ScholarlyArticle", "Test Scholarly Article") + + def test_date_published(self): + assert self.ld["datePublished"] == "2020-03-15" + + def test_date_modified(self): + assert self.ld["dateModified"] == "2023-01-10T00:00:00Z" + + def test_description_from_abstract(self): + assert "journal article" in self.ld.get("description", "").lower() + + def test_authors_count_and_order(self): + authors = self.ld["author"] + assert len(authors) == 2, f"Expected 2 authors, got {len(authors)}" + # "Smith, Alice J." → familyName=Smith, givenName=Alice J. + _assert_person(authors[0], "Alice J. Smith", family="Smith", given="Alice J.") + # "Jones, Bob" → familyName=Jones, givenName=Bob + _assert_person(authors[1], "Bob Jones", family="Jones", given="Bob") + + def test_same_as_contains_both_urls(self): + same_as = self.ld["sameAs"] + assert "https://example.com/journal-article" in same_as + assert "https://www.zotero.org/groups/test/items/ZTESTJNL" in same_as + + def test_same_as_url_source_precedes_zotero(self): + """url_source must appear before zotero_url in the sameAs array.""" + same_as = self.ld["sameAs"] + assert same_as.index("https://example.com/journal-article") < same_as.index( + "https://www.zotero.org/groups/test/items/ZTESTJNL" + ) + + def test_periodical_is_part_of(self): + is_part_of = self.ld["isPartOf"] + assert is_part_of["@type"] == "Periodical" + assert is_part_of["name"] == "Journal of Test Science" + + def test_volume_number(self): + assert self.ld["volumeNumber"] == "5" + + def test_issue_number(self): + assert self.ld["issueNumber"] == "2" + + def test_pagination(self): + assert self.ld["pagination"] == "45-67" + +class TestMagazineArticle: + """article-magazine → Article. Fixture: ZTEST-MAGAZINE.md + + Shares the periodical field block with article-journal but maps to + Schema.org Article rather than ScholarlyArticle. + """ + + @pytest.fixture(autouse=True) + def ld(self): + self.ld = _load_jsonld("ztest-magazine") + + def test_base_fields(self): + _assert_base(self.ld, "Article", "Test Magazine Article") + + def test_author(self): + authors = self.ld["author"] + assert len(authors) == 1 + _assert_person(authors[0], "Jane Doe", family="Doe", given="Jane") + + def test_periodical_is_part_of(self): + is_part_of = self.ld["isPartOf"] + assert is_part_of["@type"] == "Periodical" + assert is_part_of["name"] == "Computing Monthly" + + def test_volume_issue_pagination(self): + assert self.ld["volumeNumber"] == "10" + assert self.ld["issueNumber"] == "6" + assert self.ld["pagination"] == "12-14" + + +class TestConferencePaper: + """paper-conference → ScholarlyArticle. Fixture: ZTEST-CONFERENCE.md + + Exercises proceedings isPartOf (Book), publisher, and locationCreated + (place field → Schema.org Place). + """ + + @pytest.fixture(autouse=True) + def ld(self): + self.ld = _load_jsonld("ztest-conference") + + def test_base_fields(self): + _assert_base(self.ld, "ScholarlyArticle", "Test Conference Paper") + + def test_proceedings_is_part_of(self): + is_part_of = self.ld["isPartOf"] + assert is_part_of["@type"] == "Book" + assert is_part_of["name"] == "Proceedings of the Test Conference 2019" + + def test_publisher(self): + _assert_org(self.ld["publisher"], "ACM") + + def test_location_created(self): + """place front-matter maps to a Schema.org Place via locationCreated.""" + location = self.ld["locationCreated"] + assert location["@type"] == "Place" + assert location["name"] == "San Francisco, CA" + + def test_authors_with_compound_given_name(self): + """'Brown, Carol K.' splits to givenName='Carol K.', familyName='Brown'.""" + authors = self.ld["author"] + assert len(authors) == 2 + _assert_person(authors[0], "Carol K. Brown", family="Brown", given="Carol K.") + _assert_person(authors[1], "David White", family="White", given="David") + + +class TestBook: + """book → Book. Fixture: ZTEST-BOOK.md""" + + @pytest.fixture(autouse=True) + def ld(self): + self.ld = _load_jsonld("ztest-book") + + def test_base_fields(self): + _assert_base(self.ld, "Book", "Test Book Title") + + def test_publisher(self): + _assert_org(self.ld["publisher"], "Test Publisher") + + def test_author(self): + authors = self.ld["author"] + assert len(authors) == 1 + _assert_person(authors[0], "Test Author", family="Author", given="Test") + + def test_same_as_order(self): + """url_source must be the first element; zotero_url the second.""" + same_as = self.ld["sameAs"] + assert same_as[0] == "https://example.com/book" + assert same_as[1] == "https://www.zotero.org/groups/test/items/ZTESTBOOK" + + +class TestChapter: + """chapter → Chapter. Fixture: ZTEST-CHAPTER.md + + isPartOf is a Book that also carries a nested publisher Organization. + """ + + @pytest.fixture(autouse=True) + def ld(self): + self.ld = _load_jsonld("ztest-chapter") + + def test_base_fields(self): + _assert_base(self.ld, "Chapter", "Test Chapter Title") + + def test_is_part_of_book(self): + parent = self.ld["isPartOf"] + assert parent["@type"] == "Book" + assert parent["name"] == "Test Book of Chapters" + + def test_parent_book_publisher(self): + """publisher is nested inside the isPartOf Book, not at the top level.""" + _assert_org(self.ld["isPartOf"]["publisher"], "Chapter Publisher") + + def test_pagination(self): + assert self.ld["pagination"] == "100-120" + + def test_editor(self): + editors = self.ld["editor"] + assert len(editors) == 1 + _assert_person(editors[0], "Test Editor", family="Editor", given="Test") + + def test_author(self): + authors = self.ld["author"] + assert len(authors) == 1 + _assert_person(authors[0], "Test Writer", family="Writer", given="Test") + + +class TestReport: + """report → Report. Fixture: ZTEST-REPORT.md""" + + @pytest.fixture(autouse=True) + def ld(self): + self.ld = _load_jsonld("ztest-report") + + def test_base_fields(self): + _assert_base(self.ld, "Report", "Test Technical Report") + + def test_publisher(self): + _assert_org(self.ld["publisher"], "Test Research Lab") + + def test_author(self): + authors = self.ld["author"] + assert len(authors) == 1 + _assert_person(authors[0], "Test Researcher", family="Researcher", given="Test") + + +class TestThesis: + """thesis → Thesis. Fixture: ZTEST-THESIS.md""" + + @pytest.fixture(autouse=True) + def ld(self): + self.ld = _load_jsonld("ztest-thesis") + + def test_base_fields(self): + _assert_base(self.ld, "Thesis", "Test Doctoral Thesis") + + def test_source_organization(self): + org = self.ld["sourceOrganization"] + assert org["@type"] == "CollegeOrUniversity" + assert org["name"] == "Test University" + + def test_author(self): + authors = self.ld["author"] + assert len(authors) == 1 + _assert_person(authors[0], "Test Student", family="Student", given="Test") + + def test_no_publisher_key(self): + """Theses must use sourceOrganization, not publisher.""" + assert "publisher" not in self.ld + + +class TestPatent: + """patent → CreativeWork + additionalType "Patent". Fixture: ZTEST-PATENT.md + + Verifies identifier PropertyValue objects, copyrightHolder (assignee), + and validIn DefinedRegion (issuing authority). + """ + + @pytest.fixture(autouse=True) + def ld(self): + self.ld = _load_jsonld("ztest-patent") + + def test_base_fields(self): + """Patent @type is CreativeWork; Patent identity comes from additionalType.""" + _assert_base(self.ld, "CreativeWork", "Test Patent for Widget") + + def test_additional_type_is_patent(self): + assert self.ld["additionalType"] == "Patent" + + def test_patent_number_identifier(self): + match = next( + (i for i in self.ld["identifier"] if i.get("propertyID") == "patent-number"), + None, + ) + assert match is not None, "No 'patent-number' identifier found" + assert match["@type"] == "PropertyValue" + assert match["value"] == "US9876543B2" + + def test_application_number_identifier(self): + match = next( + (i for i in self.ld["identifier"] if i.get("propertyID") == "application-number"), + None, + ) + assert match is not None, "No 'application-number' identifier found" + assert match["@type"] == "PropertyValue" + assert match["value"] == "US12345678" + + def test_assignee_maps_to_copyright_holder(self): + """Assignee (rights owner) maps to copyrightHolder, not funder.""" + _assert_org(self.ld["copyrightHolder"], "Test Corporation") + + def test_issuing_authority_maps_to_valid_in(self): + """Issuing authority (jurisdiction) maps to validIn DefinedRegion.""" + region = self.ld["validIn"] + assert region["@type"] == "DefinedRegion" + assert region["name"] == "United States" + + def test_author(self): + authors = self.ld["author"] + assert len(authors) == 1 + _assert_person(authors[0], "Test Inventor", family="Inventor", given="Test") + + +class TestWebPage: + """webpage → WebPage. Fixture: ZTEST-WEBPAGE.md""" + + @pytest.fixture(autouse=True) + def ld(self): + self.ld = _load_jsonld("ztest-webpage") + + def test_base_fields(self): + _assert_base(self.ld, "WebPage", "Test Web Page") + + def test_is_part_of_website(self): + parent = self.ld["isPartOf"] + assert parent["@type"] == "WebSite" + assert parent["name"] == "Test Website" + + +class TestBlogPosting: + """post-weblog → BlogPosting. Fixture: ZTEST-BLOG.md""" + + @pytest.fixture(autouse=True) + def ld(self): + self.ld = _load_jsonld("ztest-blog") + + def test_base_fields(self): + _assert_base(self.ld, "BlogPosting", "Test Blog Post") + + def test_is_part_of_blog(self): + parent = self.ld["isPartOf"] + assert parent["@type"] == "Blog" + assert parent["name"] == "Test Tech Blog" + + +class TestVideoObject: + """motion_picture → VideoObject. Fixture: ZTEST-VIDEO.md""" + + @pytest.fixture(autouse=True) + def ld(self): + self.ld = _load_jsonld("ztest-video") + + def test_base_fields(self): + _assert_base(self.ld, "VideoObject", "Test Motion Picture") + + def test_production_company(self): + _assert_org(self.ld["productionCompany"], "Test Film Studio") + + def test_encoding_format(self): + assert self.ld["encodingFormat"] == "MP4" + + def test_part_of_series(self): + series = self.ld["partOfSeries"] + assert series["@type"] == "CreativeWorkSeries" + assert series["name"] == "Test Film Series" + + +class TestSoftwareSourceCode: + """software → SoftwareSourceCode. Fixture: ZTEST-SOFTWARE.md + + Also exercises the single-name (no comma) author edge case via the + "Singularity" entry, which must produce only a ``name`` key. + """ + + @pytest.fixture(autouse=True) + def ld(self): + self.ld = _load_jsonld("ztest-software") + + def test_base_fields(self): + _assert_base(self.ld, "SoftwareSourceCode", "Test Software") + + def test_author_count(self): + assert len(self.ld["author"]) == 2 + + def test_author_with_comma_split(self): + """'Developer, Test' → familyName='Developer', givenName='Test'.""" + _assert_person(self.ld["author"][0], "Test Developer", family="Developer", given="Test") + + def test_author_without_comma_has_only_name(self): + """'Singularity' (no comma) → only 'name'; no familyName or givenName.""" + single = next((a for a in self.ld["author"] if a["name"] == "Singularity"), None) + assert single is not None, "'Singularity' author not found in author array" + assert single["@type"] == "Person" + assert "familyName" not in single, "Single-name author must not have familyName" + assert "givenName" not in single, "Single-name author must not have givenName" + + +class TestEncyclopediaEntry: + """entry-encyclopedia → Article + isPartOf Book. Fixture: ZTEST-ENCYCLOPEDIA.md""" + + @pytest.fixture(autouse=True) + def ld(self): + self.ld = _load_jsonld("ztest-encyclopedia") + + def test_base_fields(self): + _assert_base(self.ld, "Article", "Test Encyclopedia Entry") + + def test_is_part_of_book(self): + """encyclopedia_title maps to isPartOf with @type Book.""" + parent = self.ld["isPartOf"] + assert parent["@type"] == "Book" + assert parent["name"] == "Encyclopedia of Test Computing" + + +class TestPersonalCommunication: + """personal_communication → Message. Fixture: ZTEST-MESSAGE.md""" + + @pytest.fixture(autouse=True) + def ld(self): + self.ld = _load_jsonld("ztest-message") + + def test_base_fields(self): + _assert_base(self.ld, "Message", "Test Personal Communication") + + def test_author(self): + authors = self.ld["author"] + assert len(authors) == 1 + _assert_person(authors[0], "Test Sender", family="Sender", given="Test") + + +class TestCollectionPage: + """The bibliography section index emits CollectionPage + ItemList JSON-LD.""" + + @pytest.fixture(autouse=True) + def ld(self): + self.ld = _load_jsonld_section() + + def test_base_structure(self): + assert self.ld["@context"] == "https://schema.org" + assert self.ld["@type"] == "CollectionPage" + assert self.ld["inLanguage"] == "en-US" + + def test_main_entity_is_item_list(self): + assert self.ld["mainEntity"]["@type"] == "ItemList" + + def test_item_list_contains_all_fixtures(self): + """Every fixture slug must appear in the ItemList URLs.""" + urls = {item["url"] for item in self.ld["mainEntity"]["itemListElement"]} + for slug in FIXTURE_SLUGS: + assert any(slug in u for u in urls), ( + f"ItemList missing entry for fixture slug '{slug}'" + ) + + def test_item_list_positions_are_sequential(self): + positions = [i["position"] for i in self.ld["mainEntity"]["itemListElement"]] + assert positions == list(range(1, len(positions) + 1)) + + def test_number_of_items_matches_list_length(self): + main = self.ld["mainEntity"] + assert main["numberOfItems"] == len(main["itemListElement"]) + + +# =========================================================================== +# Cross-cutting parametrized tests +# +# These tests validate invariants that must hold for every fixture or for +# multiple (slug, key) combinations. Using @pytest.mark.parametrize instead +# of loops means pytest generates one test ID per case, so a failure +# immediately identifies *which* slug or key combination broke — e.g.: +# +# FAILED test_bibliography_jsonld.py::test_in_language_is_en_us[ztest-blog] +# FAILED test_bibliography_jsonld.py::test_journal_key_absent[ztest-book-volumeNumber] +# =========================================================================== + + +@pytest.mark.parametrize("slug,expected_date", [ + ("ztest-journal", "2020-03-15"), + ("ztest-book", "1995-01-01"), + ("ztest-thesis", "2010-05-01"), +]) +def test_date_published_is_iso_format(slug: str, expected_date: str) -> None: + """datePublished is always YYYY-MM-DD regardless of front-matter input.""" + assert _load_jsonld(slug)["datePublished"] == expected_date + + +@pytest.mark.parametrize("slug", FIXTURE_SLUGS) +def test_in_language_is_en_us(slug: str) -> None: + """Every bibliography page must carry inLanguage='en-US'.""" + assert _load_jsonld(slug).get("inLanguage") == "en-US" + + +@pytest.mark.parametrize("slug", FIXTURE_SLUGS) +def test_context_is_schema_org(slug: str) -> None: + """Every bibliography page must declare @context='https://schema.org'.""" + assert _load_jsonld(slug).get("@context") == "https://schema.org" + + +@pytest.mark.parametrize("slug,key", [ + (slug, key) + for slug in ("ztest-book", "ztest-thesis", "ztest-report") + for key in ("volumeNumber", "issueNumber", "pagination") +]) +def test_journal_key_absent(slug: str, key: str) -> None: + """Journal pagination fields must not bleed into non-journal item types.""" + assert key not in _load_jsonld(slug), ( + f"{slug} must not have journal-specific key '{key}'" + ) + + +def test_same_as_absent_when_no_urls() -> None: + """sameAs is omitted when neither url_source nor zotero_url is set.""" + assert "sameAs" not in _load_jsonld("ztest-no-urls") + + +def test_date_modified_is_raw_lastmod() -> None: + """dateModified is stored verbatim as the lastmod front-matter value.""" + assert _load_jsonld("ztest-report")["dateModified"] == "2023-04-01T00:00:00Z" + + +def test_description_from_abstract() -> None: + """The abstract front-matter field becomes the JSON-LD description.""" + assert "test abstract for a book" in _load_jsonld("ztest-book")["description"].lower() + + +def test_conference_location_absent_from_journal() -> None: + """locationCreated is conference-specific and must not appear on journal pages.""" + assert "locationCreated" not in _load_jsonld("ztest-journal") diff --git a/tests/test_content_integrity.py b/tests/test_content_integrity.py new file mode 100644 index 000000000..246fbab56 --- /dev/null +++ b/tests/test_content_integrity.py @@ -0,0 +1,261 @@ +""" +tests/test_content_integrity.py +================================ +Validates the front matter of every bibliography content file *without* +building the Hugo site. These tests run quickly and catch structural +problems in the YAML front matter of production content. + +Tests in this file do **not** require Hugo or a build step — they parse the +markdown files directly. Because there are ~400 production entry files, tests +are parametrized to yield one test ID per file, which keeps failures +immediately identifiable. + +Three groups of tests: + +1. **Every-entry invariants** (parametrized across all files): + - Required keys are present + - ``type`` is ``"bibliography"`` + - ``date`` is a valid ISO date + - ``item_type`` is one of the known Zotero types + - ``authors`` and ``editors`` are lists + - ``url_source`` is a valid URL or empty + - ``zotero_url`` is a valid Zotero URL or empty + +2. **Index page** (``_index.md``): validates the section index front matter. + +3. **Fixture-file-specific**: validates that fixture files match expected + item_type and known edge cases. + +Usage +----- + pytest tests/test_content_integrity.py -v +""" + +import re +from pathlib import Path + +import pytest +import yaml + +from conftest import CONTENT_DIR, FIXTURES_DIR + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_KNOWN_ITEM_TYPES: frozenset[str] = frozenset({ + "article-journal", + "article-magazine", + "paper-conference", + "book", + "chapter", + "report", + "thesis", + "patent", + "webpage", + "post-weblog", + "motion_picture", + "software", + "entry-encyclopedia", + "personal_communication", + "document", + "manuscript", + "hearing", + "artwork", + "map", + "interview", + "bill", + "statute", + "legal_case", + "newspaper-article", + "dictionary-entry", + "Xerox System Integration Standard", +}) + + +def _parse_front_matter(path: Path) -> dict: + """Return the YAML front matter from a Hugo markdown file. + + Expects the standard Hugo ``---`` delimiters. Returns an empty dict if + parsing fails (the caller skips or reports the error). + """ + text = path.read_text(encoding="utf-8") + if not text.startswith("---"): + return {} + # Find the closing --- + end = text.find("\n---", 3) + if end == -1: + return {} + fm = yaml.safe_load(text[3:end]) + return fm if isinstance(fm, dict) else {} + + +# --------------------------------------------------------------------------- +# Collect test subjects +# --------------------------------------------------------------------------- + +_INDEX_PATH = CONTENT_DIR / "_index.md" + +_ENTRY_PATHS: tuple[Path, ...] = tuple( + sorted(CONTENT_DIR.glob("[A-Z0-9]*.md")) +) + +_FIXTURE_PATHS: tuple[Path, ...] = tuple( + sorted(FIXTURES_DIR.glob("*.md")) +) + +# Each entry is (path, relative_name) for clear test IDs. +_ENTRIES: tuple[tuple[Path, str], ...] = tuple( + (p, p.relative_to(CONTENT_DIR.parent).as_posix()) + for p in _ENTRY_PATHS +) + + +# =========================================================================== +# Index page tests +# =========================================================================== + + +class TestIndexPage: + """The _index.md entry for the bibliography section.""" + + def _fm(self) -> dict: + fm = _parse_front_matter(_INDEX_PATH) + if not fm: + pytest.skip(f"Could not parse front matter from {_INDEX_PATH}") + return fm + + def test_type_is_bibliography(self): + assert self._fm().get("type") == "bibliography" + + def test_has_title(self): + assert self._fm().get("title") == "Bibliography" + + def test_has_heading(self): + assert "Interlisp Bibliography" in self._fm().get("heading", "") + + def test_cascade_type(self): + assert self._fm().get("cascade", {}).get("type") == "bibliography" + + +# =========================================================================== +# Every-entry invariants (production content) +# =========================================================================== + + +class TestEveryEntry: + """Invariants that hold for every bibliography entry file.""" + + _REQUIRED_KEYS = frozenset({ + "title", "date", "readabledate", "type", "item_type", + "authors", "editors", "abstract", + "url_source", "zotero_url", "lastmod", + }) + _ENCOURAGED_KEYS = frozenset({"tags"}) + + @pytest.mark.parametrize("path,_name", _ENTRIES, ids=lambda x: x.name if hasattr(x, 'name') else x) + def test_required_keys_present(self, path: Path, _name: str) -> None: + fm = _parse_front_matter(path) + missing = self._REQUIRED_KEYS - fm.keys() + assert not missing, ( + f"{path.name} missing required key(s): {', '.join(sorted(missing))}" + ) + + @pytest.mark.parametrize("path,_name", _ENTRIES) + def test_type_is_bibliography(self, path: Path, _name: str) -> None: + fm = _parse_front_matter(path) + assert fm.get("type") == "bibliography", ( + f"{path.name}: type={fm.get('type')!r}, expected 'bibliography'" + ) + + @pytest.mark.parametrize("path,_name", _ENTRIES) + def test_item_type_is_known(self, path: Path, _name: str) -> None: + fm = _parse_front_matter(path) + it = fm.get("item_type") + assert it in _KNOWN_ITEM_TYPES, ( + f"{path.name}: unknown item_type={it!r}" + f"\nAdd this item_type to _KNOWN_ITEM_TYPES in " + f"test_content_integrity.py if it is valid" + ) + + @pytest.mark.parametrize("path,_name", _ENTRIES) + def test_date_is_valid(self, path: Path, _name: str) -> None: + """date must be YYYY-MM-DD string, a date object, or None.""" + fm = _parse_front_matter(path) + date_val = fm.get("date") + if date_val is None: + return # some entries legitimately have no date + if isinstance(date_val, str): + assert re.match(r"^\d{4}-\d{2}-\d{2}$", date_val), ( + f"{path.name}: date={date_val!r} is not YYYY-MM-DD format" + ) + elif hasattr(date_val, "isoformat"): + pass # YAML parsed it as a date object — valid + else: + pytest.fail( + f"{path.name}: date={date_val!r} has unexpected type " + f"{type(date_val).__name__}" + ) + + @pytest.mark.parametrize("path,_name", _ENTRIES) + def test_lastmod_is_valid_iso(self, path: Path, _name: str) -> None: + """lastmod must be a valid ISO 8601 timestamp — either a datetime + object (parsed by YAML) or a string matching the ISO pattern.""" + fm = _parse_front_matter(path) + lastmod = fm.get("lastmod") + if isinstance(lastmod, str): + assert re.match( + r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$", lastmod + ), f"{path.name}: lastmod={lastmod!r} is not ISO 8601 format" + elif hasattr(lastmod, "isoformat"): + pass # YAML parsed it as a datetime object — natively valid + else: + pytest.fail( + f"{path.name}: lastmod={lastmod!r} is neither a string " + f"nor a datetime object" + ) + + @pytest.mark.parametrize("path,_name", _ENTRIES) + def test_authors_is_list_or_none(self, path: Path, _name: str) -> None: + fm = _parse_front_matter(path) + authors = fm.get("authors") + assert authors is None or isinstance(authors, list), ( + f"{path.name}: authors is {type(authors).__name__}, " + f"expected None or list" + ) + + @pytest.mark.parametrize("path,_name", _ENTRIES) + def test_editors_is_list_or_none(self, path: Path, _name: str) -> None: + fm = _parse_front_matter(path) + editors = fm.get("editors") + assert editors is None or isinstance(editors, list), ( + f"{path.name}: editors is {type(editors).__name__}, " + f"expected None or list" + ) + + @pytest.mark.parametrize("path,_name", _ENTRIES) + def test_tags_is_null_or_empty(self, path: Path, _name: str) -> None: + fm = _parse_front_matter(path) + tags = fm.get("tags") + assert tags in (None, ""), f"{path.name}: tags={tags!r} should be empty" + + @pytest.mark.parametrize("path,_name", _ENTRIES) + def test_url_source_format(self, path: Path, _name: str) -> None: + fm = _parse_front_matter(path) + url = fm.get("url_source") or "" + assert isinstance(url, str), f"{path.name}: url_source is not a string" + if url: + assert url.startswith("http://") or url.startswith("https://"), ( + f"{path.name}: url_source={url!r} is not a valid URL" + ) + + @pytest.mark.parametrize("path,_name", _ENTRIES) + def test_zotero_url_format(self, path: Path, _name: str) -> None: + fm = _parse_front_matter(path) + url = fm.get("zotero_url") or "" + assert isinstance(url, str), f"{path.name}: zotero_url is not a string" + if url: + assert url.startswith("https://www.zotero.org/"), ( + f"{path.name}: zotero_url={url!r} should be a Zotero URL" + ) diff --git a/tests/test_html_validation.py b/tests/test_html_validation.py new file mode 100644 index 000000000..6df8576d6 --- /dev/null +++ b/tests/test_html_validation.py @@ -0,0 +1,207 @@ +""" +tests/test_html_validation.py +============================== +Validates that every HTML page in the built production site conforms to HTML +standards, using the Nu HTML Checker (``vnu.jar``) via the ``html5validator`` +Python package (or the ``vnu`` CLI directly). + +Grandfathered errors +-------------------- +Some historical HTML issues exist in the production content. Rather than +blocking the build, these are collected in ``_GRANDFATHERED`` — known messages +that will **not** cause the test to fail. The goal is to whittle this list +down over time. Any **new** error that appears causes a test failure. + +If you need to regenerate the grandfather list, run: + python3 tests/grandfathered_html_errors.regen.py + +Usage +----- + # Requires html5validator (pip install html5validator) or vnu CLI. + # Build the production site first: + hugo + + pytest tests/test_html_validation.py -v + + # To force a fresh production build: + PYTEST_FORCE_HUGO_BUILD=1 pytest tests/test_html_validation.py -v +""" + +import json +import os +import shutil +import subprocess + +import pytest + +from conftest import PROD_PUBLIC, production_build + +# --------------------------------------------------------------------------- +# Grandfathered error messages — allowed to appear without failing. +# These are exact message strings as reported by vnu. +# --------------------------------------------------------------------------- + +_GRANDFATHERED: list[str] = [ + 'An "img" element must have an "alt" attribute, except under certain conditions. For details, consult guidance on providing text alternatives for images.', + 'The "align" attribute on the "tr" element is obsolete. Use CSS instead.', + 'A document must not include more than one visible "main" element.', + 'Duplicate ID "notes".', + 'Duplicate ID "roadmap".', + 'Duplicate ID "solaris".', + 'Element "style" not allowed as child of element "dd" in this context. (Suppressing further errors from this subtree.)', + 'Element "style" not allowed as child of element "div" in this context. (Suppressing further errors from this subtree.)', + 'Element "style" not allowed as child of element "p" in this context. (Suppressing further errors from this subtree.)', + 'Element "ul" not allowed as child of element "ul" in this context. (Suppressing further errors from this subtree.)', + 'No "p" element in scope but a "p" end tag seen.', + 'The "aria-controls" attribute must point to an element in the same document.', + 'The "itemprop" attribute was specified, but the element is not a property of any item.', + 'The "main" element must not appear as a descendant of the "main" element.', +] + + +# --------------------------------------------------------------------------- +# Validator detection +# --------------------------------------------------------------------------- + + +def _find_validator() -> str | None: + """Return the path to an HTML validator executable, or None. + + Checks, in order: + 1. ``html5validator`` (Python package CLI wrapper) + 2. ``vnu`` (standalone Nu HTML Checker CLI) + """ + for exe in ("html5validator", "vnu"): + path = shutil.which(exe) + if path is not None: + return path + return None + + +# --------------------------------------------------------------------------- +# Building the validator command and parsing its output +# --------------------------------------------------------------------------- + + +def _build_cmd(validator: str) -> list[str]: + """Build the subprocess command that runs the validator against ``public/``. + + The ``_print`` directory is blacklisted because it is only used for + printing and is not part of the browsable site. + """ + return [ + validator, + "--skip-non-html", + "--root", str(PROD_PUBLIC), + "--blacklist", "_print", + ] + + +def _parse_vnu_json(text: str) -> list[dict]: + """Parse vnu JSON output into a list of message dicts. + + ``html5validator`` with ``--format json`` outputs newline-delimited JSON + objects via stderr. This handles both single-line and multi-line output. + """ + messages: list[dict] = [] + for line in text.strip().splitlines(): + line = line.strip() + if not line: + continue + try: + data = json.loads(line) + except json.JSONDecodeError: + continue + msgs = data.get("messages", []) + if not msgs and isinstance(data, dict) and "message" in data: + msgs = [data] + messages.extend(msgs) + return messages + + +def _run_vnu_json(validator: str) -> list[dict]: + """Run the validator and return parsed JSON messages. + + Falls back to text output parsing if JSON is not available. + """ + # Try JSON format first (supported by html5validator >= 0.2.0) + try: + result = subprocess.run( + [*_build_cmd(validator), "--format", "json"], + capture_output=True, + text=True, + timeout=120, + ) + messages = _parse_vnu_json(result.stderr) + if messages: + return messages + except (subprocess.TimeoutExpired, FileNotFoundError): + pass + + # Fallback: run without JSON and capture error messages from stderr + try: + result = subprocess.run( + _build_cmd(validator), + capture_output=True, + text=True, + timeout=120, + ) + except (subprocess.TimeoutExpired, FileNotFoundError) as exc: + pytest.skip(f"HTML validator execution failed: {exc}") + + # Parse lines starting with "ERROR:" — the plain text output format + messages = [] + for line in result.stderr.splitlines(): + line = line.strip() + if line.upper().startswith("ERROR:"): + messages.append({"message": line[6:].strip(), "type": "error"}) + elif line.upper().startswith("WARNING:"): + messages.append({"message": line[8:].strip(), "type": "warning"}) + return messages + + +# =========================================================================== +# Tests +# =========================================================================== + + +def test_html_validation(production_build) -> None: + """Validate all HTML in ``public/``; fail on any non-grandfathered error. + + This test requires either the ``html5validator`` Python package or the + ``vnu`` CLI to be installed. It skips with a clear message if neither is + available. + + The test will **not** fail on any message listed in ``_GRANDFATHERED`` + above. Any new error (not in the grandfathered list) will cause a test + failure so that new regressions are caught immediately. + """ + if not PROD_PUBLIC.is_dir(): + pytest.skip("public/ directory not found — run 'hugo' first") + + validator = _find_validator() + if validator is None: + pytest.skip( + "HTML validator not found. Install with:\n" + " pip install html5validator\n" + "(requires Java runtime for vnu.jar)" + ) + + grandfathered = frozenset(_GRANDFATHERED) + messages = _run_vnu_json(validator) + + new_errors = [ + m + for m in messages + if m["message"] not in grandfathered + ] + + if new_errors: + details = "\n".join( + f" [{m.get('type', 'error')}] {m.get('message', '?')}" + for m in new_errors + ) + pytest.fail( + f"{len(new_errors)} non-grandfathered HTML validation error(s) " + f"found in {PROD_PUBLIC}:\n{details}" + ) diff --git a/tests/test_hugo_build.py b/tests/test_hugo_build.py new file mode 100644 index 000000000..a69049cc2 --- /dev/null +++ b/tests/test_hugo_build.py @@ -0,0 +1,190 @@ +""" +tests/test_hugo_build.py +======================== +Validates that the production Hugo build succeeds and produces well-formed +output. These tests focus on *build quality* — exit code, absence of errors, +presence of critical output files, and sitemap integrity. + +They complement test_bibliography_jsonld.py (which validates template output +correctness) and test_html_validation.py (which validates HTML conformance). + +The ``production_build`` fixture (defined in conftest.py) runs +``hugo --cleanDestinationDir --logLevel warn`` when the ``public/`` directory +is stale and caches the result for the session. + +Usage +----- + pytest tests/test_hugo_build.py -v + + # Force a fresh production build first: + PYTEST_FORCE_HUGO_BUILD=1 pytest tests/test_hugo_build.py -v +""" + +import os +import re +import xml.etree.ElementTree as ET +from pathlib import Path +from urllib.parse import urlparse + +import pytest +import yaml + +from conftest import PROD_PUBLIC, REPO_ROOT + + +def _get_baseurl_path() -> str: + """Return the path component of the Hugo baseURL for the active environment. + + When ``baseURL`` contains a path prefix (e.g. + ``https://stumbo.github.io/InterlispDraft.github.io/``), Hugo prepends + that path (``/InterlispDraft.github.io``) to every site-root-relative + ``href``. This helper extracts just the path component so link checks + can strip it before resolving to the filesystem. + """ + env = os.environ.get("HUGO_ENVIRONMENT", "production") + env_config = REPO_ROOT / "config" / env / "hugo.yaml" + if not env_config.exists(): + env_config = REPO_ROOT / "config" / "_default" / "hugo.yaml" + if env_config.exists(): + with open(env_config) as f: + cfg = yaml.safe_load(f) + if cfg and "baseURL" in cfg: + return urlparse(cfg["baseURL"]).path.rstrip("/") or "" + return "" + + +# --------------------------------------------------------------------------- +# Build process tests +# --------------------------------------------------------------------------- + + +class TestHugoBuildProcess: + """The Hugo build must complete cleanly.""" + + @pytest.fixture(autouse=True) + def build(self, production_build): + self.result = production_build + + def test_exits_with_zero(self): + """Hugo must exit 0; any non-zero exit indicates a build failure.""" + assert self.result.returncode == 0, ( + f"Hugo exited {self.result.returncode}:\n{self.result.stderr}" + ) + + def test_no_error_lines_in_output(self): + """Hugo must not emit any ERROR-level log lines.""" + error_lines = [ + line for line in self.result.stderr.splitlines() + if line.strip().startswith("ERROR") + ] + assert not error_lines, ( + "Hugo produced ERROR lines:\n" + "\n".join(error_lines) + ) + + def test_no_broken_ref_links(self): + """Hugo must not report REF_NOT_FOUND — every internal page reference + used in content must resolve to an existing page.""" + assert "REF_NOT_FOUND" not in self.result.stderr, ( + "Hugo reported unresolved page references (REF_NOT_FOUND).\n" + "Check content files for broken {{< ref >}} shortcodes:\n" + + "\n".join( + l for l in self.result.stderr.splitlines() + if "REF_NOT_FOUND" in l + ) + ) + + +# --------------------------------------------------------------------------- +# Output structure tests +# --------------------------------------------------------------------------- + + +class TestCriticalPagesExist: + """Key pages must be present in the built output.""" + + REQUIRED_PATHS = ( + # Site root + "index.html", + # Bibliography section + "history/bibliography/index.html", + # Sitemap and robots + "sitemap.xml", + "robots.txt", + ) + + @pytest.mark.parametrize("path", REQUIRED_PATHS) + def test_page_exists(self, path: str) -> None: + assert (PROD_PUBLIC / path).exists(), ( + f"Required output file missing: public/{path}" + ) + + +class TestSitemapIntegrity: + """The sitemap must be valid XML and reference only absolute URLs.""" + + @pytest.fixture(autouse=True) + def sitemap(self): + path = PROD_PUBLIC / "sitemap.xml" + if not path.exists(): + pytest.skip("sitemap.xml not found — run a production build first") + self.tree = ET.parse(path) + self.root = self.tree.getroot() + # Strip XML namespace for simpler XPath queries + self.ns = {"sm": "http://www.sitemaps.org/schemas/sitemap/0.9"} + + def test_is_valid_xml(self): + """sitemap.xml must parse as well-formed XML.""" + assert self.root is not None + + def test_has_url_entries(self): + """sitemap.xml must contain at least one entry.""" + urls = self.root.findall("sm:url", self.ns) + assert len(urls) > 0, "sitemap.xml contains no entries" + + def test_all_locs_are_absolute_https(self): + """Every in the sitemap must be an absolute https:// URL.""" + bad = [] + for loc in self.root.findall(".//sm:loc", self.ns): + if not (loc.text or "").startswith("https://"): + bad.append(loc.text) + assert not bad, ( + f"{len(bad)} sitemap values are not absolute https:// URLs:\n" + + "\n".join(bad[:10]) + ) + + def test_bibliography_section_present(self): + """The bibliography section index must appear in the sitemap.""" + locs = {loc.text for loc in self.root.findall(".//sm:loc", self.ns)} + assert any("bibliography" in (loc or "") for loc in locs), ( + "No bibliography URL found in sitemap.xml" + ) + + +class TestInternalLinks: + """All internal href links in the built HTML must resolve to existing pages.""" + + def test_no_broken_internal_links(self) -> None: + if not PROD_PUBLIC.exists(): + pytest.skip("public/ not built — run hugo first") + + prefix = _get_baseurl_path() + broken: list[tuple[str, str]] = [] + + for html_file in PROD_PUBLIC.rglob("*.html"): + content = html_file.read_text(encoding="utf-8", errors="ignore") + # Match href values that start with / (site-root-relative) + for href in re.findall(r'href="(/[^"#?]*?)"', content): + # If baseURL has a path component (e.g., /InterlispDraft.github.io), + # Hugo prepends it to site-root-relative links. Strip it before + # resolving to the filesystem. + resolved = href + if prefix and href.startswith(prefix + "/"): + resolved = href[len(prefix):] + target = PROD_PUBLIC / resolved.lstrip("/") + if not target.exists() and not (target / "index.html").exists(): + broken.append((str(html_file.relative_to(PROD_PUBLIC)), href)) + + assert not broken, ( + f"{len(broken)} broken internal links found:\n" + + "\n".join(f" {page} → {link}" for page, link in broken[:20]) + )