fix(recipes): distinguish unobtainable LoRAs in recipe status and skip them in syntax

The recipe card pill counted LoRAs deleted from the source (isDeleted) as
available, showing a green 'ready 2/2' for recipes that cannot be fully
reproduced. LoRAs with an unresolvable hash (hashInvalid) were counted as
missing/downloadable even though downloads always fail, and recipe syntax
generation emitted broken tokens for them.

- Four-state status on RecipeCard pill and RecipeTab badge: ready (all in
  library), missing (downloadable, red, keeps the action cue), partial
  (unobtainable entries skipped when used, amber, fa-circle-minus),
  unavailable (nothing usable, gray, fa-ban)
- Pill numerator is now the real in-library count; tooltips spell out
  missing vs unavailable (deleted from source or unresolvable hash)
- get_recipe_syntax_tokens skips hashInvalid entries like deleted ones
  instead of emitting tokens pointing at nonexistent files
- Bulk missing-download manager and recipe context menu exclude
  hashInvalid LoRAs, matching the modal's per-item download block
- New locale keys loraStatus.missingAndUnavailable/partial/noneUsable,
  translated for all 9 non-en locales
This commit is contained in:
Will Miao
2026-08-29 16:41:48 +08:00
parent ebe3df7d22
commit c972c755fc
19 changed files with 258 additions and 40 deletions
@@ -130,14 +130,61 @@ describe('RecipeCard LoRA status pill', () => {
expect(card.element.querySelector('.lora-count.missing')).toBeNull();
});
it('does not count deleted LoRAs as missing', async () => {
it('marks deleted-from-source LoRAs as partial instead of ready', async () => {
const card = await createCard([
{ name: 'a', inLibrary: true },
{ name: 'b', inLibrary: false, isDeleted: true },
]);
expect(card.element.querySelector('.lora-count.missing')).toBeNull();
expect(card.element.querySelector('.lora-count.ready')).not.toBeNull();
expect(card.element.querySelector('.lora-count.ready')).toBeNull();
const pill = card.element.querySelector('.lora-count.partial');
expect(pill).not.toBeNull();
expect(pill.querySelector('.fa-circle-minus')).not.toBeNull();
expect(pill.textContent).toContain('1/2');
expect(pill.title).toBe('1 of 2 LoRAs unavailable (deleted from source or unresolvable hash) - skipped when recipe is used');
});
it('treats an unresolvable hash as unobtainable, not missing', async () => {
const card = await createCard([
{ name: 'a', inLibrary: true },
{ name: 'b', inLibrary: false, hashInvalid: true },
]);
expect(card.element.querySelector('.lora-count.missing')).toBeNull();
const pill = card.element.querySelector('.lora-count.partial');
expect(pill).not.toBeNull();
expect(pill.textContent).toContain('1/2');
});
it('shows 0/n unavailable when every LoRA is deleted and none is in the library', async () => {
const card = await createCard([
{ name: 'a', inLibrary: false, isDeleted: true },
{ name: 'b', inLibrary: false, isDeleted: true },
]);
const pill = card.element.querySelector('.lora-count.unavailable');
expect(pill).not.toBeNull();
expect(pill.querySelector('.fa-ban')).not.toBeNull();
expect(pill.textContent).toContain('0/2');
expect(pill.title).toBe('No usable LoRAs - 2 of 2 deleted from source or unresolvable hash');
expect(card.element.querySelector('.lora-count.ready')).toBeNull();
});
it('keeps the actionable missing state when LoRAs are both missing and deleted', async () => {
const card = await createCard([
{ name: 'a', inLibrary: true },
{ name: 'b', inLibrary: false },
{ name: 'c', inLibrary: false, isDeleted: true },
]);
const pill = card.element.querySelector('.lora-count.missing');
expect(pill).not.toBeNull();
expect(pill.textContent).toContain('1/3');
expect(pill.title).toBe('1 of 3 LoRAs missing, 1 unavailable (deleted from source or unresolvable hash)');
expect(card.element.querySelector('.lora-count.partial')).toBeNull();
});
it('shows a neutral layers icon with a bare 0 when the recipe has no LoRAs', async () => {
+28
View File
@@ -374,6 +374,34 @@ async def test_set_lora_entry_hash_invalid_persists_flag(tmp_path: Path, recipe_
assert cleared_lora["hashInvalid"] is False
@pytest.mark.asyncio
async def test_get_recipe_syntax_tokens_skips_unobtainable_loras(tmp_path: Path, recipe_scanner):
scanner, _ = recipe_scanner
recipes_dir = Path(config.loras_roots[0]) / "recipes"
recipes_dir.mkdir(parents=True, exist_ok=True)
recipe_id = "syntax-skip-1"
recipe_path = recipes_dir / f"{recipe_id}.recipe.json"
recipe_data = {
"id": recipe_id,
"file_path": str(tmp_path / "image.png"),
"title": "Syntax skip",
"modified": 0.0,
"created_date": 0.0,
"loras": [
{"file_name": "usable_lora", "strength": 0.8, "hash": ""},
{"file_name": "deleted_lora", "strength": 1.0, "hash": "", "isDeleted": True},
{"file_name": "invalid_hash_lora", "strength": 1.0, "hash": "", "hashInvalid": True},
],
}
recipe_path.write_text(json.dumps(recipe_data))
await scanner.add_recipe(dict(recipe_data))
tokens = await scanner.get_recipe_syntax_tokens(recipe_id)
assert tokens == ["<lora:usable_lora:0.8>"]
@pytest.mark.asyncio
async def test_load_recipe_rewrites_missing_image_path(tmp_path: Path, recipe_scanner):
scanner, _ = recipe_scanner