mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-05-06 08:26:45 -03:00
feat(prompt): expand wildcards at runtime (#895)
This commit is contained in:
@@ -1073,6 +1073,66 @@ describe('AutoComplete widget interactions', () => {
|
||||
expect(fetchApiMock).toHaveBeenCalledWith('/lm/custom-words/search?enriched=true&search=cat&limit=100');
|
||||
});
|
||||
|
||||
it('searches wildcard keys when using the /wild command', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
fetchApiMock.mockResolvedValue({
|
||||
json: () => Promise.resolve({ success: true, words: ['animals/cat'] }),
|
||||
});
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('/wild cat');
|
||||
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = '/wild cat';
|
||||
input.selectionStart = input.value.length;
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
});
|
||||
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await vi.runAllTimersAsync();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fetchApiMock).toHaveBeenCalledWith('/lm/wildcards/search?search=cat&limit=100');
|
||||
expect(autoComplete.searchType).toBe('wildcards');
|
||||
expect(autoComplete.items).toEqual(['animals/cat']);
|
||||
});
|
||||
|
||||
it('inserts wildcard references when accepting a /wild result', async () => {
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('/wild animals/cat');
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = '/wild animals/cat';
|
||||
input.selectionStart = input.value.length;
|
||||
input.focus = vi.fn();
|
||||
input.setSelectionRange = vi.fn();
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
});
|
||||
|
||||
autoComplete.searchType = 'wildcards';
|
||||
autoComplete.activeCommand = { type: 'wildcard', label: 'Wildcards' };
|
||||
autoComplete.items = ['animals/cat'];
|
||||
autoComplete.selectedIndex = 0;
|
||||
|
||||
await autoComplete.insertSelection('animals/cat');
|
||||
|
||||
expect(input.value).toBe('__animals/cat__,');
|
||||
expect(input.focus).toHaveBeenCalled();
|
||||
expect(input.setSelectionRange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('invalidates stale autocomplete metadata and falls back to delimiter-based matching', async () => {
|
||||
settingGetMock.mockImplementation((key) => {
|
||||
if (key === 'loramanager.autocomplete_append_comma') {
|
||||
|
||||
61
tests/nodes/test_prompt_text_wildcards.py
Normal file
61
tests/nodes/test_prompt_text_wildcards.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from py.nodes.prompt import PromptLM
|
||||
from py.nodes.text import TextLM
|
||||
|
||||
|
||||
def test_text_lm_expands_wildcards_before_output(monkeypatch):
|
||||
node = TextLM()
|
||||
|
||||
expand_calls = []
|
||||
|
||||
class StubService:
|
||||
def expand_text(self, text, seed=None):
|
||||
expand_calls.append((text, seed))
|
||||
return "expanded text"
|
||||
|
||||
monkeypatch.setattr("py.nodes.text.get_wildcard_service", lambda: StubService())
|
||||
|
||||
assert node.process("__flower__", seed=9) == ("expanded text",)
|
||||
assert expand_calls == [("__flower__", 9)]
|
||||
|
||||
|
||||
def test_prompt_lm_expands_before_appending_trigger_words(monkeypatch):
|
||||
node = PromptLM()
|
||||
|
||||
class StubService:
|
||||
def expand_text(self, text, seed=None):
|
||||
assert text == "__flower__"
|
||||
assert seed == 42
|
||||
return "rose"
|
||||
|
||||
class StubEncoder:
|
||||
def encode(self, clip, prompt):
|
||||
assert clip == "clip"
|
||||
assert prompt == "artist style, rose"
|
||||
return ("conditioning",)
|
||||
|
||||
monkeypatch.setattr("py.nodes.prompt.get_wildcard_service", lambda: StubService())
|
||||
monkeypatch.setattr("nodes.CLIPTextEncode", lambda: StubEncoder(), raising=False)
|
||||
|
||||
result = node.encode("__flower__", "clip", seed=42, trigger_words1="artist style")
|
||||
|
||||
assert result == ("conditioning", "artist style, rose")
|
||||
|
||||
|
||||
def test_prompt_lm_input_types_expose_input_only_seed():
|
||||
input_types = PromptLM.INPUT_TYPES()
|
||||
seed_type, seed_options = input_types["optional"]["seed"]
|
||||
|
||||
assert seed_type == "INT"
|
||||
assert seed_options["forceInput"] is True
|
||||
assert "wildcard generation" in seed_options["tooltip"]
|
||||
|
||||
|
||||
def test_text_lm_input_types_expose_input_only_seed():
|
||||
input_types = TextLM.INPUT_TYPES()
|
||||
seed_type, seed_options = input_types["optional"]["seed"]
|
||||
|
||||
assert seed_type == "INT"
|
||||
assert seed_options["forceInput"] is True
|
||||
assert "wildcard generation" in seed_options["tooltip"]
|
||||
45
tests/routes/test_wildcard_routes.py
Normal file
45
tests/routes/test_wildcard_routes.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from py.routes.handlers.misc_handlers import WildcardsHandler
|
||||
|
||||
|
||||
class FakeRequest:
|
||||
def __init__(self, query=None):
|
||||
self.query = query or {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_wildcards_returns_results():
|
||||
class StubService:
|
||||
def search_keys(self, search_term, limit, offset):
|
||||
assert search_term == "cat"
|
||||
assert limit == 25
|
||||
assert offset == 2
|
||||
return ["animals/cat"]
|
||||
|
||||
handler = WildcardsHandler(service=StubService())
|
||||
response = await handler.search_wildcards(
|
||||
FakeRequest(query={"search": "cat", "limit": "25", "offset": "2"})
|
||||
)
|
||||
payload = json.loads(response.text)
|
||||
|
||||
assert response.status == 200
|
||||
assert payload == {"success": True, "words": ["animals/cat"]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_wildcards_handles_errors():
|
||||
class StubService:
|
||||
def search_keys(self, search_term, limit, offset):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
handler = WildcardsHandler(service=StubService())
|
||||
response = await handler.search_wildcards(FakeRequest(query={"search": "cat"}))
|
||||
payload = json.loads(response.text)
|
||||
|
||||
assert response.status == 500
|
||||
assert payload["error"] == "boom"
|
||||
123
tests/services/test_wildcard_service.py
Normal file
123
tests/services/test_wildcard_service.py
Normal file
@@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from py.services.wildcard_service import WildcardService
|
||||
|
||||
|
||||
def _make_service(monkeypatch, tmp_path):
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir()
|
||||
monkeypatch.setattr(
|
||||
"py.services.wildcard_service.get_settings_dir",
|
||||
lambda create=True: str(settings_dir),
|
||||
)
|
||||
service = WildcardService()
|
||||
service._cached_signature = None
|
||||
service._wildcard_dict = {}
|
||||
return service, settings_dir / "wildcards"
|
||||
|
||||
|
||||
def test_search_keys_returns_empty_when_directory_missing(monkeypatch, tmp_path):
|
||||
service, _wildcards_dir = _make_service(monkeypatch, tmp_path)
|
||||
|
||||
assert service.search_keys("cat") == []
|
||||
|
||||
|
||||
def test_search_keys_loads_txt_yaml_and_json(monkeypatch, tmp_path):
|
||||
service, wildcards_dir = _make_service(monkeypatch, tmp_path)
|
||||
wildcards_dir.mkdir()
|
||||
|
||||
(wildcards_dir / "animals").mkdir()
|
||||
(wildcards_dir / "animals" / "cat.txt").write_text("tabby\nblack cat\n", encoding="utf-8")
|
||||
(wildcards_dir / "colors.yaml").write_text(
|
||||
"palette:\n warm:\n - red\n - orange\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(wildcards_dir / "artists.json").write_text(
|
||||
json.dumps({"illustrators/digital": ["alice", "bob"]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert service.search_keys("cat") == ["animals/cat"]
|
||||
assert service.search_keys("warm") == ["palette/warm"]
|
||||
assert service.search_keys("digital") == ["illustrators/digital"]
|
||||
|
||||
|
||||
def test_search_keys_prefers_exact_and_prefix_matches(monkeypatch, tmp_path):
|
||||
service, wildcards_dir = _make_service(monkeypatch, tmp_path)
|
||||
wildcards_dir.mkdir()
|
||||
|
||||
(wildcards_dir / "animals").mkdir()
|
||||
(wildcards_dir / "animals" / "cat.txt").write_text("tabby\n", encoding="utf-8")
|
||||
(wildcards_dir / "animals" / "catgirl.txt").write_text("heroine\n", encoding="utf-8")
|
||||
(wildcards_dir / "fantasy_cat.txt").write_text("beast\n", encoding="utf-8")
|
||||
|
||||
results = service.search_keys("cat")
|
||||
|
||||
assert results == ["animals/cat", "animals/catgirl", "fantasy_cat"]
|
||||
|
||||
|
||||
def test_search_keys_supports_offset_and_limit(monkeypatch, tmp_path):
|
||||
service, wildcards_dir = _make_service(monkeypatch, tmp_path)
|
||||
wildcards_dir.mkdir()
|
||||
|
||||
for name in ("cat", "catgirl", "catmaid"):
|
||||
(wildcards_dir / f"{name}.txt").write_text("x\n", encoding="utf-8")
|
||||
|
||||
assert service.search_keys("cat", limit=1, offset=1) == ["catgirl"]
|
||||
|
||||
|
||||
def test_expand_text_resolves_nested_wildcards(monkeypatch, tmp_path):
|
||||
service, wildcards_dir = _make_service(monkeypatch, tmp_path)
|
||||
wildcards_dir.mkdir()
|
||||
|
||||
(wildcards_dir / "flower.txt").write_text("rose\n__color__ lily\n", encoding="utf-8")
|
||||
(wildcards_dir / "color.txt").write_text("red\nblue\n", encoding="utf-8")
|
||||
|
||||
expanded = service.expand_text("__flower__", seed=7)
|
||||
|
||||
assert expanded in {"rose", "red lily", "blue lily"}
|
||||
assert "__" not in expanded
|
||||
|
||||
|
||||
def test_expand_text_resolves_dynamic_prompt_and_multi_select(monkeypatch, tmp_path):
|
||||
service, wildcards_dir = _make_service(monkeypatch, tmp_path)
|
||||
wildcards_dir.mkdir()
|
||||
|
||||
expanded = service.expand_text("{2$$, $$red|blue|green}", seed=3)
|
||||
|
||||
assert expanded.count(", ") == 1
|
||||
assert set(expanded.split(", ")).issubset({"red", "blue", "green"})
|
||||
|
||||
|
||||
def test_expand_text_resolves_wildcard_glob(monkeypatch, tmp_path):
|
||||
service, wildcards_dir = _make_service(monkeypatch, tmp_path)
|
||||
wildcards_dir.mkdir()
|
||||
|
||||
(wildcards_dir / "animals").mkdir()
|
||||
(wildcards_dir / "animals" / "cat.txt").write_text("tabby\n", encoding="utf-8")
|
||||
(wildcards_dir / "animals" / "dog.txt").write_text("retriever\n", encoding="utf-8")
|
||||
|
||||
expanded = service.expand_text("__animals/*__", seed=1)
|
||||
|
||||
assert expanded in {"tabby", "retriever"}
|
||||
|
||||
|
||||
def test_expand_text_is_deterministic_with_seed(monkeypatch, tmp_path):
|
||||
service, wildcards_dir = _make_service(monkeypatch, tmp_path)
|
||||
wildcards_dir.mkdir()
|
||||
|
||||
(wildcards_dir / "color.txt").write_text("red\nblue\ngreen\n", encoding="utf-8")
|
||||
|
||||
first = service.expand_text("__color__", seed=123)
|
||||
second = service.expand_text("__color__", seed=123)
|
||||
|
||||
assert first == second
|
||||
|
||||
|
||||
def test_expand_text_leaves_unresolved_reference_visible(monkeypatch, tmp_path):
|
||||
service, wildcards_dir = _make_service(monkeypatch, tmp_path)
|
||||
wildcards_dir.mkdir()
|
||||
|
||||
assert service.expand_text("__missing__", seed=1) == "__missing__"
|
||||
Reference in New Issue
Block a user