diff --git a/tests/unit/build_scripts/test_check_links.py b/tests/unit/build_scripts/test_check_links.py new file mode 100644 index 000000000..658a6dc4d --- /dev/null +++ b/tests/unit/build_scripts/test_check_links.py @@ -0,0 +1,84 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import tempfile +from pathlib import Path + +import pytest + +from build_scripts.check_links import extract_urls, resolve_relative_url, strip_fragment + + +class TestStripFragment: + def test_removes_fragment(self) -> None: + assert strip_fragment("https://example.com/page#section") == "https://example.com/page" + + def test_no_fragment_unchanged(self) -> None: + assert strip_fragment("https://example.com/page") == "https://example.com/page" + + def test_empty_fragment(self) -> None: + assert strip_fragment("https://example.com/page#") == "https://example.com/page" + + def test_preserves_query_string(self) -> None: + result = strip_fragment("https://example.com/page?q=1#section") + assert "q=1" in result + assert "section" not in result + + +class TestResolveRelativeUrl: + def test_http_url_unchanged(self) -> None: + url = "https://example.com" + assert resolve_relative_url("/some/file.md", url) == url + + def test_mailto_unchanged(self) -> None: + url = "mailto:test@example.com" + assert resolve_relative_url("/some/file.md", url) == url + + def test_relative_url_resolved(self, tmp_path: Path) -> None: + base = str(tmp_path / "docs" / "file.md") + target = str(tmp_path / "docs" / "other.md") + Path(target).parent.mkdir(parents=True, exist_ok=True) + Path(target).write_text("# Other") + result = resolve_relative_url(base, "other.md") + assert "other" in result + + def test_relative_url_with_md_extension(self, tmp_path: Path) -> None: + base = str(tmp_path / "docs" / "file.md") + target = tmp_path / "docs" / "other.md" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text("# Other") + result = resolve_relative_url(base, "other") + assert result.endswith(".md") + + +class TestExtractUrls: + def test_extracts_markdown_links(self, tmp_path: Path) -> None: + f = tmp_path / "test.md" + f.write_text("[Click here](https://example.com)") + urls = extract_urls(str(f)) + assert "https://example.com" in urls + + def test_extracts_href_links(self, tmp_path: Path) -> None: + f = tmp_path / "test.html" + f.write_text('link') + urls = extract_urls(str(f)) + assert "https://example.com" in urls + + def test_extracts_src_links(self, tmp_path: Path) -> None: + f = tmp_path / "test.html" + f.write_text('') + urls = extract_urls(str(f)) + assert "https://example.com/image.png" in urls + + def test_empty_file_returns_no_urls(self, tmp_path: Path) -> None: + f = tmp_path / "empty.md" + f.write_text("") + urls = extract_urls(str(f)) + assert urls == [] + + def test_strips_fragments_from_extracted_urls(self, tmp_path: Path) -> None: + f = tmp_path / "test.md" + f.write_text("[link](https://example.com/page#section)") + urls = extract_urls(str(f)) + assert "https://example.com/page" in urls + assert not any("#section" in u for u in urls) diff --git a/tests/unit/build_scripts/test_generate_rss.py b/tests/unit/build_scripts/test_generate_rss.py new file mode 100644 index 000000000..88f9d63e1 --- /dev/null +++ b/tests/unit/build_scripts/test_generate_rss.py @@ -0,0 +1,80 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import ast +from collections.abc import Callable +from pathlib import Path +from typing import Any + + +def _load_generate_rss_functions() -> tuple[Callable[[str], str], Callable[[Path], tuple[str, str]]]: + """Load generate_rss helpers without executing the script body.""" + script_path = Path(__file__).resolve().parents[3] / "build_scripts" / "generate_rss.py" + source = script_path.read_text(encoding="utf-8") + parsed_module = ast.parse(source, filename=str(script_path)) + target_functions = {"extract_date_from_filename", "parse_blog_markdown"} + selected_nodes: list[ast.stmt] = [] + for node in parsed_module.body: + if isinstance(node, (ast.Import, ast.ImportFrom)): + selected_nodes.append(node) + continue + if isinstance(node, ast.FunctionDef) and node.name in target_functions: + selected_nodes.append(node) + safe_module = ast.Module(body=selected_nodes, type_ignores=[]) + namespace: dict[str, Any] = {} + exec(compile(safe_module, filename=str(script_path), mode="exec"), namespace) + return namespace["extract_date_from_filename"], namespace["parse_blog_markdown"] + + +extract_date_from_filename, parse_blog_markdown = _load_generate_rss_functions() + + +class TestExtractDateFromFilename: + def test_standard_date(self) -> None: + assert extract_date_from_filename("2024_12_3.md") == "2024-12-03" + + def test_double_digit_day_and_month(self) -> None: + assert extract_date_from_filename("2023_11_25.md") == "2023-11-25" + + def test_single_digit_month(self) -> None: + assert extract_date_from_filename("2024_1_15.md") == "2024-01-15" + + def test_returns_empty_for_invalid_filename(self) -> None: + assert extract_date_from_filename("no_date_here.md") == "" + + def test_returns_empty_for_non_numeric(self) -> None: + assert extract_date_from_filename("intro.md") == "" + + +class TestParseBlogMarkdown: + def test_extracts_title(self, tmp_path: Path) -> None: + f = tmp_path / "2024_01_01.md" + f.write_text("# My Blog Title\n\nSome description here.") + title, _ = parse_blog_markdown(f) + assert title == "My Blog Title" + + def test_extracts_description(self, tmp_path: Path) -> None: + f = tmp_path / "2024_01_01.md" + f.write_text("# Title\n\nThis is the description paragraph.") + _, desc = parse_blog_markdown(f) + assert "This is the description paragraph." in desc + + def test_skips_small_tag_in_description(self, tmp_path: Path) -> None: + f = tmp_path / "2024_01_01.md" + f.write_text("# Title\n\ndate info\n\nReal description here.") + _, desc = parse_blog_markdown(f) + assert "small" not in desc + assert "Real description here." in desc + + def test_empty_title_when_no_heading(self, tmp_path: Path) -> None: + f = tmp_path / "2024_01_01.md" + f.write_text("No heading here.\n\nJust paragraphs.") + title, _ = parse_blog_markdown(f) + assert title == "" + + def test_multiline_description_joined(self, tmp_path: Path) -> None: + f = tmp_path / "2024_01_01.md" + f.write_text("# Title\n\nLine one.\nLine two.") + _, desc = parse_blog_markdown(f) + assert "Line one." in desc + assert "Line two." in desc diff --git a/tests/unit/build_scripts/test_prepare_package.py b/tests/unit/build_scripts/test_prepare_package.py new file mode 100644 index 000000000..53f5026c7 --- /dev/null +++ b/tests/unit/build_scripts/test_prepare_package.py @@ -0,0 +1,84 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import subprocess +from pathlib import Path +from unittest.mock import MagicMock, patch + +from build_scripts.prepare_package import build_frontend, copy_frontend_to_package + + +class TestBuildFrontend: + def test_returns_false_when_npm_not_found(self, tmp_path: Path) -> None: + with patch("build_scripts.prepare_package.shutil.which", return_value=None): + result = build_frontend(tmp_path) + assert result is False + + def test_returns_false_when_package_json_missing(self, tmp_path: Path) -> None: + with patch("build_scripts.prepare_package.shutil.which", return_value="/usr/bin/npm"): + with patch("build_scripts.prepare_package.subprocess.run", return_value=MagicMock(stdout="10.0.0\n")): + result = build_frontend(tmp_path) + assert result is False + + def test_returns_false_when_npm_install_fails(self, tmp_path: Path) -> None: + (tmp_path / "package.json").write_text("{}") + responses = [ + MagicMock(stdout="10.0.0\n"), + subprocess.CalledProcessError(1, "npm install", output="error"), + ] + with patch("build_scripts.prepare_package.shutil.which", return_value="/usr/bin/npm"): + with patch("build_scripts.prepare_package.subprocess.run", side_effect=responses): + result = build_frontend(tmp_path) + assert result is False + + def test_returns_false_when_npm_build_fails(self, tmp_path: Path) -> None: + (tmp_path / "package.json").write_text("{}") + responses = [ + MagicMock(stdout="10.0.0\n"), + MagicMock(), + subprocess.CalledProcessError(1, "npm run build", output="error"), + ] + with patch("build_scripts.prepare_package.shutil.which", return_value="/usr/bin/npm"): + with patch("build_scripts.prepare_package.subprocess.run", side_effect=responses): + result = build_frontend(tmp_path) + assert result is False + + def test_returns_true_when_build_succeeds(self, tmp_path: Path) -> None: + (tmp_path / "package.json").write_text("{}") + with patch("build_scripts.prepare_package.shutil.which", return_value="/usr/bin/npm"): + with patch("build_scripts.prepare_package.subprocess.run", return_value=MagicMock(stdout="10.0.0\n")): + result = build_frontend(tmp_path) + assert result is True + + +class TestCopyFrontendToPackage: + def test_returns_false_when_dist_missing(self, tmp_path: Path) -> None: + result = copy_frontend_to_package(tmp_path / "dist", tmp_path / "out") + assert result is False + + def test_returns_false_when_index_html_missing(self, tmp_path: Path) -> None: + dist = tmp_path / "dist" + dist.mkdir() + (dist / "main.js").write_text("console.log('hi')") + result = copy_frontend_to_package(dist, tmp_path / "out") + assert result is False + + def test_returns_true_when_copy_succeeds(self, tmp_path: Path) -> None: + dist = tmp_path / "dist" + dist.mkdir() + (dist / "index.html").write_text("") + out = tmp_path / "out" + result = copy_frontend_to_package(dist, out) + assert result is True + assert (out / "index.html").exists() + + def test_removes_existing_output_dir(self, tmp_path: Path) -> None: + dist = tmp_path / "dist" + dist.mkdir() + (dist / "index.html").write_text("") + out = tmp_path / "out" + out.mkdir() + (out / "old_file.txt").write_text("old") + copy_frontend_to_package(dist, out) + assert not (out / "old_file.txt").exists() + assert (out / "index.html").exists() diff --git a/tests/unit/build_scripts/test_validate_docs.py b/tests/unit/build_scripts/test_validate_docs.py new file mode 100644 index 000000000..bea434a49 --- /dev/null +++ b/tests/unit/build_scripts/test_validate_docs.py @@ -0,0 +1,86 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import tempfile +from pathlib import Path + +import pytest + +from build_scripts.validate_docs import find_orphaned_files, parse_toc_files, validate_toc_files + + +class TestParseTocFiles: + def test_extracts_single_file(self) -> None: + toc = [{"file": "intro"}] + result = parse_toc_files(toc) + assert "intro" in result + + def test_extracts_nested_children(self) -> None: + toc = [{"file": "parent", "children": [{"file": "child"}]}] + result = parse_toc_files(toc) + assert "parent" in result + assert "child" in result + + def test_ignores_entries_without_file(self) -> None: + toc = [{"title": "No file here"}] + result = parse_toc_files(toc) + assert len(result) == 0 + + def test_empty_toc(self) -> None: + result = parse_toc_files([]) + assert result == set() + + def test_normalizes_backslashes(self) -> None: + toc = [{"file": "setup\\install"}] + result = parse_toc_files(toc) + assert "setup/install" in result + + +class TestValidateTocFiles: + def test_no_errors_when_files_exist(self, tmp_path: Path) -> None: + (tmp_path / "intro.md").write_text("# Intro") + errors = validate_toc_files({"intro.md"}, tmp_path) + assert errors == [] + + def test_error_when_file_missing(self, tmp_path: Path) -> None: + errors = validate_toc_files({"missing.md"}, tmp_path) + assert len(errors) == 1 + assert "missing.md" in errors[0] + + def test_skips_api_generated_files(self, tmp_path: Path) -> None: + errors = validate_toc_files({"api/some_module"}, tmp_path) + assert errors == [] + + def test_multiple_missing_files(self, tmp_path: Path) -> None: + errors = validate_toc_files({"a.md", "b.md"}, tmp_path) + assert len(errors) == 2 + + +class TestFindOrphanedFiles: + def test_no_orphans_when_all_referenced(self, tmp_path: Path) -> None: + (tmp_path / "intro.md").write_text("# Intro") + orphaned = find_orphaned_files({"intro.md"}, tmp_path) + assert orphaned == [] + + def test_detects_orphaned_markdown(self, tmp_path: Path) -> None: + (tmp_path / "orphan.md").write_text("# Orphan") + orphaned = find_orphaned_files(set(), tmp_path) + assert any("orphan.md" in o for o in orphaned) + + def test_skips_build_directory(self, tmp_path: Path) -> None: + build_dir = tmp_path / "_build" + build_dir.mkdir() + (build_dir / "generated.md").write_text("# Generated") + orphaned = find_orphaned_files(set(), tmp_path) + assert not any("_build" in o for o in orphaned) + + def test_skips_myst_yml(self, tmp_path: Path) -> None: + (tmp_path / "myst.yml").write_text("project:") + orphaned = find_orphaned_files(set(), tmp_path) + assert not any("myst.yml" in o for o in orphaned) + + def test_skips_py_companion_files(self, tmp_path: Path) -> None: + (tmp_path / "notebook.ipynb").write_text("{}") + (tmp_path / "notebook.py").write_text("# companion") + orphaned = find_orphaned_files(set(), tmp_path) + assert not any("notebook.py" in o for o in orphaned)