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..1cbbec255 --- /dev/null +++ b/layouts/_partials/bibliography-json-ld.html @@ -0,0 +1,313 @@ +{{- /* + 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 @@
- {{- $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..71ce637aa 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 } @@ -185,6 +187,7 @@ sub sanitize_text { $extraFields tags: +concepts: url_source: $urlSource zotero_url: "https://www.zotero.org/groups/2914042/items/$key" 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..6d7bbd71a --- /dev/null +++ b/tests/fixtures/bibliography/ZTEST-JOURNAL.md @@ -0,0 +1,27 @@ +--- +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" +concepts: + - "Interlisp" + - "ProgrammingLanguages" + +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..f8603849f --- /dev/null +++ b/tests/test_bibliography_jsonld.py @@ -0,0 +1,730 @@ +""" +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, pagination fields, and concepts → about[]/keywords. + """ + + @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" + + def test_about_defined_terms(self): + about = self.ld["about"] + ids = [term["@id"] for term in about] + assert "https://interlisp.org/concepts/#Interlisp" in ids + assert "https://interlisp.org/concepts/#ProgrammingLanguages" in ids + for term in about: + assert term["@type"] == "DefinedTerm" + assert term["inDefinedTermSet"] == "https://interlisp.org/data/cs-concepts.jsonld" + assert "name" in term + + def test_keywords_from_concepts(self): + assert "Interlisp" in self.ld["keywords"] + assert "Programming Languages" in self.ld["keywords"] + + +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")