Skip to content

tests/test_book.py

View raw file · All sources

"""The book must preserve runnable code and portable links."""
from pathlib import Path
import re
import zipfile
import pytest

from book.build import build_book, split_chapters
from book.check import check_site

ROOT = Path(__file__).resolve().parents[1]


def test_chapter_split_ignores_headings_inside_code():
    text = '# Book\nIntro\n\n## 0. Start\n```python\n## Not a chapter\n### Stay\n```\n\n## 1. Next\n### Detail\n'
    intro, chapters = split_chapters(text)
    assert 'Intro' in intro
    assert len(chapters) == 2
    assert '## Not a chapter\n### Stay' in chapters[0][1]
    assert chapters[1][0] == '1. Next'


def test_book_preserves_every_code_block_and_chapter(tmp_path):
    result = build_book(ROOT, tmp_path)
    original = (ROOT / 'tasks/plan.md').read_text()
    compiled = '\n'.join((tmp_path / page).read_text() for page in result['chapters'])
    pattern = r'^```[^\n]*\n.*?^```$'
    assert re.findall(pattern, compiled, re.M | re.S) == re.findall(pattern, original, re.M | re.S)
    assert len(result['chapters']) == 10
    assert '# 4. 训练与验证:运行第一次 QLoRA' in (tmp_path / 'chapters/04-training.md').read_text()


def test_book_links_to_readable_code_and_bundles_only_source(tmp_path):
    build_book(ROOT, tmp_path)
    training = (tmp_path / 'chapters/04-training.md').read_text()
    assert '../reference/code/lab/train.py.md' in training
    assert (tmp_path / 'reference/code/lab/train.py.md').is_file()
    assert (tmp_path / 'reference/checklist.md').is_file()
    assert (tmp_path / 'reference/verification.md').is_file()
    with zipfile.ZipFile(tmp_path / 'downloads/lora-lab.zip') as archive:
        paths = archive.namelist()
        assert 'llm-lora-tutorial/lab/train.py' in paths
        assert 'llm-lora-tutorial/data/dataset.json' in paths
        assert 'llm-lora-tutorial/data/raw/seeds.jsonl' not in paths
        assert 'llm-lora-tutorial/data/facts.jsonl' not in paths
        assert 'llm-lora-tutorial/book_review.sh' in paths
        assert not any('/models/' in p or '/runs/' in p or '/.venv' in p for p in paths)
        assert archive.read('llm-lora-tutorial/tasks/plan.md').decode() == (ROOT / 'tasks/plan.md').read_text()


def test_generated_markdown_has_no_broken_local_links(tmp_path):
    build_book(ROOT, tmp_path)
    for page in tmp_path.rglob('*.md'):
        # Source viewers contain literal Markdown inside fences, not active links.
        text = re.sub(r'^(`{3,})[^\n]*\n.*?^\1\s*$', '', page.read_text(), flags=re.M | re.S)
        for target in re.findall(r'\]\(([^)\s]+)\)', text):
            if re.match(r'https?://|mailto:|#', target):
                continue
            assert not target.startswith('/'), (page, target)
            assert (page.parent / target.split('#')[0]).exists(), (page, target)


def test_notebook_download_matches_upload_source(tmp_path):
    build_book(ROOT, tmp_path)
    notebook = 'notebooks/01_gpu_train.ipynb'
    assert (tmp_path / 'downloads' / notebook).read_bytes() == (ROOT / notebook).read_bytes()
    assert '../downloads/notebooks/01_gpu_train.ipynb' in (tmp_path / 'chapters/03-gpu.md').read_text()
    with zipfile.ZipFile(tmp_path / 'downloads/lora-lab.zip') as archive:
        assert archive.read('llm-lora-tutorial/' + notebook) == (ROOT / notebook).read_bytes()
        assert 'llm-lora-tutorial/lab/notebook.py' not in archive.namelist()
        assert archive.read('llm-lora-tutorial/lab/download-lock.json') == (ROOT / 'lab/download-lock.json').read_bytes()


def test_data_viewer_explains_purpose_and_formats_one_real_record(tmp_path):
    import json
    build_book(ROOT, tmp_path)
    page = (tmp_path / 'reference/code/data/dataset.json.md').read_text()
    data = json.loads((ROOT / 'data/dataset.json').read_text())
    first = data['train'][0]
    sample = {'facts': [card for card in data['facts'] if card['id'] in first['fact_ids']],
              'train': [first], 'val': ['…'], 'test': ['…']}
    assert '唯一需要维护的数据文件' in page
    assert '[逐字段说明与使用位置]' in page
    assert json.dumps(sample, ensure_ascii=False, indent=2) in page
    assert '<summary>展开全部原始记录' in page


