From a2936ab54741aef045e61d084c0a1c640e418758 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=8B=E3=81=BF=E3=81=AA=E3=83=BC=E5=85=88=E7=94=9F?= Date: Wed, 19 Aug 2026 04:13:06 +0900 Subject: [PATCH 1/3] feat: Add specification documents for checkpoint management and tagging --- .gitignore | 1 - .spec/README.md | 20 ++++++++++ .spec/node-contracts.md | 46 ++++++++++++++++++++++ .spec/regression-matrix.md | 50 ++++++++++++++++++++++++ .spec/review-and-deletion.md | 52 +++++++++++++++++++++++++ .spec/selection-and-cycling.md | 47 ++++++++++++++++++++++ .spec/tag-transfer.md | 71 ++++++++++++++++++++++++++++++++++ 7 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 .spec/README.md create mode 100644 .spec/node-contracts.md create mode 100644 .spec/regression-matrix.md create mode 100644 .spec/review-and-deletion.md create mode 100644 .spec/selection-and-cycling.md create mode 100644 .spec/tag-transfer.md diff --git a/.gitignore b/.gitignore index 7f6128a..bf7f864 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,4 @@ data/checkpoint_favorites.json __pycache__/ *.pyc .vscode/ -.spec/ .ruff_cache/ \ No newline at end of file diff --git a/.spec/README.md b/.spec/README.md new file mode 100644 index 0000000..dd64767 --- /dev/null +++ b/.spec/README.md @@ -0,0 +1,20 @@ +# CheckpointHandpickerSuite specification index + +このディレクトリは、実装より先に守るべき現行仕様と退行条件を記録する。 +過去の開発版ごとの差分や実装ファイルの複製は置かず、現在有効な契約だけを正本とする。 + +- [node-contracts.md](node-contracts.md): ノード登録、入出力、状態とフロントエンドの境界 +- [selection-and-cycling.md](selection-and-cycling.md): Selector、Cycler、Local List、DirectLink +- [review-and-deletion.md](review-and-deletion.md): 評価、Preview、サムネイル、削除予約 +- [tag-transfer.md](tag-transfer.md): 評価JSONのExport / Import +- [regression-matrix.md](regression-matrix.md): 静的検証と手動回帰項目 + +## 変更時の原則 + +1. 挙動を変更する前に該当仕様を更新する。 +2. 仕様変更には、可能な限り対応する自動テストを追加する。自動化できないUI挙動は回帰マトリクスへ追加する。 +3. PythonとJavaScriptの静的検証を成功させる。 +4. 公開ノード名、ソケット、保存済みWorkflowとの互換性に影響する変更はREADMEとRELEASE_NOTESにも記載する。 +5. `.spec`には仕様文書だけを置き、`suite_nodes.py`や`web/checkpoint_handpicker_suite.js`を複製しない。 + +現在の対象バージョンは `0.3.0`。 diff --git a/.spec/node-contracts.md b/.spec/node-contracts.md new file mode 100644 index 0000000..bd3c5ed --- /dev/null +++ b/.spec/node-contracts.md @@ -0,0 +1,46 @@ +# Node and state contracts + +## 公開ノード + +| 登録名 | 表示名 | 主な入力 | 出力 | +|---|---|---|---| +| `CheckpointListSelector` | Checkpoint List Selector | `checkpoint` | `ckpt_name`, `ckpt_name_str`, `ckpt_name_safe` | +| `CheckpointNameCycler` | Checkpoint Name Cycler | `start_checkpoint`, `mode`, `change_every` | `ckpt_name`, `ckpt_name_str`, `ckpt_name_safe` | +| `CheckpointStatusTagger` | Checkpoint Status Tagger | `ckpt_name_str` | なし | +| `CheckpointTagExportImport` | Checkpoint Tag Export / Import | `tag_transfer_directory` UI | なし | +| `EphemeralPreview` | Ephemeral Preview | `image` | なし | +| `ImageDirPreview` | ImageDir Preview | `ckpt_name_str`, `search_directory`, `max_preview_images` | なし | + +## Checkpoint名の3出力 + +SelectorとCyclerは、同じ相対Checkpointパスを用途別に3形式で公開する。 + +- `ckpt_name`: Checkpoint Loaderへ接続できるCombo互換出力。 +- `ckpt_name_str`: TaggerとImageDirPreviewへ接続するSTRING出力。 +- `ckpt_name_safe`: 拡張子とファイル名に不向きな文字を除去・置換したSTRING出力。 + +値が同じでも型と接続先が異なるため、`ckpt_name_str`を削除して`ckpt_name`へ統合してはならない。 +TaggerとImageDirPreviewの`ckpt_name_str`はSTRINGかつ`forceInput: True`を維持する。 +未接続時に先頭Checkpointや共有Preview状態から対象を推測してはならない。 + +## 状態の所有範囲 + +- Cycler、Tagger、Preview、実行中Checkpointの保持状態はブラウザタブIDとノードIDで分離する。 +- 通常のSelector同期、DirectLink、Tagger変更、Preview進捗は対象タブだけへ反映する。 +- Tag Importと、Refresh Allによる消失済み`delete`評価の解除は全タブへ反映する。 +- Cyclerの実行後スナップショットが、最後に実際に返したCheckpointの正本である。 +- EphemeralPreviewのCheckpoint名表示はタブ内の実行状態を利用するが、評価や削除対象の決定には使用しない。 + +## 永続データ + +- 評価DBは`data/checkpoint_statuses.json`へatomic保存する。 +- `favorite`互換データは`data/checkpoint_favorites.json`へ同期する。 +- `none`は評価DBからrecordを削除して表現する。 +- Runtime UI状態はバックエンドメモリにも保持され、Workflowに保存されたhidden widgetは初期化時の復元元となる。 + +## フロントエンド読み込み契約 + +- 実装は`web/checkpoint_handpicker_suite.js`だけを正本とする。 +- `WEB_DIRECTORY = "./web"`を維持する。 +- JavaScriptのComfyUI importは`../../scripts/app.js`と`../../scripts/api.js`を使用する。 +- 必須Comboへ`.`や空文字など、候補一覧に存在しないsentinelを残してQueue検証を失敗させてはならない。 diff --git a/.spec/regression-matrix.md b/.spec/regression-matrix.md new file mode 100644 index 0000000..a71b7bb --- /dev/null +++ b/.spec/regression-matrix.md @@ -0,0 +1,50 @@ +# Regression matrix + +## 静的検証 + +```powershell +$py = "C:\path\to\python.exe" +& $py -B -c "import ast, pathlib; [ast.parse(pathlib.Path(p).read_text(encoding='utf-8')) for p in ('suite_nodes.py', '__init__.py')]" +node --check web/checkpoint_handpicker_suite.js +git diff --check +``` + +現時点では専用の自動テストsuiteを持たない。ロジック変更時はComfyUI依存部を分離して自動テストを追加し、この表へ対応を記載する。 + +## 仕様と回帰確認の対応 + +| 保護対象 | 主な確認 | +|---|---| +| 6ノードの登録と表示名 | ComfyUI再起動後に全ノードがカテゴリへ表示される | +| 3出力の互換性 | Selector/CyclerからLoader、Tagger、ImageDirPreviewへ既存Workflowのまま接続できる | +| JS読み込み契約 | custom UIが表示され、hidden widgetが通常widgetとして露出しない | +| Selector 3モード | 接続先ごとにPush、Sync、DirectLinkの正確なボタンへ切り替わる | +| DirectLink安全性 | OFFではTaggerが追従せず、ON確認はページ内1回、ONはボタンだけactiveになる | +| Cycler runtime state | 保存・再読込・タブ移動後もmode、change_every、filter、Local List設定を維持する | +| 4つのCycler mode | fixed、increment、randomize、shuffle_onceが実行時の候補規則に従う | +| Local List | 重複を保持し、1実行で1件消費し、Clear/ON/OFFでCurrent Jobを変えない | +| status順序とdelete制約 | `👑 💛 👍 ✔ 🗑 —`を全UIで揃え、正評価から直接deleteへ変更できない | +| 削除script安全性 | `[y/N]`を維持し、対象Checkpoint群以外の画像を削除候補にしない | +| Refresh All cleanup | Explorer/scriptで消えたCheckpointの予約と`delete`評価を除き、正評価は保持する | +| EphemeralPreview | disk cacheを作らず、表示用実行状態が評価対象へ流用されない | +| ImageDirPreview | progress中も既存sheetを維持し、左クリックmenuとsession検証が機能する | +| 画像削除 | 確認後に元画像を削除し、tile位置を保った`NO IMAGE`表示が非操作になる | +| thumbnail設定 | 保守的なsidecarだけを作成/上書きし、Selector cacheを更新する | +| Tag Export | missingをFailed、異なる重複評価をAmbiguousにし、残りをatomic出力する | +| 最新Export選択 | 未来日時名を許可し、形式外を無視し、壊れた最新JSONからfallbackしない | +| Tag Import merge | noneだけを更新し、同値をUnchanged、異値をConflictとして現在値を守る | +| Import delete | 評価だけを復元し、予約queueと削除scriptを生成しない | +| Import UI同期 | Import直後に開いている全タブのSelector、Tagger、Preview、Cycler表示が更新される | +| 共有directory | 未作成custom pathをerrorにし、同時transferをBUSY、同時Exportを同名上書きしない | + +## Tag Transferの最小手動シナリオ + +1. default、既存local絶対path、既存UNC path、空白だけの入力を確認する。 +2. 存在しないcustom pathでExport/Importが失敗し、directory作成もdefault fallbackもしないことを確認する。 +3. evaluated、missing、同一identity同評価、同一identity異評価を含むDBからExportし、件数とJSONを確認する。 +4. Export名を未来日時へ変更してImport対象になること、形式外ファイルが無視されることを確認する。 +5. 最新JSONを壊し、古い正常JSONへfallbackしないことを確認する。 +6. none、同値、異値、missing、物理重複を含む環境へImportし、各ResultとDBを確認する。 +7. Import処理の前後でTaggerを操作し、後に完了したTagger指定が最終値になることを確認する。 +8. `delete`をImportして予約が作られないこと、OFF/ON後は通常予約が作られることを確認する。 +9. 複数ブラウザタブを開いた状態でImportし、手動reloadなしに全タブへ反映されることを確認する。 diff --git a/.spec/review-and-deletion.md b/.spec/review-and-deletion.md new file mode 100644 index 0000000..ae328df --- /dev/null +++ b/.spec/review-and-deletion.md @@ -0,0 +1,52 @@ +# Review, tagging, and deletion specification + +## Checkpoint評価 + +評価値と表示順は次のとおり。 + +1. `god` / `👑 god!` +2. `favorite` / `💛 favorite` +3. `nice` / `👍 nice` +4. `keep` / `✔ keep` +5. `delete` / `🗑 delete` +6. `none` / `— none` + +Taggerは`none`ボタンを表示しない。現在選択中の評価ボタンをもう一度押すと`none`へ戻る。正の評価同士は直接変更できるが、`delete`は現在値が`none`または`delete`のときだけ操作できる。したがって正の評価から削除予約するには、いったん現在の評価をOFFにする。 + +Taggerは明示された`ckpt_name_str`だけを評価する。未接続時、およびSelector直結でDirectLink OFFのときは操作対象を持たない。評価変更は同じタブのSelector、Tagger、Preview、Cycler状態へ反映する。 + +## Checkpoint削除予約 + +`delete`評価は即時削除ではない。通常のTagger操作で`delete`をONにしたときだけ予約recordを追加し、次を`output/CheckpointHandpickerSuite/delete_scripts/`へ生成する。 + +- `checkpoint_delete_queue.jsonl` +- `delete_reserved_checkpoints.py` +- `checkpoint_delete_plan.txt` + +生成scriptはCheckpoint単位で`[y/N]`確認を行う。評価を`delete`以外へ戻した場合は有効な予約をcancelし、scriptを更新する。 + +削除候補は対象Checkpoint、その対応JSON、および同一ディレクトリの保守的なsidecar thumbnailに限定する。thumbnail拡張子は`.jpg`, `.jpeg`, `.png`, `.webp`とし、曖昧な部分一致検索、ImageDirPreview検索結果、review/output画像をCheckpoint削除対象にしてはならない。 + +Refresh Allでは、物理Checkpointが存在しない予約をcancelする。予約recordの有無にかかわらず、物理Checkpointが存在しない`delete`評価もDBから除去し、以後のExportで永続的なFailedにならないようにする。消失した`god`, `favorite`, `nice`, `keep`評価はバックアップ用途のため保持する。 + +## Ephemeral Preview + +入力画像batchからメモリ上でcontact sheetを作り、ディスクへpreview cacheを書かずブラウザへ送る。最後のCycler実行状態が同じタブにあればCheckpoint名と評価を表示する。この表示用状態をTaggerや削除対象の決定へ使用しない。 + +## ImageDir Preview + +- `ckpt_name_str`未接続時は自力でCheckpointを選ばずinactiveとする。 +- `search_directory`が空の場合はComfyUI outputを検索する。 +- 対応画像は`.png`, `.jpg`, `.jpeg`, `.webp`。 +- 再帰検索は一致候補3,000件で打ち切り、更新日時順から最大`max_preview_images`件を表示する。 +- Selector Syncの重い読み込みはevent loop外で実行し、進捗通知だけで既存contact sheetを消さない。 +- 表示した`source_paths`、layout、`preview_session_id`をbackend stateへ保存し、操作時にsessionを検証する。 + +生きているtileをhoverすると枠を描き、左クリックで次の順序のmenuを開く。右クリックはComfyUI本来の動作を変更しない。 + +1. `Delete image` +2. `Set as checkpoint thumbnail` + +`Delete image`は確認後に元画像を直接削除する。現在のtile位置は詰めず、`deleted_indices`へ記録して`NO IMAGE`を重ね、以後hoverとmenuを無効にする。 + +`Set as checkpoint thumbnail`は選択画像から、既存の保守的なsidecarを上書きする。存在しなければ`.jpg`を作成し、Selectorのthumbnail cacheを無効化する。Checkpoint評価やLocal Listは変更しない。 diff --git a/.spec/selection-and-cycling.md b/.spec/selection-and-cycling.md new file mode 100644 index 0000000..652cf25 --- /dev/null +++ b/.spec/selection-and-cycling.md @@ -0,0 +1,47 @@ +# Selection and cycling specification + +## Checkpoint List Selector + +SelectorはCheckpoint一覧、評価アイコン、現在選択、スクロール操作を表示する。主操作は接続先によって変わる。 + +| 接続状態 | ボタン | 動作 | +|---|---|---| +| Review対象なし | `🏹 Push to Local List` | 選択をCyclerのLocal Listへ追加する | +| ImageDirPreviewあり | `🎯 Sync Checkpoint` | TaggerとImageDirPreviewを明示的に同期する | +| Taggerのみ | `🌑 DirectLink OFF` / `🌕 DirectLink ON` | 外部画像レビュー用の追従を切り替える | + +ImageDirPreviewとTaggerの両方が接続されている場合はSyncを優先する。`Push to Local List`を短縮せず、通常UIへ`List Only`ボタンを戻さない。 + +DirectLinkは初期状態OFFとする。ONへ切り替える確認はブラウザページごとに最初の1回だけ表示し、キャンセル時も表示済みとする。ONでは選択変更にTaggerが追従し、OFFではSelector由来のTagger対象を持たない。ONの表示はボタンだけをactive表示し、Selector全体を緑枠にしない。 + +一覧行のhoverでは、Checkpointと同じディレクトリにある保守的なsidecar thumbnailだけを表示する。スクロール、ノード外への移動、Refresh Allで古いpopup/cacheを消す。ImageDirPreviewの検索先やComfyUI outputをthumbnail探索に流用しない。 + +## Refresh All + +Refresh AllはComfyUIのCheckpoint一覧cacheを更新し、HandpickerSuiteと既知の標準Checkpoint widgetを現在存在する候補へ同期する。必須Comboの現在値が消失していれば、実在する候補へ補正する。 + +Refreshは削除予約の整理と、物理ファイルが消失した`delete`評価の解除も行う。詳細は[review-and-deletion.md](review-and-deletion.md)を参照する。 + +## Checkpoint Name Cycler + +CyclerはQueue登録時ではなく、ノードの実行時に最新のバックエンド状態からCheckpointを解決する。初回はWorkflowに保存された値をruntime stateへ取り込み、以後は新しいsettings revisionを持つ更新だけを採用する。 + +- `fixed`: `start_checkpoint`を返す。通常のstatus filterは選択を変えない。 +- `increment`: 全Checkpointの順序を進み、現在のfilterに一致しない項目を飛ばす。 +- `randomize`: 現在の候補から毎回ランダム選択し、重複を許す。 +- `shuffle_once`: 全Checkpointからglobal deckを作り、順に消費する。実行時のfilterに不一致の項目も消費し、deckが空なら再構築する。 + +`change_every`回は同じ通常選択を保持する。実行結果のtitle、Current Job、status iconは最後に実際に返したCheckpointから再構築し、フロントエンドのstatus通知で既存titleへiconを継ぎ足さない。 + +status filterの順序は`all`, `👑 god`, `💛 favorite`, `👍 nice`, `✔ keep`, `🗑 delete`, `— none`。filter一致が0件のときは全Checkpointへfallbackし、その事実を状態表示へ出す。 + +## Local List + +- Local Listはsetではなく順序付きlistで、重複を許す。 +- ONかつ空でない場合、通常モードより優先し、1実行につき先頭1件を消費する。 +- Local List中は`change_every`で保持せず、1件ずつ進む。 +- Local Listの消費は`shuffle_once`のglobal deckを進めない。 +- 空の場合は通常のCycler候補へ戻る。 +- Push、Clear、ON/OFFは、最後に実行したCheckpoint名・source・Current Jobを書き換えない。 +- 0件時に`Local List Remaining: 0`を表示せず、`Queue`という名称を使わない。 +- status表示ではShuffle DeckをLocal Listより先に置き、Local List項目番号1〜9を2桁幅で揃える。 diff --git a/.spec/tag-transfer.md b/.spec/tag-transfer.md new file mode 100644 index 0000000..11e6ade --- /dev/null +++ b/.spec/tag-transfer.md @@ -0,0 +1,71 @@ +# Checkpoint Tag Export / Import specification + +## ノードと保存先 + +`Checkpoint Tag Export / Import`はQueueへ参加しない独立管理ノードで、接続用socketを持たない。UIは次を表示する。 + +- `Tag Transfer Directory (full path only / network paths OK)` +- 空欄時のplaceholder `Default: output/CheckpointHandpickerSuite/` +- `📤 Export` +- `📥 Import` +- 件数、詳細、エラーを表示できるscroll可能なResult領域 + +入力値はtrimする。空なら`output/CheckpointHandpickerSuite/`を使用し、Export時だけ必要に応じて作成する。空でない値は既存の絶対ディレクトリでなければエラーとする。ローカル絶対パスとUNC/network pathを許可するが、custom directoryは自動作成せず、defaultへfallbackしない。ExportとImportは指定directory直下だけを扱う。 + +## Export形式 + +ファイル名は`checkpoint_tags_YYYYMMDD_HHMMSS.json`。同一秒に衝突する場合は数値suffixを付ける。document形式は次のとおり。 + +```json +{ + "format_version": 1, + "exported_at": "2026-08-19T12:34:56+09:00", + "evaluations": [ + { + "file_name": "modelA.safetensors", + "file_size": 6847213568, + "tag": "favorite" + } + ] +} +``` + +portable identityは大文字小文字を含む正確な`file_name + file_size`とする。directory、drive、relative path、Checkpoint更新日時は出力しない。対象は`none`以外の評価済み`.safetensors`だけとする。 + +- 評価DBのrelative pathから物理Checkpointが見つからないrecordはFailedとしてスキップする。 +- 同じrelative pathが複数rootで異なるfile sizeへ解決される場合はAmbiguousとしてスキップする。 +- 同じidentityに同じ評価が複数あれば1recordへ統合する。 +- 同じidentityに異なる評価が複数あればAmbiguousとして出力しない。 +- 他recordのFailed/AmbiguousでExport全体を中止しない。 + +評価DBは処理開始時にsnapshotを取り、その後のTagger変更はそのExportへ含めない。JSONは同じdirectoryの一意な一時ファイルへ完全に書き、flush後に正式名へ置換する。短時間の予約ファイルにより、共有directory上の同名上書きとpartial JSON公開を防ぐ。 + +## Import対象の選択 + +対象名は`checkpoint_tags_YYYYMMDD_HHMMSS.json`または数値suffix付きだけとする。形式外ファイルは無視し、日時文字列とsuffix数値の組が最大のファイルを1件選ぶ。 + +日時が実在するか、現在以前かは検証しない。したがってファイル名を未来の日時文字列へ変更すれば、そのファイルを優先できる。選択した最新JSONが不正でも、古いExportへfallbackせずImportを中止する。 + +`format_version`は整数`1`だけを受理する。document rootや`evaluations`自体が不正ならImportを中止する。個別recordが不正ならFailedとしてスキップする。同じidentityに異なるtagが含まれる場合はAmbiguousとしてスキップし、同一tagの重複は1件へまとめる。 + +## 現環境との照合とmerge + +現環境の`.safetensors`を全Checkpoint rootから走査し、exactな`file_name + file_size`で照合する。 + +| 状態 | Result | DB更新 | +|---|---|---| +| 一致なし | Missing | なし | +| 複数の物理Checkpointが一致 | Ambiguous | なし | +| 現在評価が`none` | Imported | Import評価を設定 | +| 現在評価とImport評価が同じ | Unchanged | なし | +| 現在評価とImport評価が異なる | Conflict | 現在評価を維持 | + +遅いJSON検証とCheckpoint走査が終わった後、評価DBを1回読み、全recordをmergeし、変更があれば1回だけatomic保存する。通常Tagger書き込みとはlockを共有しないが、commitはawaitを挟まない同期区間とする。このためcommitより前に完了したTagger評価はImport対象外またはConflictになり、commitより後のTagger評価はImport結果を上書きする。常に操作順で後のTagger指定が優先される。 + +ExportとImport同士は同時実行せず、別のtransfer処理が実行中なら`BUSY`を返す。 + +## Import後の反映とdelete + +Imported recordだけをbackendのTagger、Preview、Cycler、tab execution stateへ同期し、global eventで開いている全ブラウザタブのHandpickerSuite UIへ直ちに反映する。ユーザーによる手動reloadを要求しない。 + +Importされた`delete`は論理評価だけを復元し、削除予約queueや削除scriptを作らない。後から削除予約が必要ならTaggerで`delete`を一度OFFにしてからONにする。物理Checkpointが既に存在しない`delete`評価はRefresh Allで除去する。 From 3fb0488abfb63ad0358a884ae2c3cafed0d58c5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=8B=E3=81=BF=E3=81=AA=E3=83=BC=E5=85=88=E7=94=9F?= Date: Wed, 19 Aug 2026 19:23:02 +0900 Subject: [PATCH 2/3] feat: Update node categories to reflect HandpickerSuite structure and enhance menu hierarchy --- .spec/node-contracts.md | 15 +++++++++++++++ suite_nodes.py | 12 ++++++------ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/.spec/node-contracts.md b/.spec/node-contracts.md index bd3c5ed..b6e22d8 100644 --- a/.spec/node-contracts.md +++ b/.spec/node-contracts.md @@ -11,6 +11,21 @@ | `EphemeralPreview` | Ephemeral Preview | `image` | なし | | `ImageDirPreview` | ImageDir Preview | `ckpt_name_str`, `search_directory`, `max_preview_images` | なし | +## メニュー階層 + +通常ノードは`HandpickerSuite`直下、Preview系だけは`HandpickerSuite/Preview`へ登録する。 + +```text +HandpickerSuite +├─ Checkpoint List Selector +├─ Checkpoint Name Cycler +├─ Checkpoint Status Tagger +├─ Checkpoint Tag Export / Import +└─ Preview + ├─ Ephemeral Preview + └─ ImageDir Preview +``` + ## Checkpoint名の3出力 SelectorとCyclerは、同じ相対Checkpointパスを用途別に3形式で公開する。 diff --git a/suite_nodes.py b/suite_nodes.py index a039870..aae118f 100644 --- a/suite_nodes.py +++ b/suite_nodes.py @@ -2136,7 +2136,7 @@ class CheckpointListSelector: RETURN_TYPES = (_get_checkpoint_list() or [""], "STRING", "STRING") RETURN_NAMES = ("ckpt_name", "ckpt_name_str", "ckpt_name_safe") FUNCTION = "select" - CATEGORY = "checkpoint/handpicker" + CATEGORY = "HandpickerSuite" def select(self, checkpoint="", unique_id=None): rel = _normalize_relpath(checkpoint) @@ -2168,7 +2168,7 @@ class CheckpointNameCycler: RETURN_TYPES = (_get_checkpoint_list() or [""], "STRING", "STRING") RETURN_NAMES = ("ckpt_name", "ckpt_name_str", "ckpt_name_safe") FUNCTION = "cycle" - CATEGORY = "checkpoint/handpicker" + CATEGORY = "HandpickerSuite" @classmethod def IS_CHANGED(cls, start_checkpoint, mode="increment", change_every=1, hps_tab_id="", hps_filter_statuses="", hps_use_local_list="", hps_settings_revision="0", unique_id=None): @@ -2467,7 +2467,7 @@ class CheckpointStatusTagger: RETURN_TYPES = () FUNCTION = "tag" - CATEGORY = "checkpoint/handpicker" + CATEGORY = "HandpickerSuite" OUTPUT_NODE = True @classmethod @@ -2503,7 +2503,7 @@ class CheckpointTagExportImport: RETURN_TYPES = () FUNCTION = "noop" - CATEGORY = "checkpoint/handpicker" + CATEGORY = "HandpickerSuite" OUTPUT_NODE = False def noop(self, tag_transfer_directory=""): @@ -2521,7 +2521,7 @@ class EphemeralPreview: RETURN_TYPES = () FUNCTION = "preview" - CATEGORY = "checkpoint/handpicker/preview" + CATEGORY = "HandpickerSuite/Preview" OUTPUT_NODE = True @classmethod @@ -2569,7 +2569,7 @@ class ImageDirPreview: RETURN_TYPES = () FUNCTION = "preview" - CATEGORY = "checkpoint/handpicker/preview" + CATEGORY = "HandpickerSuite/Preview" OUTPUT_NODE = True @classmethod From 15f1615398889631407cb0606b97a9eaf72f753d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=8B=E3=81=BF=E3=81=AA=E3=83=BC=E5=85=88=E7=94=9F?= Date: Tue, 25 Aug 2026 21:45:55 +0900 Subject: [PATCH 3/3] feat: Add regression tests workflow and enhance documentation for version 0.3.1 --- .github/workflows/regression-tests.yml | 28 ++++ .spec/README.md | 2 +- .spec/regression-matrix.md | 16 ++- README.md | 4 +- RELEASE_NOTES.md | 35 +++++ doc/CheckpointListSelector.md | 10 +- doc/CheckpointNameCycler.md | 2 +- doc/CheckpointStatusTagger.md | 6 +- doc/EphemeralPreview.md | 8 +- doc/HOW_TO_USE.ja.md | 6 +- doc/ImageDirPreview.md | 2 +- doc/README.ja.md | 4 +- pyproject.toml | 2 +- suite_nodes.py | 10 +- tests/test_suite_nodes.py | 181 +++++++++++++++++++++++++ 15 files changed, 291 insertions(+), 25 deletions(-) create mode 100644 .github/workflows/regression-tests.yml create mode 100644 tests/test_suite_nodes.py diff --git a/.github/workflows/regression-tests.yml b/.github/workflows/regression-tests.yml new file mode 100644 index 0000000..ed6ba0a --- /dev/null +++ b/.github/workflows/regression-tests.yml @@ -0,0 +1,28 @@ +name: Regression tests + +on: + push: + branches: [develop, main] + pull_request: + branches: [develop, main] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + - uses: actions/setup-node@v4 + with: + node-version: "20" + - name: Run Python unit tests + run: python -B -m unittest discover -s tests -v + - name: Check Python syntax + run: python -B -c "import ast, pathlib; [ast.parse(pathlib.Path(p).read_text(encoding='utf-8')) for p in ('suite_nodes.py', '__init__.py', 'tests/test_suite_nodes.py')]" + - name: Check JavaScript syntax + run: node --check web/checkpoint_handpicker_suite.js diff --git a/.spec/README.md b/.spec/README.md index dd64767..d0f7062 100644 --- a/.spec/README.md +++ b/.spec/README.md @@ -17,4 +17,4 @@ 4. 公開ノード名、ソケット、保存済みWorkflowとの互換性に影響する変更はREADMEとRELEASE_NOTESにも記載する。 5. `.spec`には仕様文書だけを置き、`suite_nodes.py`や`web/checkpoint_handpicker_suite.js`を複製しない。 -現在の対象バージョンは `0.3.0`。 +現在の対象バージョンは `0.3.1`。 diff --git a/.spec/regression-matrix.md b/.spec/regression-matrix.md index a71b7bb..25c3ee7 100644 --- a/.spec/regression-matrix.md +++ b/.spec/regression-matrix.md @@ -4,25 +4,29 @@ ```powershell $py = "C:\path\to\python.exe" -& $py -B -c "import ast, pathlib; [ast.parse(pathlib.Path(p).read_text(encoding='utf-8')) for p in ('suite_nodes.py', '__init__.py')]" +& $py -B -m unittest discover -s tests +& $py -B -c "import ast, pathlib; [ast.parse(pathlib.Path(p).read_text(encoding='utf-8')) for p in ('suite_nodes.py', '__init__.py', 'tests/test_suite_nodes.py')]" node --check web/checkpoint_handpicker_suite.js git diff --check ``` -現時点では専用の自動テストsuiteを持たない。ロジック変更時はComfyUI依存部を分離して自動テストを追加し、この表へ対応を記載する。 +`tests/test_suite_nodes.py`はComfyUI依存をstubし、Cyclerの候補選択と公開ノード契約を単体で検証する。今後のロジック変更でも、可能な部分は同じように自動テストを追加する。 + +GitHub Actionsでは`.github/workflows/regression-tests.yml`がunit test、Python構文、JavaScript構文を同様に検証する。 ## 仕様と回帰確認の対応 | 保護対象 | 主な確認 | |---|---| -| 6ノードの登録と表示名 | ComfyUI再起動後に全ノードがカテゴリへ表示される | -| 3出力の互換性 | Selector/CyclerからLoader、Tagger、ImageDirPreviewへ既存Workflowのまま接続できる | +| 6ノードの登録と表示名 | `test_public_node_mappings_are_stable`とComfyUI再起動後のメニュー表示で確認する | +| メニュー階層 | `test_node_menu_categories`で通常4ノードとPreview 2ノードのcategoryを固定する | +| 3出力と終端ノードの互換性 | `test_documented_output_contracts`と既存Workflowの接続確認で保護する | | JS読み込み契約 | custom UIが表示され、hidden widgetが通常widgetとして露出しない | | Selector 3モード | 接続先ごとにPush、Sync、DirectLinkの正確なボタンへ切り替わる | | DirectLink安全性 | OFFではTaggerが追従せず、ON確認はページ内1回、ONはボタンだけactiveになる | | Cycler runtime state | 保存・再読込・タブ移動後もmode、change_every、filter、Local List設定を維持する | -| 4つのCycler mode | fixed、increment、randomize、shuffle_onceが実行時の候補規則に従う | -| Local List | 重複を保持し、1実行で1件消費し、Clear/ON/OFFでCurrent Jobを変えない | +| 4つのCycler mode | `test_suite_nodes.py`でfixedのFilter無視、increment、randomize、shuffle_onceの候補規則を検証する | +| Local List | `test_local_list_overrides_fixed_and_ignores_filter`に加え、重複・Clear/ON/OFFはComfyUI上で確認する | | status順序とdelete制約 | `👑 💛 👍 ✔ 🗑 —`を全UIで揃え、正評価から直接deleteへ変更できない | | 削除script安全性 | `[y/N]`を維持し、対象Checkpoint群以外の画像を削除候補にしない | | Refresh All cleanup | Explorer/scriptで消えたCheckpointの予約と`delete`評価を除き、正評価は保持する | diff --git a/README.md b/README.md index a422ebf..a2a774b 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,8 @@ Restart ComfyUI. - Ephemeral Preview - ImageDir Preview +The first four nodes are under `HandpickerSuite` in the node menu. Preview nodes are under `HandpickerSuite / Preview`. + ## Basic review workflows ### 1. Batch-generation review @@ -210,7 +212,7 @@ The generated script asks for confirmation before deleting files. ## Ephemeral Preview -`Ephemeral Preview` shows all images from the incoming `IMAGE` batch. It does not drop batch items. +`Ephemeral Preview` shows every image in the incoming `IMAGE` batch. It is a terminal preview node and has no output socket; branch the source `IMAGE` connection if the images must also continue to another node. When it receives images after a `Checkpoint Name Cycler` execution, it can display the checkpoint name from the tab-local execution state as: diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 24c4dc4..482a6db 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,38 @@ +# ComfyUI-CheckpointHandpickerSuite 0.3.1 + +ComfyUI-CheckpointHandpickerSuite 0.3.1 is a maintenance release for Cycler correctness, node-menu organization, and documentation accuracy. + +## Changes + +### Fixed mode now ignores status filters + +`Checkpoint Name Cycler` in `fixed` mode now always uses its selected `start_checkpoint`, regardless of the active status Filter. `Local List` remains the only intentional temporary override of fixed mode. + +This restores the documented normal-checkpoint-selector behavior and prevents an unrelated Filter from silently selecting another checkpoint. + +### Shallower node menu + +The four main nodes now appear directly under `HandpickerSuite`. `Ephemeral Preview` and `ImageDir Preview` appear under `HandpickerSuite / Preview`. + +Node registration names and sockets are unchanged, so existing workflows continue to load without reconnecting nodes. + +### Documentation and regression coverage + +- Corrected the documented outputs of `Checkpoint Status Tagger` and `Ephemeral Preview`; both are terminal nodes without output sockets. +- Corrected the documented three-output interface of `Checkpoint List Selector`. +- Added executable regression tests and GitHub Actions coverage for all four Cycler modes, the Local List override contract, public node registration, menu categories, and output contracts. +- Added the current behavior specifications under `.spec/` to version control. + +## Compatibility + +Existing workflow node IDs, registration names, inputs, and outputs are unchanged. Only node-menu placement has changed. + +## Upgrade notes + +Update the custom node and restart ComfyUI. Existing workflow nodes do not need to be recreated. + +--- + # ComfyUI-CheckpointHandpickerSuite 0.3.0 ComfyUI-CheckpointHandpickerSuite 0.3.0 adds portable backup and restore for checkpoint evaluations. diff --git a/doc/CheckpointListSelector.md b/doc/CheckpointListSelector.md index 18eb0f6..8e30df3 100644 --- a/doc/CheckpointListSelector.md +++ b/doc/CheckpointListSelector.md @@ -1,4 +1,4 @@ -# 💜 Checkpoint List Selector (頭脳 v0.2.0) +# 💜 Checkpoint List Selector (頭脳 v0.3.1) `Checkpoint List Selector` は、リポジトリ内に存在するすべてのCheckpointを一手に統括し、状況に応じてその役割を **「3変化(サントランスフォーム)」** させる、我がシャドウガーデンの超絶進化した司令塔じゃ! @@ -14,14 +14,18 @@ v0.2.0の特大アップデートにより、視覚的な格付け機能や、iP ### 📥 入力(Inputs) -* **`checkpoint` (必須 / COMBO):** +* **`checkpoint` (必須 / STRING・カスタム一覧UI):** 管理・検分対象とするCheckpointをリストから選択する。 * *※v0.2.0のデラックス強化により、マウスホイールでの爆速スクロール、上下の矢印ボタン、そしてスクロールバーのダイレクトドラッグ掴みまで、狂おしいほど快適なUI操作をフルサポートしておるぞ!* ### 📤 出力(Outputs) +* **`ckpt_name` (COMBO互換):** +Checkpoint Loaderへ接続するための相対Checkpointパス。 * **`ckpt_name_str` (STRING):** -現在選択されているCheckpointのファイルパス文字列。 +TaggerやImageDir Previewへ接続するための同じ相対Checkpointパス。 +* **`ckpt_name_safe` (STRING):** +画像保存時のlabelなどに使える、ファイル名として安全化した文字列。 * *※この出力をどこへ「繋ぐか・繋がないか」によって、この司令塔のアビリティが3つの姿へと動的に変身(トグル)する仕様じゃ!* --- diff --git a/doc/CheckpointNameCycler.md b/doc/CheckpointNameCycler.md index 1868922..f9d1934 100644 --- a/doc/CheckpointNameCycler.md +++ b/doc/CheckpointNameCycler.md @@ -1,4 +1,4 @@ -# 🤎 Checkpoint Name Cycler (心臓 v0.2.0) +# 🤎 Checkpoint Name Cycler (心臓 v0.3.1) `Checkpoint Name Cycler` は、本Suiteの「動力を司る心臓」じゃ。 大量の生成ジョブをバックグラウンドで走らせながら、Checkpoint(.safetensors)を指定したルールに基づいて全自動で切り替え、KSamplerへインジェクション(注入)し続ける役割を担っておる。 diff --git a/doc/CheckpointStatusTagger.md b/doc/CheckpointStatusTagger.md index c8dfd1f..40a4e6b 100644 --- a/doc/CheckpointStatusTagger.md +++ b/doc/CheckpointStatusTagger.md @@ -1,4 +1,4 @@ -# 💛 Checkpoint Status Tagger (右手 v0.2.0) +# 💛 Checkpoint Status Tagger (右手 v0.3.1) `Checkpoint Status Tagger` は、出力された画像を見ながら、そのモデルに対して冷徹かつ瞬時に格付け(ステータスタグ打ち)を行う「シャドウガーデンの執行官」じゃ。 @@ -21,8 +21,8 @@ v0.2.0のアップデートでは、ついに最上位の絶対神ステータ ### 📤 出力(Outputs) -* **`ckpt_name_str` (STRING):** -入力されたパスをそのまま下流へスルー(パススルー)する出力じゃ。 +* 出力ソケットはない。 +* 評価を保存する終端ノードとして使用する。 --- diff --git a/doc/EphemeralPreview.md b/doc/EphemeralPreview.md index f08e2b7..96e0162 100644 --- a/doc/EphemeralPreview.md +++ b/doc/EphemeralPreview.md @@ -1,4 +1,4 @@ -# 💙 Ephemeral Preview (泡沫プレビュー v0.2.0) +# 💙 Ephemeral Preview (泡沫プレビュー v0.3.1) `Ephemeral Preview` は、現在バックグラウンドで爆走している生成ラインの中間画像を受け取り、リアルタイムで画面に表示するための「シャドウガーデンの網膜(目)」じゃ。 @@ -12,15 +12,15 @@ ### 📥 入力(Inputs) -* **`images` (必須 / IMAGE):** +* **`image` (必須 / IMAGE):** ワークフローの中間(`VAE Decode` の直後など)から出力される画像バッチ。 * **`hps_tab_id` (非表示 / STRING):** ブラウザのタブを識別し、プレビュー画像を「今それを実行している正しいタブ」だけに正確に届ける(時空隔離)ための固有ID。 ### 📤 出力(Outputs) -* **`images` (IMAGE):** -入力された画像バッチをそのまま下流(`SaveImage` や `Upscale` など)へ1枚もドロップすることなくスルー(パススルー)する出力じゃ。 +* 出力ソケットはない。 +* 下流でも同じ画像を使う場合は、上流の`IMAGE`出力を分岐して接続する。 --- diff --git a/doc/HOW_TO_USE.ja.md b/doc/HOW_TO_USE.ja.md index a6dce72..daabf17 100644 --- a/doc/HOW_TO_USE.ja.md +++ b/doc/HOW_TO_USE.ja.md @@ -1,4 +1,4 @@ -# 📖 HOW_TO_USE.ja.md (大祭囃子・のじゃのじゃ確定版 v0.3.0) +# 📖 HOW_TO_USE.ja.md (大祭囃子・のじゃのじゃ確定版 v0.3.1) # 🖥️ 使用方法(How to Use) @@ -13,12 +13,14 @@ ```bash cd ComfyUI/custom_nodes -git clone [https://github.com/ruminar/ComfyUI-CheckpointHandpickerSuite.git](https://github.com/ruminar/ComfyUI-CheckpointHandpickerSuite.git) +git clone https://github.com/ruminar/ComfyUI-CheckpointHandpickerSuite.git ``` クローンが完了したら、ComfyUIを再起動して祭りの準備を整えられよ! +通常の4ノードはノードメニューの`HandpickerSuite`直下、`Ephemeral Preview`と`ImageDir Preview`は`HandpickerSuite / Preview`に表示されるぞ。 + --- ## 🎼 一番簡単な使い方:【Checkpoint 巡回モード】 diff --git a/doc/ImageDirPreview.md b/doc/ImageDirPreview.md index 9958515..89776e0 100644 --- a/doc/ImageDirPreview.md +++ b/doc/ImageDirPreview.md @@ -1,4 +1,4 @@ -# 💙 ImageDir Preview (画像ディレクトリ参照プレビュー v0.2.0) +# 💙 ImageDir Preview (画像ディレクトリ参照プレビュー v0.3.1) `ImageDir Preview` は、指定されたディレクトリから「特定のモデルが過去に出力した画像」だけを爆速で逆探知し、キャンバス上へ美しい一覧シートとして展開する「シャドウガーデンの超探知眼(目)」じゃ。 diff --git a/doc/README.ja.md b/doc/README.ja.md index 22bf0a3..69bd2b2 100644 --- a/doc/README.ja.md +++ b/doc/README.ja.md @@ -1,4 +1,4 @@ -# 🧬 ComfyUI-CheckpointHandpickerSuite (日本語版聖典 v0.3.0) +# 🧬 ComfyUI-CheckpointHandpickerSuite (日本語版聖典 v0.3.1) > 「**ディスク容量が節約できる保証はありません。なぜなら、あなたが空いた容量に歓喜して、明日さらに多くのCheckpointをダウンロードしてしまうからです。**」 @@ -62,6 +62,8 @@ v0.2.0の特大アップデートで、ついに「失敗画像の直接削除 我がリポジトリを支える、シャドウガーデンの序列(4人の看板娘たちと5つの魔導具)を紹介しよう。それぞれが固有の役割を持ち、おぬしのComfyUI(肉体)と脳内で非同期にリンクしておるのじゃ! +通常の4ノードは`HandpickerSuite`直下、Preview系2ノードは`HandpickerSuite / Preview`から追加できる。 + ### 1. 🤎 [Checkpoint Name Cycler(心臓)](CheckpointNameCycler.md) バックグラウンドで何百件ものジョブをノンストップで爆走させるための動力源じゃ。 diff --git a/pyproject.toml b/pyproject.toml index e66b4cf..d087020 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "checkpoint-handpicker-suite" -version = "0.3.0" +version = "0.3.1" description = "A deluxe checkpoint jukebox and review desk for ComfyUI. Review-first checkpoint cycling and handpicking suite: non-invasive checkpoint switcher, rotation/cycler helper, random checkpoint selector, and cleanup workflow without replacing Load Checkpoint." license = { file = "LICENSE" } readme = "README.md" diff --git a/suite_nodes.py b/suite_nodes.py index aae118f..1ea81b5 100644 --- a/suite_nodes.py +++ b/suite_nodes.py @@ -2230,7 +2230,15 @@ class CheckpointNameCycler: global_candidates, fallback_all, global_match_count = _candidate_checkpoints(active_filter) local_items = _valid_local_list(state.get("local_list", [])) use_local_source = use_local_list and bool(local_items) - candidates = local_items if use_local_source else global_candidates + # fixed mode is a normal checkpoint selector and deliberately ignores + # status filters. Local List remains the only source allowed to override + # its selected start checkpoint. + if use_local_source: + candidates = local_items + elif mode == "fixed": + candidates = all_checkpoints + else: + candidates = global_candidates if not candidates: candidates = all_checkpoints fallback_all = True diff --git a/tests/test_suite_nodes.py b/tests/test_suite_nodes.py new file mode 100644 index 0000000..0aa709b --- /dev/null +++ b/tests/test_suite_nodes.py @@ -0,0 +1,181 @@ +import importlib.util +import json +import sys +import tempfile +import types +import unittest +from pathlib import Path +from unittest.mock import patch + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +class _FakeRoutes: + def get(self, _path): + return lambda function: function + + def post(self, _path): + return lambda function: function + + +def _load_suite_nodes(): + folder_paths = types.ModuleType("folder_paths") + folder_paths.filename_list_cache = {} + folder_paths.supported_pt_extensions = {".safetensors"} + folder_paths.get_output_directory = lambda: tempfile.gettempdir() + folder_paths.get_filename_list = lambda _kind: ["a.safetensors"] + folder_paths.get_folder_paths = lambda _kind: [] + + numpy = types.ModuleType("numpy") + numpy.ndarray = type("ndarray", (), {}) + numpy.uint8 = int + + pil = types.ModuleType("PIL") + pil_image = types.ModuleType("PIL.Image") + pil_image.Image = type("Image", (), {}) + pil_image.Resampling = types.SimpleNamespace(LANCZOS=1) + pil_image_ops = types.ModuleType("PIL.ImageOps") + pil.Image = pil_image + pil.ImageOps = pil_image_ops + + aiohttp = types.ModuleType("aiohttp") + web = types.ModuleType("aiohttp.web") + web.json_response = lambda payload, status=200: {"payload": payload, "status": status} + aiohttp.web = web + + server = types.ModuleType("server") + + class PromptServer: + instance = types.SimpleNamespace( + routes=_FakeRoutes(), + client_id=None, + send_sync=lambda *_args, **_kwargs: None, + ) + + server.PromptServer = PromptServer + + stubs = { + "folder_paths": folder_paths, + "numpy": numpy, + "PIL": pil, + "PIL.Image": pil_image, + "PIL.ImageOps": pil_image_ops, + "aiohttp": aiohttp, + "aiohttp.web": web, + "server": server, + } + module_name = "checkpoint_handpicker_suite_nodes_under_test" + spec = importlib.util.spec_from_file_location(module_name, REPO_ROOT / "suite_nodes.py") + module = importlib.util.module_from_spec(spec) + with patch.dict(sys.modules, stubs): + sys.modules[module_name] = module + try: + spec.loader.exec_module(module) + finally: + sys.modules.pop(module_name, None) + return module + + +class CyclerModeTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.suite = _load_suite_nodes() + + def setUp(self): + self.checkpoints = ["a.safetensors", "b.safetensors", "c.safetensors"] + self.statuses = { + "a.safetensors": "god", + "b.safetensors": "none", + "c.safetensors": "favorite", + } + self.suite._CYCLER_STATES.clear() + self.suite._TAB_EXECUTION_STATES.clear() + self.suite._get_checkpoint_list = lambda: list(self.checkpoints) + self.suite._get_status = lambda relpath: self.statuses.get(relpath, "none") + + def cycle(self, *, start="a.safetensors", mode="increment", statuses=None, use_local_list=False): + return self.suite.CheckpointNameCycler().cycle( + start, + mode=mode, + change_every=1, + hps_tab_id="test-tab", + hps_filter_statuses=json.dumps(statuses or []), + hps_use_local_list="true" if use_local_list else "false", + hps_settings_revision="1", + unique_id=1, + )[0] + + def cycler_state(self): + return self.suite._get_cycler_state(self.suite._state_key("test-tab", 1)) + + def test_fixed_ignores_status_filter(self): + selected = self.cycle(start="b.safetensors", mode="fixed", statuses=["god"]) + self.assertEqual("b.safetensors", selected) + + def test_increment_uses_status_filter_as_pass_condition(self): + selected = self.cycle(start="a.safetensors", mode="increment", statuses=["favorite"]) + self.assertEqual("c.safetensors", selected) + + def test_randomize_selects_from_filtered_candidates(self): + selected = self.cycle(start="a.safetensors", mode="randomize", statuses=["favorite"]) + self.assertEqual("c.safetensors", selected) + + def test_shuffle_once_consumes_nonmatching_deck_items(self): + self.cycler_state()["shuffle_deck"] = ["b.safetensors", "c.safetensors", "a.safetensors"] + selected = self.cycle(start="a.safetensors", mode="shuffle_once", statuses=["god"]) + self.assertEqual("a.safetensors", selected) + self.assertEqual([], self.cycler_state()["shuffle_deck"]) + + def test_local_list_overrides_fixed_and_ignores_filter(self): + self.cycler_state()["local_list"] = ["c.safetensors"] + selected = self.cycle( + start="b.safetensors", + mode="fixed", + statuses=["god"], + use_local_list=True, + ) + self.assertEqual("c.safetensors", selected) + self.assertEqual([], self.cycler_state()["local_list"]) + + +class NodeContractTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.suite = _load_suite_nodes() + + def test_public_node_mappings_are_stable(self): + self.assertEqual( + { + "CheckpointListSelector", + "CheckpointNameCycler", + "CheckpointStatusTagger", + "CheckpointTagExportImport", + "EphemeralPreview", + "ImageDirPreview", + }, + set(self.suite.NODE_CLASS_MAPPINGS), + ) + + def test_node_menu_categories(self): + main_nodes = ( + self.suite.CheckpointListSelector, + self.suite.CheckpointNameCycler, + self.suite.CheckpointStatusTagger, + self.suite.CheckpointTagExportImport, + ) + preview_nodes = (self.suite.EphemeralPreview, self.suite.ImageDirPreview) + self.assertTrue(all(node.CATEGORY == "HandpickerSuite" for node in main_nodes)) + self.assertTrue(all(node.CATEGORY == "HandpickerSuite/Preview" for node in preview_nodes)) + + def test_documented_output_contracts(self): + self.assertEqual( + ("ckpt_name", "ckpt_name_str", "ckpt_name_safe"), + self.suite.CheckpointListSelector.RETURN_NAMES, + ) + self.assertEqual((), self.suite.CheckpointStatusTagger.RETURN_TYPES) + self.assertEqual((), self.suite.EphemeralPreview.RETURN_TYPES) + + +if __name__ == "__main__": + unittest.main()