From dd207aad228d929a065e1d5000edac5272212339 Mon Sep 17 00:00:00 2001 From: 9uiLe Date: Mon, 14 Sep 2026 10:38:17 +0900 Subject: [PATCH] test(speakerdeck-knowledge): complete image workflow coverage and docs --- .gitattributes | 15 ---- README.md | 4 +- plugins/speakerdeck-knowledge/README.md | 10 +-- .../references/acquisition.md | 8 ++- .../references/html-guide.md | 6 +- .../references/image-reading.md | 18 +++-- .../scripts/fetch_deck.py | 13 ++-- .../scripts/prepare_images.py | 25 ++++--- .../tests/test_fetch_deck.py | 68 ++++++++++++------- .../tests/test_prepare_images.py | 52 ++++++++++---- scripts/lib/common.sh | 2 +- 11 files changed, 138 insertions(+), 83 deletions(-) delete mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index bf9c2f1..0000000 --- a/.gitattributes +++ /dev/null @@ -1,15 +0,0 @@ -# Vendored third-party assets (Prism / Mermaid) — see ADR-0002. -# `binary` suppresses textual diff so review attention is drawn to any change; -# `linguist-vendored` and `linguist-generated` exclude these files from -# GitHub language statistics and from default PR diff rendering. - -docs/**/system/vendor/** binary linguist-vendored linguist-generated - -# Generated design-system copies under any docs output directory. -docs/**/system/components/** linguist-generated -docs/**/system/components.css linguist-generated -docs/**/system/components.js linguist-generated -docs/**/system/tokens.css linguist-generated - -# Checksums are text and must remain diff-able. -docs/**/system/vendor/SHA256SUMS text diff --git a/README.md b/README.md index b04516a..150d047 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ codex plugin add @9uile-plugins │ ├── speakerdeck-knowledge/ │ │ ├── .claude-plugin/plugin.json │ │ ├── .codex-plugin/plugin.json -│ │ ├── skills/ ← speakerdeck-knowledge と取得補助・図解用 CSS +│ │ ├── skills/ ← speakerdeck-knowledge と取得・画像生成補助・図解用 CSS │ │ ├── tests/ │ │ └── README.md │ └── model-strategy/ @@ -86,7 +86,7 @@ codex plugin add @9uile-plugins │ ├── references/ │ ├── tests/ │ └── README.md -├── docs/ ← 設計指針・ADR +├── docs/ ← 設計指針・運用記録 ├── scripts/ ← リリース・バージョン検証 ├── releases/ ← リリースノート ├── CHANGELOG.md diff --git a/plugins/speakerdeck-knowledge/README.md b/plugins/speakerdeck-knowledge/README.md index 3a59d80..628c604 100644 --- a/plugins/speakerdeck-knowledge/README.md +++ b/plugins/speakerdeck-knowledge/README.md @@ -17,7 +17,7 @@ URL は必須です。形式の指定がなければ Markdown と HTML の両方 ## 成果物の保存先と内容 -保存先が未指定の場合は、作業ディレクトリの `ai-knowledge/` 配下に、スライドごとのディレクトリを作成します。ディレクトリ名は URL の最後のパス要素で、クエリとフラグメントを除きます。 +保存先が未指定の場合は、作業ディレクトリの `ai-knowledge/` 配下に、資料ごとのディレクトリを作成します。ディレクトリ名は URL の最後のパス要素で、クエリとフラグメントを除きます。 ```text 作業ディレクトリ/ @@ -37,7 +37,7 @@ URL は必須です。形式の指定がなければ Markdown と HTML の両方 形式を指定した場合は指定形式だけを作成します。必要な検証記録や配布用画像も、その資料のディレクトリへまとめます。同じ資料の再生成では同じ場所を使い、別資料と名前が重なる場合は著者の URL 識別子を付けて区別します。 -取得した HTML・本文・スライド画像一式は一時ディレクトリに保存します。プラグインの配置先や `examples/` は通常利用時の保存先には使いません。 +取得した元 HTML・本文・選択したページの元画像は一時ディレクトリに保存します。プラグインの配置先や `examples/` は通常利用時の保存先には使いません。 ## 資料の設計 @@ -47,7 +47,7 @@ HTML は PC 向けの単体ファイルを基本とします。左側の追従 本文を先に読み、図・配置の確認や本文欠損を補うために必要な画像だけを開きます。画像主体の資料も少数ページずつ確認します。取得した範囲と実際に読んだ範囲を成果物に記録し、仮説や提案の効果は観測済みの成果と区別します。資料の作成には、対象プロジェクトの実装や外部への公開・送信は含みません。 -画像の閲覧には長辺1280px以下の縮小画像を使い、位置の俯瞰が必要な場合だけ最大6ページのコンタクトシートを作ります。読めない文字や図は必要領域を元画像から切り出し、観察結果をメモして執筆中の再読を抑えます。元画像は引用・配布用に保持し、HTML の画像データはローカルで埋め込みます。実際の token 使用量はモデル・閲覧ツールに依存するため、固定の削減率は保証しません。 +画像の閲覧は、既定で長辺1280px以下の縮小画像から始めます。位置の俯瞰が必要な場合だけ最大6ページのコンタクトシートを作り、読めない文字や図は元画像の必要領域を切り出して確認します。寸法の調整方法とコマンド例は [画像読解の手順](skills/speakerdeck-knowledge/references/image-reading.md) に記載しています。観察結果をメモして執筆中の再読を抑え、元画像は引用・配布用に保持します。HTML の画像データはローカルで埋め込みます。実際の token 使用量はモデル・閲覧ツールに依存するため、固定の削減率は保証しません。 ## 構成と実行環境 @@ -66,7 +66,7 @@ HTML は PC 向けの単体ファイルを基本とします。左側の追従 | `scripts/fetch_deck.py` | ページ別本文・画像・書誌情報の取得と保存 | | `scripts/prepare_images.py` | 対象ページの縮小・切り出し・分割コンタクトシート生成 | -取得スクリプトの `--out` は中間資料の保存先です。最終成果物の保存先決定と執筆は、スキルを実行する AI が担当します。 +取得スクリプトの `--out` は中間資料の保存先です。画像生成スクリプトはその中の `reading-images/` に閲覧用画像を作ります。画像の閲覧、`reading-notes.md` への観察記録、最終成果物の保存先決定と執筆は、スキルを実行する AI が担当します。 ## 検証 @@ -81,4 +81,4 @@ rtk proxy python3 -m unittest discover -s plugins/speakerdeck-knowledge/tests -v rtk proxy bash scripts/verify-versions.sh ``` -サンプルの `validation.md` は読解範囲と表示・操作の確認結果です。通常利用時の必須出力ではありません。独立した読者の理解度や実案件での改善効果は、別途評価する必要があります。ローカル導入は [コントリビューションガイド](../../CONTRIBUTING.md#ローカルで検証する) を参照してください。 +自動テストは補助スクリプトの動作を検証します。資料の読解品質・表示・操作、独立した読者の理解度や実案件での改善効果は、別途評価する必要があります。ローカル導入は [コントリビューションガイド](../../CONTRIBUTING.md#ローカルで検証する) を参照してください。 diff --git a/plugins/speakerdeck-knowledge/skills/speakerdeck-knowledge/references/acquisition.md b/plugins/speakerdeck-knowledge/skills/speakerdeck-knowledge/references/acquisition.md index 326bf90..2c1e676 100644 --- a/plugins/speakerdeck-knowledge/skills/speakerdeck-knowledge/references/acquisition.md +++ b/plugins/speakerdeck-knowledge/skills/speakerdeck-knowledge/references/acquisition.md @@ -29,7 +29,11 @@ python3 scripts/fetch_deck.py 'https://speakerdeck.com/author/deck' --out /tmp/d 画像は本文ブロックの実在するリンクから取得し、JPEG/PNG の応答を保存する。各ページの状態は `not_requested`、`downloaded`、`failed` のいずれか。取得失敗時には理由を記録し、成功した画像を保持する。 -終了コードは成功が0、入力・本文取得・解析・保存の失敗が1、画像の一部取得失敗が2。異なる資料がある保存先はエラーにする。同じ資料では、ページ番号と画像 URL が一致しローカルファイルが残る取得済み画像を再利用し、追加取得時にもその状態を保持する。失敗した画像の再取得は明示した範囲だけ行う。資料の更新を確認したい場合は、新しい一時ディレクトリに取得する。 +異なる資料がある保存先はエラーにする。同じ資料では、ページ番号と画像 URL が一致し、取得時のファイル名で空でないローカルファイルが残る取得済み画像を再利用する。過去の取得失敗もページ番号と画像 URL が一致すれば保持し、再取得は明示した範囲だけ行う。再利用時は配信元の画像を再検証しないため、資料の更新を確認したい場合は新しい一時ディレクトリに取得する。 + +標準出力の JSON は、総ページ数 `pages`、指定した画像の件数 `images_requested`、その実行で取得に失敗したページ `images_failed`、保存先 `out` を返す。`images_requested` は再利用した画像も含む。全ページの取得状態は `deck.json` を参照する。 + +引数解析後の終了コードは、指定範囲の処理成功が0、入力・本文取得・解析・保存の失敗が1、指定範囲の画像取得失敗が2。指定範囲外の過去の失敗は終了コードと `images_failed` に含めない。必須引数不足など、コマンドライン引数の解析エラーも2で終了し、標準エラーに使用方法を出す。 ## 読解の進め方 @@ -43,6 +47,6 @@ python3 scripts/fetch_deck.py 'https://speakerdeck.com/author/deck' --out /tmp/d HTTP エラーでは応答と実行環境を確認し、公開ブラウザ閲覧など条件の異なる経路を選ぶ。同じ失敗の再試行には条件の変化を必要とし、認証を迂回しない。 -本文を解析できなければ公開の画像や PDF の実在を確認する。PDF はページ数と本文をローカルに抽出し、必要ページだけをレンダリングして画像読解の手順へ進む。OCR を補助に使う場合は否定・数値・コード・矢印の方向を必要な画像領域と照合する。推測した連番 URL でページを補わない。 +本文を解析できなければ公開の画像や PDF の実在を確認する。PDF はページ数と本文をローカルに抽出し、必要ページだけをレンダリングして画像読解の手順へ進む。推測した連番 URL でページを補わない。 本文も画像も読めない場合は PDF または画像の提供を求める。説明文や検索スニペットしか確認できていない場合はその範囲を明記し、全スライドの知識抽出として納品しない。 diff --git a/plugins/speakerdeck-knowledge/skills/speakerdeck-knowledge/references/html-guide.md b/plugins/speakerdeck-knowledge/skills/speakerdeck-knowledge/references/html-guide.md index f9da06c..45f0ea8 100644 --- a/plugins/speakerdeck-knowledge/skills/speakerdeck-knowledge/references/html-guide.md +++ b/plugins/speakerdeck-knowledge/skills/speakerdeck-knowledge/references/html-guide.md @@ -50,13 +50,13 @@ PC 専用のレイアウトとし、サイドバー幅360pxを起点に本文幅 `assets/guide.css` を土台として、資料に使う規則を HTML の `style` に埋め込む。単体共有では引用画像を data URI、必要な JavaScript をインラインで含める。画像が多くファイルサイズが問題になる場合は HTML と画像フォルダを一組で渡す。出典リンクを残し、表示に CDN・外部フォント・通信を必要としない構成にする。取得キャッシュやスライド全文は配布対象に含めない。 -画像の埋め込みはローカルのコードで行い、base64 をセッションへ出力しない。埋め込み後の HTML は画像データを除いて本文を検査する。画像の扱いとスクリーンショットの範囲は [画像を効率的に読む](image-reading.md) に従う。 +画像の埋め込みはローカルのコードで行い、base64・画像バイナリを会話やツールのテキスト結果へ出さない。埋め込み後の HTML 全文や巨大な diff も出さず、本文は埋め込み前か画像データを除いた抽出で検査する。 言語を示す `lang`、ページタイトル、viewport、適切な見出し階層、アンカーを設定する。色にはラベルを併用する。拡大や開閉はキーボードで操作でき、閉じた後は起点へフォーカスを戻す。JavaScript 無効時も知識と作業指示を閲覧できるようにする。 ## 完了確認 -- 代表的な PC の幅と高さでビューポート単位のスクリーンショットを撮り、実際に見る。ヘッダーのまとまり、本文の読み順、図の可読性、見切れ、サイドバーの追従と内部スクロールを確認する。追加撮影は未確認・修正した領域を中心に行う。 -- 目次の全リンク、拡大、開閉、キーボードのフォーカスを操作する。JavaScript 無効時の本文と指示全文、表示時の外部通信とスクリプト例外を確認する。 +- 代表的な PC の幅と高さでビューポート単位のスクリーンショットを撮り、実際に見る。ヘッダーのまとまり、本文の読み順、図の可読性、見切れ、サイドバーの追従と内部スクロールを確認する。追加撮影は未確認・修正した領域と影響範囲を対象とし、長いページの全面画像を繰り返し開かない。 +- 目次の全リンク、拡大、開閉、キーボードのフォーカスを操作する。リンク・画像の有無や寸法は DOM の検査も使う。JavaScript 無効時の本文と指示全文、表示時の外部通信とスクリプト例外を確認する。 - 印刷では操作部品を隠し、開閉内容を表示し、図とキャプションの分断を避ける。 - Markdown の知識 ID と各項目、根拠ページ、AI 指示を照合する。未検証の環境や読解範囲を検証結果に記載する。 diff --git a/plugins/speakerdeck-knowledge/skills/speakerdeck-knowledge/references/image-reading.md b/plugins/speakerdeck-knowledge/skills/speakerdeck-knowledge/references/image-reading.md index 82eccc6..29231b4 100644 --- a/plugins/speakerdeck-knowledge/skills/speakerdeck-knowledge/references/image-reading.md +++ b/plugins/speakerdeck-knowledge/skills/speakerdeck-knowledge/references/image-reading.md @@ -7,7 +7,7 @@ 1. 本文から「ページ・確認したい問い」を選ぶ。本文欠損や画像依存の内容は飛ばさず、画像主体の資料では少数ページずつ進める。全件を一度に開く必要はない。 2. 図のある位置や反復ページの判別だけが必要なら、小さなコンタクトシートを使う。読むページが既に決まっている場合は、単ページの縮小画像へ直接進む。全ページのシート閲覧を必須にしない。 3. 単ページの縮小画像で問いに答えられれば止める。文字・数値・コードが読めない場合は、元画像の必要領域を切り出す。矢印の両端や凡例など、意味を決める周囲も含める。切り出しでは関係が失われる場合だけ単ページの解像度を上げる。 -4. 確認直後に一時ディレクトリの `reading-notes.md` へ、ページ・確認方法と領域・観察結果・未解決の問いを短く追記する。執筆、HTML 化、会話の圧縮後はこのメモを参照する。同じ画像は、新しい問いや可読性の問題がある場合だけ再度開く。 +4. 確認直後に、読解する AI が一時ディレクトリの `reading-notes.md` へ、ページ・確認方法と領域・観察結果・未解決の問いを短く追記する。補助スクリプトは閲覧や観察の記録を行わない。執筆、HTML 化、会話の圧縮後はこのメモを参照する。同じ画像は、新しい問いや可読性の問題がある場合だけ再度開く。 コンタクトシートで細かな文字を精読した扱いにしない。取得や縮小画像の生成だけでは閲覧済みにしない。本文のみで確認した範囲、画像で確認した範囲、未確認部分と影響を成果物へ記載し、未読部分を推測で補わない。 @@ -28,12 +28,16 @@ python3 scripts/prepare_images.py --deck /tmp/deck-source/deck.json --pages 5-12 python3 scripts/prepare_images.py --deck /tmp/deck-source/deck.json --pages 19 --crop 0.5,0,1,1 ``` -出力は一時ディレクトリ内の `reading-images/`。標準出力にはパス・対象ページ・寸法だけを返すので、そのうち次の問いに必要な画像だけを画像閲覧ツールで開く。シートはページ番号を描画し、最大6ページで分割する。`--pages` は必須で `all` は受け付けない。`--max-edge` は必要時に256〜2048pxで調整できる。これはこの補助ツールの運用上の上限であり、モデルの仕様ではない。 +| 引数 | 指定と制約 | +| --- | --- | +| `--deck` | 必須。取得済みの `deck.json` | +| `--pages` | 必須。1始まりのページ番号または範囲。重複を除きページ順に処理する。`all` は受け付けない | +| `--mode` | 既定は `preview`。`sheet` はページ番号付きのコンタクトシートを最大6ページずつ生成する | +| `--crop` | `preview` の単一ページだけに指定できる。向き補正後の元画像を基準とする左・上・右・下の座標(0〜1)。切り出した領域を縮小する | +| `--max-edge` | 生成画像の長辺の上限。既定1280px、指定範囲256〜2048px。元画像の拡大は行わない | -同じ元画像・ページ指定・変換条件なら同じ出力を再利用する。ファイルの再利用とセッション内の再閲覧は別なので、既読かどうかはメモで判断する。元画像は変更せず、引用・配布には必要な元画像を使う。OCR が利用できる場合はローカルの補助抽出に使えるが、不確かな文字や視覚的関係は画像で確認する。 +寸法とページ数の上限は補助ツールの運用上の制約であり、モデルの仕様ではない。出力は指定した `deck.json` と同じディレクトリ内の `reading-images/` に保存する。標準出力の JSON は `images` 配列にパス `path`、対象ページ `pages`、生成画像の寸法 `width`・`height` だけを返す。そのうち次の問いに必要な画像だけを画像閲覧ツールで開く。 -## 成果物と表示確認 +終了コードは成功が0、指定値の検証・画像処理・保存や依存ライブラリのエラーが1、必須引数不足などコマンドライン引数の解析エラーが2。失敗時は標準エラーに理由を出す。途中まで生成された画像は残るが、失敗した実行では成功結果の JSON を返さない。 -HTML の data URI はローカルのコードで画像ファイルから埋め込み、base64・画像バイナリを会話やツールのテキスト結果へ出さない。埋め込み後の HTML 全文や巨大な diff も出さず、本文の確認は埋め込み前か画像データを除いた抽出で行う。 - -HTML の表示確認は代表的な PC ビューポートと、問題がある領域のスクリーンショットから始める。長いページの全面画像を繰り返し開かない。リンク・画像の有無・寸法・操作は DOM やブラウザの検査も使い、画像確認は可読性や配置の判断に使う。修正後は変更領域と影響範囲を確認する。 +同じ元画像・ページ指定・変換条件なら同じ出力を再利用する。ファイルの再利用とセッション内の再閲覧は別なので、既読かどうかはメモで判断する。元画像は変更せず、引用・配布には必要な元画像を使う。OCR が利用できる場合はローカルの補助抽出に使えるが、不確かな否定・数値・コード・矢印の方向は必要な画像領域と照合する。 diff --git a/plugins/speakerdeck-knowledge/skills/speakerdeck-knowledge/scripts/fetch_deck.py b/plugins/speakerdeck-knowledge/skills/speakerdeck-knowledge/scripts/fetch_deck.py index f712d5c..ead278e 100644 --- a/plugins/speakerdeck-knowledge/skills/speakerdeck-knowledge/scripts/fetch_deck.py +++ b/plugins/speakerdeck-knowledge/skills/speakerdeck-knowledge/scripts/fetch_deck.py @@ -4,7 +4,9 @@ The --out directory holds source.html, deck.json, transcript.md and requested images as reading evidence. The calling skill chooses the separate directory for knowledge.md and guide.html, interprets the evidence and writes those files. -Exit codes: 0 complete, 1 input/source error, 2 partial image acquisition. +After argument parsing, exit codes are 0 for success, 1 for input/source/output +errors, and 2 for failures among requested images. Argument parsing errors also +exit 2. Unrequested cached failures remain in deck.json, not the exit status. """ import argparse from datetime import datetime, timezone @@ -194,8 +196,9 @@ def prepare_evidence_directory(directory, url): return previous return {} + def reuse_images(pages, previous, directory): - """Preserve matching acquisition state across text-first, incremental reads.""" + """Retain source-matched state without revalidating the remote image.""" indexed = {page["page"]: page for page in previous.get("pages", [])} for page in pages: old = indexed.get(page["page"], {}) @@ -212,7 +215,7 @@ def reuse_images(pages, previous, directory): def download_images(pages, chosen, directory): - """Record each requested image's result while retaining successful pages.""" + """Only selected failures retry; successful downloads stay cached.""" failed = [] for page in pages: if page["page"] not in chosen: @@ -240,7 +243,6 @@ def download_images(pages, chosen, directory): def write_evidence(deck, directory): - """Save acquisition state and a page-by-page reading document in UTF-8.""" (directory / "deck.json").write_text( json.dumps(deck, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" ) @@ -260,7 +262,8 @@ def main(): parser.add_argument("--out", required=True, type=Path, help="Temporary evidence directory; separate from final knowledge output") parser.add_argument("--html-file", type=Path, help="Parse HTML saved from the same deck") - parser.add_argument("--images", default="", help="all or page ranges such as 5-12,19") + parser.add_argument("--images", default="", + help="all or page ranges such as 5-12,19; default: none; cached images are reused") args = parser.parse_args() try: url = deck_url(args.url) diff --git a/plugins/speakerdeck-knowledge/skills/speakerdeck-knowledge/scripts/prepare_images.py b/plugins/speakerdeck-knowledge/skills/speakerdeck-knowledge/scripts/prepare_images.py index 2e5afbc..ba064e3 100644 --- a/plugins/speakerdeck-knowledge/skills/speakerdeck-knowledge/scripts/prepare_images.py +++ b/plugins/speakerdeck-knowledge/skills/speakerdeck-knowledge/scripts/prepare_images.py @@ -1,7 +1,8 @@ -"""Prepare bounded local slide previews; print paths, never image data. +"""Prepare bounded local slide previews; print paths, pages and dimensions only. Requires Pillow. Originals are preserved. Preparing an image does not mean it -has been visually reviewed. Exit codes: 0 success, 1 input/image/output error. +has been visually reviewed. Exit codes: 0 success, 1 validation/processing error, +2 argument parsing error. Failed runs keep generated files but emit no result JSON. """ import argparse import hashlib @@ -11,6 +12,9 @@ from fetch_deck import selection +# Increment when rendering changes so existing cached images cannot mask it. +CACHE_VERSION = 1 + def crop_box(value): try: @@ -67,6 +71,7 @@ def make_sheet(sources, edge): columns = min(2, len(sources)) rows = (len(sources) + columns - 1) // columns + # Bound both axes for portrait slides too; trim unused row height below. cell = edge // max(columns, rows) label_height, padding = 24, 8 images = [open_image(path, None, cell - 2 * padding - label_height) @@ -91,7 +96,7 @@ def prepare(sources, output, mode, crop, edge): for offset in range(0, len(sources), batch_size): group = sources[offset:offset + batch_size] numbers = [number for number, _ in group] - digest = hashlib.sha256(json.dumps([1, mode, crop, edge, numbers]).encode()) + digest = hashlib.sha256(json.dumps([CACHE_VERSION, mode, crop, edge, numbers]).encode()) for _, path in group: digest.update(hashlib.sha256(path.read_bytes()).digest()) name = f"{mode}-{'-'.join(str(n) for n in numbers)}-{digest.hexdigest()[:16]}.jpg" @@ -109,11 +114,15 @@ def prepare(sources, output, mode, crop, edge): def main(): parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--deck", required=True, type=Path, help="Local deck.json") - parser.add_argument("--pages", required=True, help="Explicit pages, e.g. 5-12,19") - parser.add_argument("--mode", choices=("preview", "sheet"), default="preview") - parser.add_argument("--crop", type=crop_box, help="Normalized left,top,right,bottom; one page only") - parser.add_argument("--max-edge", type=int, default=1280, help="Longest output edge: 256..2048 pixels") + parser.add_argument("--deck", required=True, type=Path, + help="Local deck.json; outputs go to reading-images/ beside it") + parser.add_argument("--pages", required=True, help="One-based pages, e.g. 5-12,19; all is not accepted") + parser.add_argument("--mode", choices=("preview", "sheet"), default="preview", + help="preview (default) or sheets of at most 6 pages") + parser.add_argument("--crop", type=crop_box, + help="Normalized left,top,right,bottom after orientation correction; one preview page only") + parser.add_argument("--max-edge", type=int, default=1280, + help="Longest output edge: 256..2048 pixels (default: 1280); no upscaling") args = parser.parse_args() try: if not 256 <= args.max_edge <= 2048: diff --git a/plugins/speakerdeck-knowledge/tests/test_fetch_deck.py b/plugins/speakerdeck-knowledge/tests/test_fetch_deck.py index 2eaec0a..c87fd65 100644 --- a/plugins/speakerdeck-knowledge/tests/test_fetch_deck.py +++ b/plugins/speakerdeck-knowledge/tests/test_fetch_deck.py @@ -1,4 +1,6 @@ import importlib.util +from contextlib import redirect_stdout +import io import json from pathlib import Path import subprocess @@ -123,37 +125,62 @@ def test_image_selection_skips_network_and_rejects_non_image_response(self): self.assertEqual(deck['pages'][0]['image_status'], 'not_requested') self.assertEqual(list(Path(directory).iterdir()), []) - def test_incremental_acquisition_preserves_and_reuses_downloads(self): + def test_incremental_cli_preserves_state_and_only_retries_requested_failures(self): jpeg = b'\xff\xd8\xff' + b'image-bytes' with tempfile.TemporaryDirectory() as directory: root = Path(directory) - previous = fetch.parse_deck(fixture(), URL) - with patch.object(fetch, 'fetch', return_value=jpeg): - fetch.download_images(previous['pages'], {1}, root) - fetch.write_evidence(previous, root) - deck = fetch.parse_deck(fixture(), URL) - fetch.reuse_images(deck['pages'], fetch.prepare_evidence_directory(root, URL), root) - with patch.object(fetch, 'fetch', return_value=jpeg) as request: - self.assertEqual(fetch.download_images(deck['pages'], {1, 2}, root), []) - request.assert_called_once_with(deck['pages'][1]['image_url']) - fetch.write_evidence(deck, root) - # A later text-only pass must not discard acquisition evidence. - text_only = fetch.parse_deck(fixture(), URL) - fetch.reuse_images(text_only['pages'], deck, root) + source = root / 'source.html' + source.write_text(fixture(), encoding='utf-8') + command = [str(SCRIPT), URL, '--html-file', str(source), '--out', str(root)] + + def run(*args): + output = io.StringIO() + with patch.object(sys, 'argv', command + list(args)), redirect_stdout(output): + status = fetch.main() + return status, json.loads(output.getvalue()) + + with patch.object(fetch, 'fetch', side_effect=[jpeg, ValueError('HTTP 403')]): + status, summary = run('--images', 'all') + self.assertEqual(status, 2) + self.assertEqual(summary['images_requested'], 2) + self.assertEqual(summary['images_failed'], [2]) + original = root / 'slide-001.jpg' + modified = original.stat().st_mtime_ns + with patch.object(fetch, 'fetch') as request: - fetch.download_images(text_only['pages'], set(), root) + status, summary = run() request.assert_not_called() - self.assertEqual([p['image_status'] for p in text_only['pages']], ['downloaded'] * 2) + self.assertEqual(status, 0) + self.assertEqual(summary['images_requested'], 0) + self.assertEqual(summary['images_failed'], []) + saved = json.loads((root / 'deck.json').read_text()) + self.assertEqual([p['image_status'] for p in saved['pages']], ['downloaded', 'failed']) + self.assertIn('403', saved['pages'][1]['image_error']) - def test_cache_misses_and_failed_retry(self): + with patch.object(fetch, 'fetch', return_value=jpeg) as request: + status, summary = run('--images', '1-2') + request.assert_called_once_with(saved['pages'][1]['image_url']) + self.assertEqual(status, 0) + self.assertEqual(summary['images_requested'], 2) + self.assertEqual(summary['images_failed'], []) + saved = json.loads((root / 'deck.json').read_text()) + self.assertEqual([p['image_status'] for p in saved['pages']], ['downloaded'] * 2) + self.assertNotIn('image_error', saved['pages'][1]) + self.assertEqual(original.read_bytes(), jpeg) + self.assertEqual(original.stat().st_mtime_ns, modified) + + def test_cache_requires_matching_source_and_nonempty_local_file(self): jpeg = b'\xff\xd8\xff' + b'image-bytes' with tempfile.TemporaryDirectory() as directory: - root = Path(directory) + root = Path(directory) / 'evidence' + root.mkdir() + (root.parent / 'slide-001.jpg').write_bytes(jpeg) previous = fetch.parse_deck(fixture(), URL) with patch.object(fetch, 'fetch', side_effect=[jpeg, ValueError('HTTP 403')]): fetch.download_images(previous['pages'], {1, 2}, root) for change in ['url', 'missing', 'empty', 'unsafe_path']: with self.subTest(change=change): + (root / 'slide-001.jpg').write_bytes(jpeg) old = json.loads(json.dumps(previous)) page = old['pages'][0] if change == 'url': @@ -168,11 +195,6 @@ def test_cache_misses_and_failed_retry(self): fetch.reuse_images(deck['pages'], old, root) self.assertEqual(deck['pages'][0]['image_status'], 'not_requested') self.assertEqual(deck['pages'][1]['image_status'], 'failed') - with patch.object(fetch, 'fetch', return_value=jpeg) as request: - self.assertEqual(fetch.download_images(deck['pages'], {2}, root), []) - request.assert_called_once() - self.assertEqual(deck['pages'][1]['image_status'], 'downloaded') - self.assertNotIn('image_error', deck['pages'][1]) if __name__ == '__main__': diff --git a/plugins/speakerdeck-knowledge/tests/test_prepare_images.py b/plugins/speakerdeck-knowledge/tests/test_prepare_images.py index 8068c72..f79d1e0 100644 --- a/plugins/speakerdeck-knowledge/tests/test_prepare_images.py +++ b/plugins/speakerdeck-knowledge/tests/test_prepare_images.py @@ -35,8 +35,12 @@ def run_cli(self, *args): def output_images(self, *args): result = self.run_cli(*args) self.assertEqual(result.returncode, 0, result.stderr) - self.assertLess(len(result.stdout), 3000) - return json.loads(result.stdout)['images'] + output = json.loads(result.stdout) + self.assertEqual(set(output), {'images'}) + for image in output['images']: + self.assertEqual(set(image), {'path', 'pages', 'width', 'height'}) + self.assertTrue(Path(image['path']).is_file()) + return output['images'] def test_preview_dimensions_original_preservation_and_cache_reuse(self): self.fixture() @@ -79,7 +83,7 @@ def test_sheets_split_with_page_mapping_labels_and_bounded_dimensions(self): self.assertLessEqual(max(output['width'], output['height']), 1280) with Image.open(output['path']) as sheet: self.assertEqual(sheet.size, (output['width'], output['height'])) - # Each row's label band has dark text, separate from the colored slide. + # Solid color fixtures make page labels detectable without OCR. columns = min(2, len(output['pages'])) rows = (len(output['pages']) + columns - 1) // columns for index in range(len(output['pages'])): @@ -97,17 +101,19 @@ def test_transparency_flattens_to_white(self): def test_invalid_selection_crop_and_bounds_produce_no_images(self): self.fixture(count=2, size=(100, 100)) - cases = [[], ['--pages', 'all'], ['--pages', ''], ['--pages', '0'], ['--pages', '3'], - ['--pages', '1', '--max-edge', '9000'], - ['--pages', '1', '--max-edge', '255'], - ['--pages', '1', '--crop', '0.8,0,0.2,1'], - ['--pages', '1', '--crop', 'nan,0,1,1'], - ['--pages', '1-2', '--crop', '0,0,1,1'], - ['--pages', '1', '--mode', 'sheet', '--crop', '0,0,1,1']] - for args in cases: + cases = [([], 2), (['--pages', 'all'], 1), (['--pages', ''], 1), + (['--pages', '0'], 1), (['--pages', '3'], 1), + (['--pages', '1', '--max-edge', '9000'], 1), + (['--pages', '1', '--max-edge', '255'], 1), + (['--pages', '1', '--crop', '0.8,0,0.2,1'], 2), + (['--pages', '1', '--crop', 'nan,0,1,1'], 2), + (['--pages', '1-2', '--crop', '0,0,1,1'], 1), + (['--pages', '1', '--mode', 'sheet', '--crop', '0,0,1,1'], 1)] + for args, expected_status in cases: with self.subTest(args=args): result = self.run_cli(*args) - self.assertNotEqual(result.returncode, 0) + self.assertEqual(result.returncode, expected_status) + self.assertEqual(result.stdout, '') self.assertFalse((self.root / 'reading-images').exists()) def test_missing_unrequested_and_corrupt_sources_report_errors(self): @@ -125,6 +131,28 @@ def test_missing_unrequested_and_corrupt_sources_report_errors(self): self.assertEqual(result.returncode, 1) self.assertIn('Page 1', result.stderr) + def test_crop_coordinates_follow_image_orientation(self): + self.fixture(size=(400, 200)) + with Image.open(self.root / 'slide-001.png') as image: + exif = image.getexif() + exif[274] = 6 # Display orientation is 90 degrees clockwise. + image.save(self.root / 'slide-001.png', exif=exif) + output = self.output_images('--pages', '1', '--crop', '0,0.5,1,1')[0] + self.assertEqual((output['width'], output['height']), (200, 200)) + with Image.open(output['path']) as image: + red, _, blue = image.getpixel((100, 100)) + self.assertLess(red, 10) + self.assertGreater(blue, 240) + + def test_partial_processing_failure_keeps_files_without_success_json(self): + self.fixture(count=2, size=(100, 100)) + (self.root / 'slide-002.png').write_bytes(b'not an image') + result = self.run_cli('--pages', '1-2') + self.assertEqual(result.returncode, 1) + self.assertEqual(result.stdout, '') + self.assertNotIn('Traceback', result.stderr) + self.assertEqual(len(list((self.root / 'reading-images').glob('preview-1-*.jpg'))), 1) + if __name__ == '__main__': unittest.main() diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh index e80d88f..ff41353 100644 --- a/scripts/lib/common.sh +++ b/scripts/lib/common.sh @@ -81,7 +81,7 @@ require_branch() { # ---------- version helpers ---------- -# SemVer X.Y.Z (no pre-release / metadata for now — see ADR-0001 Q3) +# Release automation accepts numeric X.Y.Z only; pre-release and build metadata are unsupported. SEMVER_REGEX='^[0-9]+\.[0-9]+\.[0-9]+$' is_semver() {