def test_404_uses_configured_project_prefix_and_checks_target(tmp_path):
    (tmp_path / 'index.html').write_text('<h1 id="intro">Book</h1>')
    (tmp_path / '404.html').write_text('<a href="/demo/#intro">Home</a>')
    assert check_site(tmp_path, base_path='/demo/')['pages'] == 2
    with pytest.raises(ValueError, match='non-portable'):
        check_site(tmp_path, base_path='/other/')
    (tmp_path / '404.html').write_text('<a href="/demo/#missing">Home</a>')
    with pytest.raises(ValueError, match='missing anchor'):
        check_site(tmp_path, base_path='/demo/')


def test_removed_modules_cannot_survive_as_generated_source_downloads(tmp_path):
    stale_paths = [tmp_path / 'reference/code/lab/removed.py.md',
                   tmp_path / 'downloads/source/lab/removed.py']
    for path in stale_paths:
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text('obsolete generated source')
    build_book(ROOT, tmp_path)
    assert not any(path.exists() for path in stale_paths)
    assert (tmp_path / 'reference/code/lab/train.py.md').is_file()
    assert (tmp_path / 'downloads/source/lab/train.py').read_bytes() == (ROOT / 'lab/train.py').read_bytes()


TRANSLATIONS = {
    'en': ('01_gpu_train.en.ipynb', 'The only data file to maintain', 'Expand all raw records'),
    'ja': ('01_gpu_train.en.ipynb', 'メンテナンスするデータファイルはこれ 1 つだけです', 'すべての元データを展開'),
    'ko': ('01_gpu_train.en.ipynb', '관리할 데이터 파일은 이것 하나뿐입니다', '전체 원본 레코드 펼치기'),
}


@pytest.mark.parametrize('lang', TRANSLATIONS)
def test_translated_editions_build_with_the_same_pages_and_code(tmp_path, lang):
    notebook, data_intro, expand = TRANSLATIONS[lang]
    zh = build_book(ROOT, tmp_path / 'zh')
    en = build_book(ROOT, tmp_path / lang, lang)
    # 同一路径两种语言,语言切换才能停留在同一页。
    assert en['chapters'] == zh['chapters'] and en['source'] == f'tasks/plan.{lang}.md'
    pages = lambda folder: sorted(p.relative_to(folder).as_posix() for p in folder.rglob('*.md'))
    assert pages(tmp_path / lang) == pages(tmp_path / 'zh')
    original = (ROOT / f'tasks/plan.{lang}.md').read_text()
    compiled = '\n'.join((tmp_path / lang / page).read_text() for page in en['chapters'])
    pattern = r'^```[^\n]*\n.*?^```$'
    assert re.findall(pattern, compiled, re.M | re.S) == re.findall(pattern, original, re.M | re.S)
    assert (tmp_path / lang / 'chapters/04-training.md').read_text().startswith('# 4. ')
    assert f'../downloads/notebooks/{notebook}' in (tmp_path / lang / 'chapters/03-gpu.md').read_text()
    assert (tmp_path / lang / 'downloads/notebooks' / notebook).read_bytes() == \
        (ROOT / 'notebooks' / notebook).read_bytes()
    viewer = (tmp_path / lang / 'reference/code/data/dataset.json.md').read_text()
    assert data_intro in viewer and f'<summary>{expand}' in viewer
    assert f'(../downloads/notebooks/{notebook})' in (tmp_path / lang / 'reference/sources.md').read_text()
    for page in (tmp_path / lang).rglob('*.md'):
        text = re.sub(r'^(`{3,})[^\n]*\n.*?^\1\s*$', '', page.read_text(), flags=re.M | re.S)
        for target in re.findall(r'\]\(([^)\s]+)\)', text):
            if not re.match(r'https?://|mailto:|#', target):
                assert (page.parent / target.split('#')[0]).exists(), (page, target)


@pytest.mark.parametrize('lang', TRANSLATIONS)
def test_mkdocs_configs_list_the_same_pages_and_link_each_other(lang):
    import yaml
    from book.build import LANGUAGES

    def load(name):
        # Material's !!python/name tags are not used here; plain YAML is enough.
        return yaml.safe_load((ROOT / name).read_text())

    zh, other = load('mkdocs.yml'), load(f'mkdocs.{lang}.yml')
    assert other['INHERIT'] == 'mkdocs.yml' and other['theme']['language'] == lang
    assert other['docs_dir'] == LANGUAGES[lang]['docs_dir']
    assert [list(item.values())[0] for item in zh['nav']] == [list(item.values())[0] for item in other['nav']]
    menu = lambda config: [alt['lang'] for alt in config['extra']['alternate']]
    assert menu(zh) == menu(other) == list(LANGUAGES)
    assert other['site_dir'] == f"{zh['site_dir']}/{lang}"