diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index 5ed968960..5ed49242c 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -44,7 +44,67 @@ class UploadType(str, Enum): model = "file_upload" +class RemoteItemSchema: + """Describes how to map API response objects to rich dropdown items. + + All *_field parameters use dot-path notation (e.g. ``"labels.gender"``). + ``label_field`` and ``description_field`` additionally support template strings + with ``{field}`` placeholders (e.g. ``"{name} ({labels.accent})"``). + """ + def __init__( + self, + value_field: str, + label_field: str, + preview_url_field: str | None = None, + preview_type: Literal["image", "video", "audio"] = "image", + description_field: str | None = None, + search_fields: list[str] | None = None, + ): + if preview_type not in ("image", "video", "audio"): + raise ValueError( + f"RemoteItemSchema: 'preview_type' must be 'image', 'video', or 'audio'; got {preview_type!r}." + ) + if search_fields is not None: + for f in search_fields: + if "{" in f or "}" in f: + raise ValueError( + f"RemoteItemSchema: 'search_fields' must be dot-paths, not template strings (got {f!r})." + ) + self.value_field = value_field + """Dot-path to the unique identifier within each item. + This value is stored in the widget and passed to execute().""" + self.label_field = label_field + """Dot-path to the display name, or a template string with {field} placeholders.""" + self.preview_url_field = preview_url_field + """Dot-path to a preview media URL. If None, no preview is shown.""" + self.preview_type = preview_type + """How to render the preview: "image", "video", or "audio".""" + self.description_field = description_field + """Optional dot-path or template for a subtitle line shown below the label.""" + self.search_fields = search_fields + """Dot-paths to fields included in the search index. When unset, search falls back to + the resolved label (i.e. ``label_field`` after template substitution). Note that template + label strings (e.g. ``"{first} {last}"``) are not valid path entries here — list the + underlying paths (``["first", "last"]``) instead.""" + + def as_dict(self): + return prune_dict({ + "value_field": self.value_field, + "label_field": self.label_field, + "preview_url_field": self.preview_url_field, + "preview_type": self.preview_type, + "description_field": self.description_field, + "search_fields": self.search_fields, + }) + + class RemoteOptions: + """Plain remote combo: fetches a list of strings/objects and populates a standard dropdown. + + Use this for lightweight lists from endpoints that return a bare array (or an array under + ``response_key``). For rich dropdowns with previews, search, filtering, or pagination, + use :class:`RemoteComboOptions` and the ``remote_combo=`` parameter on ``Combo.Input``. + """ def __init__(self, route: str, refresh_button: bool, control_after_refresh: Literal["first", "last"]="first", timeout: int=None, max_retries: int=None, refresh: int=None): self.route = route @@ -71,6 +131,80 @@ class RemoteOptions: }) +class RemoteComboOptions: + """Rich remote combo: populates a Vue dropdown with previews, search, and filtering. + + Attached to a :class:`Combo.Input` via ``remote_combo=`` (not ``remote=``). Requires an + ``item_schema`` describing how to map API response objects to dropdown items. + + Response-shape contract: the endpoint returns the full items array in a single response + (either at the top level, or at the dot-path given by ``response_key``). Backing endpoints + that paginate upstream are expected to aggregate and cache server-side. + """ + def __init__( + self, + route: str, + item_schema: RemoteItemSchema, + refresh_button: bool = True, + auto_select: Literal["first", "last"] | None = None, + timeout: int | None = None, + max_retries: int | None = None, + refresh: int | None = None, + response_key: str | None = None, + ): + if auto_select is not None and auto_select not in ("first", "last"): + raise ValueError( + f"RemoteComboOptions: 'auto_select' must be 'first', 'last', or None; got {auto_select!r}." + ) + if refresh is not None and 0 < refresh < 128: + raise ValueError( + f"RemoteComboOptions: 'refresh' must be >= 128 (ms TTL) or <= 0 (cache never expires); got {refresh}." + ) + if timeout is not None and timeout < 0: + raise ValueError( + f"RemoteComboOptions: 'timeout' must be >= 0 (got {timeout})." + ) + if max_retries is not None and max_retries < 0: + raise ValueError( + f"RemoteComboOptions: 'max_retries' must be >= 0 (got {max_retries})." + ) + if not route.startswith("/"): + raise ValueError( + f"RemoteComboOptions: 'route' must be a relative path starting with '/'; got {route!r}." + ) + self.route = route + """Relative path to the remote source (must start with ``/``). The frontend resolves this + against the comfy-api base URL and injects auth headers; absolute URLs are rejected.""" + self.item_schema = item_schema + """Required: describes how each API response object maps to a dropdown item.""" + self.refresh_button = refresh_button + """Specifies whether to show a refresh button next to the widget.""" + self.auto_select = auto_select + """Fallback item to select when the widget's value is empty. Never overrides an existing + selection. Default None means no fallback.""" + self.timeout = timeout + """Maximum time to wait for a response, in milliseconds.""" + self.max_retries = max_retries + """Maximum number of retries before aborting the request. Default None uses the frontend's built-in limit.""" + self.refresh = refresh + """TTL of the cached value in milliseconds. Must be >= 128 (ms TTL) or <= 0 (cache never expires, + re-fetched only via the refresh button). Default None uses the frontend's built-in behavior.""" + self.response_key = response_key + """Dot-path to the items array within the response (when not at the top level).""" + + def as_dict(self): + return prune_dict({ + "route": self.route, + "item_schema": self.item_schema.as_dict(), + "refresh_button": self.refresh_button, + "auto_select": self.auto_select, + "timeout": self.timeout, + "max_retries": self.max_retries, + "refresh": self.refresh, + "response_key": self.response_key, + }) + + class NumberDisplay(str, Enum): number = "number" slider = "slider" @@ -360,11 +494,16 @@ class Combo(ComfyTypeIO): upload: UploadType=None, image_folder: FolderType=None, remote: RemoteOptions=None, + remote_combo: RemoteComboOptions=None, socketless: bool=None, extra_dict=None, raw_link: bool=None, advanced: bool=None, ): + if remote is not None and remote_combo is not None: + raise ValueError("Combo.Input: pass either 'remote' or 'remote_combo', not both.") + if options is not None and remote_combo is not None: + raise ValueError("Combo.Input: pass either 'options' or 'remote_combo', not both.") if isinstance(options, type) and issubclass(options, Enum): options = [v.value for v in options] if isinstance(default, Enum): @@ -376,6 +515,7 @@ class Combo(ComfyTypeIO): self.upload = upload self.image_folder = image_folder self.remote = remote + self.remote_combo = remote_combo self.default: str def as_dict(self): @@ -386,6 +526,7 @@ class Combo(ComfyTypeIO): **({self.upload.value: True} if self.upload is not None else {}), "image_folder": self.image_folder.value if self.image_folder else None, "remote": self.remote.as_dict() if self.remote else None, + "remote_combo": self.remote_combo.as_dict() if self.remote_combo else None, }) class Output(Output): @@ -2228,7 +2369,9 @@ class NodeReplace: __all__ = [ "FolderType", "UploadType", + "RemoteItemSchema", "RemoteOptions", + "RemoteComboOptions", "NumberDisplay", "ControlAfterGenerate", diff --git a/comfy_api_nodes/apis/bytedance.py b/comfy_api_nodes/apis/bytedance.py index c05bd6893..b66a0342f 100644 --- a/comfy_api_nodes/apis/bytedance.py +++ b/comfy_api_nodes/apis/bytedance.py @@ -132,6 +132,10 @@ class GetAssetResponse(BaseModel): error: TaskStatusError | None = Field(None) +class SeedanceCreateVisualValidateSessionRequest(BaseModel): + name: str | None = Field(None, max_length=64) + + class SeedanceCreateVisualValidateSessionResponse(BaseModel): session_id: str = Field(...) h5_link: str = Field(...) @@ -141,6 +145,7 @@ class SeedanceGetVisualValidateSessionResponse(BaseModel): session_id: str = Field(...) status: str = Field(...) group_id: str | None = Field(None) + name: str | None = Field(None) error_code: str | None = Field(None) error_message: str | None = Field(None) diff --git a/comfy_api_nodes/nodes_bytedance.py b/comfy_api_nodes/nodes_bytedance.py index 5f74f4a14..1627190e0 100644 --- a/comfy_api_nodes/nodes_bytedance.py +++ b/comfy_api_nodes/nodes_bytedance.py @@ -19,6 +19,7 @@ from comfy_api_nodes.apis.bytedance import ( Seedance2TaskCreationRequest, SeedanceCreateAssetRequest, SeedanceCreateAssetResponse, + SeedanceCreateVisualValidateSessionRequest, SeedanceCreateVisualValidateSessionResponse, SeedanceGetVisualValidateSessionResponse, SeedanceVirtualLibraryCreateAssetRequest, @@ -196,11 +197,16 @@ def _rewrite_asset_refs(prompt: str, labels: dict[int, str]) -> str: return _ASSET_REF_RE.sub(_sub, prompt) -async def _obtain_group_id_via_h5_auth(cls: type[IO.ComfyNode]) -> str: +async def _obtain_group_id_via_h5_auth( + cls: type[IO.ComfyNode], + group_name: str | None = None, +) -> str: + payload = SeedanceCreateVisualValidateSessionRequest(name=group_name) session = await sync_op( cls, ApiEndpoint(path="/proxy/seedance/visual-validate/sessions", method="POST"), response_model=SeedanceCreateVisualValidateSessionResponse, + data=payload, ) logger.warning("Seedance authentication required. Open link: %s", session.h5_link) @@ -229,10 +235,15 @@ async def _obtain_group_id_via_h5_auth(cls: type[IO.ComfyNode]) -> str: return result.group_id -async def _resolve_group_id(cls: type[IO.ComfyNode], group_id: str) -> str: +async def _resolve_group_id( + cls: type[IO.ComfyNode], + group_id: str, + group_name: str | None = None, +) -> str: if group_id and group_id.strip(): return group_id.strip() - return await _obtain_group_id_via_h5_auth(cls) + label = (group_name or "").strip() or None + return await _obtain_group_id_via_h5_auth(cls, group_name=label) async def _create_seedance_asset( @@ -1949,6 +1960,55 @@ async def process_video_task( return IO.NodeOutput(await download_url_to_video_output(response.content.video_url)) +def _seedance_group_picker_input() -> IO.Combo.Input: + """Combo populated from /proxy/seedance/visual-validate/groups. Empty selection triggers H5 enrollment.""" + return IO.Combo.Input( + "group_id", + default="", + tooltip=( + "Pick an existing verified group, or leave empty to run real-person H5 " + "authentication and create a new group." + ), + remote_combo=IO.RemoteComboOptions( + route="/proxy/seedance/visual-validate/groups", + response_key="groups", + item_schema=IO.RemoteItemSchema( + value_field="group_id", + label_field="name", + description_field="created_at", + search_fields=["name", "group_id"], + ), + refresh=60_000, + ), + optional=True, + ) + + +def _seedance_group_name_input() -> IO.String.Input: + return IO.String.Input( + "group_name", + default="", + tooltip=( + "Optional label for a new group. Used only when group_id is empty; the label is " + "shown later in the group picker so you can identify this group at a glance. " + "Up to 64 characters." + ), + optional=True, + ) + + +def _seedance_asset_name_input() -> IO.String.Input: + return IO.String.Input( + "asset_name", + default="", + tooltip=( + "Optional label for the asset, shown in the asset selector dropdown. " + "Up to 64 characters. Leave empty to identify the asset by its id." + ), + optional=True, + ) + + class ByteDanceCreateImageAsset(IO.ComfyNode): @classmethod @@ -1959,22 +2019,15 @@ class ByteDanceCreateImageAsset(IO.ComfyNode): category="api node/image/ByteDance", description=( "Create a Seedance 2.0 personal image asset. Uploads the input image and " - "registers it in the given asset group. If group_id is empty, runs a real-person " - "H5 authentication flow to create a new group before adding the asset." + "registers it in the selected asset group. Leave group_id empty to run a " + "real-person H5 authentication flow and create a new group; provide group_name " + "to label the new group." ), inputs=[ IO.Image.Input("image", tooltip="Image to register as a personal asset."), - IO.String.Input( - "group_id", - default="", - tooltip="Reuse an existing Seedance asset group ID to skip repeated human verification for the " - "same person. Leave empty to run real-person authentication in the browser and create a new group.", - ), - # IO.String.Input( - # "name", - # default="", - # tooltip="Asset name (up to 64 characters).", - # ), + _seedance_group_picker_input(), + _seedance_group_name_input(), + _seedance_asset_name_input(), ], outputs=[ IO.String.Output(display_name="asset_id"), @@ -1993,18 +2046,17 @@ class ByteDanceCreateImageAsset(IO.ComfyNode): cls, image: Input.Image, group_id: str = "", - # name: str = "", + group_name: str = "", + asset_name: str = "", ) -> IO.NodeOutput: - # if len(name) > 64: - # raise ValueError("Name of asset can not be greater then 64 symbols") validate_image_dimensions(image, min_width=300, max_width=6000, min_height=300, max_height=6000) validate_image_aspect_ratio(image, min_ratio=(0.4, 1), max_ratio=(2.5, 1)) - resolved_group = await _resolve_group_id(cls, group_id) + resolved_group = await _resolve_group_id(cls, group_id, group_name=group_name) asset_id = await _create_seedance_asset( cls, group_id=resolved_group, url=await upload_image_to_comfyapi(cls, image), - name="", + name=asset_name.strip()[:64], asset_type="Image", ) await _wait_for_asset_active(cls, asset_id, resolved_group) @@ -2026,22 +2078,15 @@ class ByteDanceCreateVideoAsset(IO.ComfyNode): category="api node/video/ByteDance", description=( "Create a Seedance 2.0 personal video asset. Uploads the input video and " - "registers it in the given asset group. If group_id is empty, runs a real-person " - "H5 authentication flow to create a new group before adding the asset." + "registers it in the selected asset group. Leave group_id empty to run a " + "real-person H5 authentication flow and create a new group; provide group_name " + "to label the new group." ), inputs=[ IO.Video.Input("video", tooltip="Video to register as a personal asset."), - IO.String.Input( - "group_id", - default="", - tooltip="Reuse an existing Seedance asset group ID to skip repeated human verification for the " - "same person. Leave empty to run real-person authentication in the browser and create a new group.", - ), - # IO.String.Input( - # "name", - # default="", - # tooltip="Asset name (up to 64 characters).", - # ), + _seedance_group_picker_input(), + _seedance_group_name_input(), + _seedance_asset_name_input(), ], outputs=[ IO.String.Output(display_name="asset_id"), @@ -2060,10 +2105,9 @@ class ByteDanceCreateVideoAsset(IO.ComfyNode): cls, video: Input.Video, group_id: str = "", - # name: str = "", + group_name: str = "", + asset_name: str = "", ) -> IO.NodeOutput: - # if len(name) > 64: - # raise ValueError("Name of asset can not be greater then 64 symbols") validate_video_duration(video, min_duration=2, max_duration=15) validate_video_dimensions(video, min_width=300, max_width=6000, min_height=300, max_height=6000) @@ -2082,12 +2126,12 @@ class ByteDanceCreateVideoAsset(IO.ComfyNode): if not (24 <= fps <= 60): raise ValueError(f"Asset video FPS must be in [24, 60], got {fps:.2f}.") - resolved_group = await _resolve_group_id(cls, group_id) + resolved_group = await _resolve_group_id(cls, group_id, group_name=group_name) asset_id = await _create_seedance_asset( cls, group_id=resolved_group, url=await upload_video_to_comfyapi(cls, video), - name="", + name=asset_name.strip()[:64], asset_type="Video", ) await _wait_for_asset_active(cls, asset_id, resolved_group) @@ -2099,6 +2143,92 @@ class ByteDanceCreateVideoAsset(IO.ComfyNode): return IO.NodeOutput(asset_id, resolved_group) +def _seedance_asset_picker_input(asset_type: str, preview_type: str) -> IO.Combo.Input: + """Combo populated from /proxy/seedance/assets, scoped to one asset_type.""" + return IO.Combo.Input( + "asset_id", + tooltip=( + f"Pick a previously-created Seedance {asset_type.lower()} asset. The dropdown shows " + "your assets across all your verified groups; type a group name to filter." + ), + remote_combo=IO.RemoteComboOptions( + route=f"/proxy/seedance/assets?asset_type={asset_type}", + response_key="assets", + item_schema=IO.RemoteItemSchema( + value_field="asset_id", + label_field="name", + description_field="group_name", + preview_url_field="url", + preview_type=preview_type, + search_fields=["name", "asset_id", "group_name", "group_id"], + ), + refresh=60_000, + ), + ) + + +class ByteDanceSelectImageAsset(IO.ComfyNode): + + @classmethod + def define_schema(cls) -> IO.Schema: + return IO.Schema( + node_id="ByteDanceSelectImageAsset", + display_name="ByteDance Select Image Asset", + category="api node/image/ByteDance", + description=( + "Pick a previously-created Seedance image asset. Outputs the selected asset_id " + "for use with downstream Seedance 2.0 reference/first-last-frame nodes." + ), + inputs=[ + _seedance_asset_picker_input("Image", "image"), + ], + outputs=[IO.String.Output(display_name="asset_id")], + hidden=[ + IO.Hidden.auth_token_comfy_org, + IO.Hidden.api_key_comfy_org, + IO.Hidden.unique_id, + ], + # is_api_node=True, + ) + + @classmethod + async def execute(cls, asset_id: str) -> IO.NodeOutput: + if not asset_id or not asset_id.strip(): + raise ValueError("asset_id is required. Pick an asset from the dropdown.") + return IO.NodeOutput(asset_id.strip()) + + +class ByteDanceSelectVideoAsset(IO.ComfyNode): + + @classmethod + def define_schema(cls) -> IO.Schema: + return IO.Schema( + node_id="ByteDanceSelectVideoAsset", + display_name="ByteDance Select Video Asset", + category="api node/video/ByteDance", + description=( + "Pick a previously-created Seedance video asset. Outputs the selected asset_id " + "for use with downstream Seedance 2.0 reference/first-last-frame nodes." + ), + inputs=[ + _seedance_asset_picker_input("Video", "video"), + ], + outputs=[IO.String.Output(display_name="asset_id")], + hidden=[ + IO.Hidden.auth_token_comfy_org, + IO.Hidden.api_key_comfy_org, + IO.Hidden.unique_id, + ], + # is_api_node=True, + ) + + @classmethod + async def execute(cls, asset_id: str) -> IO.NodeOutput: + if not asset_id or not asset_id.strip(): + raise ValueError("asset_id is required. Pick an asset from the dropdown.") + return IO.NodeOutput(asset_id.strip()) + + class ByteDanceExtension(ComfyExtension): @override async def get_node_list(self) -> list[type[IO.ComfyNode]]: @@ -2114,6 +2244,8 @@ class ByteDanceExtension(ComfyExtension): ByteDance2ReferenceNode, ByteDanceCreateImageAsset, ByteDanceCreateVideoAsset, + ByteDanceSelectImageAsset, + ByteDanceSelectVideoAsset, ] diff --git a/comfy_api_nodes/nodes_elevenlabs.py b/comfy_api_nodes/nodes_elevenlabs.py index e452daf77..40b191d5a 100644 --- a/comfy_api_nodes/nodes_elevenlabs.py +++ b/comfy_api_nodes/nodes_elevenlabs.py @@ -233,6 +233,44 @@ class ElevenLabsVoiceSelector(IO.ComfyNode): return IO.NodeOutput(voice_id) +class ElevenLabsRichVoiceSelector(IO.ComfyNode): + @classmethod + def define_schema(cls) -> IO.Schema: + return IO.Schema( + node_id="ElevenLabsRichVoiceSelector", + display_name="ElevenLabs Voice Selector (Rich)", + category="api node/audio/ElevenLabs", + description="Select an ElevenLabs voice with audio preview and rich metadata.", + inputs=[ + IO.Combo.Input( + "voice", + remote_combo=IO.RemoteComboOptions( + route="/proxy/elevenlabs/v2/voices?page_size=100", + response_key="items", + refresh_button=True, + refresh=43200000, + item_schema=IO.RemoteItemSchema( + value_field="voice_id", + label_field="name", + preview_url_field="preview_url", + preview_type="audio", + search_fields=["name", "labels.gender", "labels.accent", "labels.use_case"], + ), + ), + tooltip="Choose a voice with audio preview.", + ), + ], + outputs=[ + IO.Custom(ELEVENLABS_VOICE).Output(display_name="voice"), + ], + is_api_node=False, + ) + + @classmethod + def execute(cls, voice: str) -> IO.NodeOutput: + return IO.NodeOutput(voice) # voice is already the voice_id from item_schema.value_field + + class ElevenLabsTextToSpeech(IO.ComfyNode): @classmethod def define_schema(cls) -> IO.Schema: @@ -911,6 +949,7 @@ class ElevenLabsExtension(ComfyExtension): return [ ElevenLabsSpeechToText, ElevenLabsVoiceSelector, + ElevenLabsRichVoiceSelector, ElevenLabsTextToSpeech, ElevenLabsAudioIsolation, ElevenLabsTextToSoundEffects, diff --git a/comfy_api_nodes/nodes_kling.py b/comfy_api_nodes/nodes_kling.py index 7586f1816..7a8cd3e4e 100644 --- a/comfy_api_nodes/nodes_kling.py +++ b/comfy_api_nodes/nodes_kling.py @@ -3292,6 +3292,53 @@ class KlingAvatarNode(IO.ComfyNode): return IO.NodeOutput(await download_url_to_video_output(final_response.data.task_result.videos[0].url)) +KLING_ELEMENT_ID = "KLING_ELEMENT_ID" + + +class KlingElementSelector(IO.ComfyNode): + """Select a Kling preset element (character, scene, effect, etc.) for use in video generation.""" + + @classmethod + def define_schema(cls) -> IO.Schema: + return IO.Schema( + node_id="KlingElementSelector", + display_name="Kling Element Selector", + category="api node/video/Kling", + description="Browse and select a Kling preset element with image preview. Elements provide consistent characters, scenes, costumes, and effects for video generation.", + inputs=[ + IO.Combo.Input( + "element", + remote_combo=IO.RemoteComboOptions( + route="/proxy/kling/v1/general/advanced-presets-elements", + refresh_button=True, + refresh=43200000, + response_key="data", + item_schema=IO.RemoteItemSchema( + value_field="task_result.elements.0.element_id", + label_field="task_result.elements.0.element_name", + preview_url_field="task_result.elements.0.element_image_list.frontal_image", + preview_type="image", + description_field="task_result.elements.0.element_description", + search_fields=["task_result.elements.0.element_name", "task_result.elements.0.element_description"], + ), + ), + tooltip="Select a preset element to use in video generation.", + ), + ], + outputs=[IO.Custom(KLING_ELEMENT_ID).Output(display_name="element_id")], + hidden=[ + IO.Hidden.auth_token_comfy_org, + IO.Hidden.api_key_comfy_org, + IO.Hidden.unique_id, + ], + is_api_node=False, + ) + + @classmethod + async def execute(cls, element: str) -> IO.NodeOutput: + return IO.NodeOutput(element) + + class KlingExtension(ComfyExtension): @override async def get_node_list(self) -> list[type[IO.ComfyNode]]: @@ -3321,6 +3368,7 @@ class KlingExtension(ComfyExtension): KlingVideoNode, KlingFirstLastFrameNode, KlingAvatarNode, + KlingElementSelector, ] diff --git a/execution.py b/execution.py index f37d0360d..2d8f6dfae 100644 --- a/execution.py +++ b/execution.py @@ -1016,6 +1016,10 @@ async def validate_inputs(prompt_id, prompt, item, validated, visiting=None): if isinstance(input_type, list) or input_type == io.Combo.io_type: if input_type == io.Combo.io_type: + # Skip validation for combos with remote options — options + # are fetched client-side and not available on the server. + if extra_info.get("remote_combo"): + continue combo_options = extra_info.get("options", []) else: combo_options = input_type diff --git a/tests-unit/comfy_api_test/remote_combo_options_test.py b/tests-unit/comfy_api_test/remote_combo_options_test.py new file mode 100644 index 000000000..cce6bf8ec --- /dev/null +++ b/tests-unit/comfy_api_test/remote_combo_options_test.py @@ -0,0 +1,139 @@ +import pytest + +from comfy_api.latest._io import ( + Combo, + RemoteComboOptions, + RemoteItemSchema, + RemoteOptions, +) + + +def _schema(**overrides): + defaults = dict(value_field="id", label_field="name") + return RemoteItemSchema(**{**defaults, **overrides}) + + +def _combo(**overrides): + defaults = dict(route="/proxy/foo", item_schema=_schema()) + return RemoteComboOptions(**{**defaults, **overrides}) + + +def test_item_schema_defaults_accepted(): + d = _schema().as_dict() + assert d == {"value_field": "id", "label_field": "name", "preview_type": "image"} + + +def test_item_schema_full_config_accepted(): + d = _schema( + preview_url_field="preview", + preview_type="audio", + description_field="desc", + search_fields=["first", "last", "profile.email"], + ).as_dict() + assert d["preview_type"] == "audio" + assert d["search_fields"] == ["first", "last", "profile.email"] + + +@pytest.mark.parametrize( + "bad_fields", + [ + ["{first} {last}"], + ["name", "{age}"], + ["leading{"], + ["trailing}"], + ], +) +def test_item_schema_rejects_template_strings_in_search_fields(bad_fields): + with pytest.raises(ValueError, match="search_fields"): + _schema(search_fields=bad_fields) + + +@pytest.mark.parametrize("bad_preview_type", ["middle", "IMAGE", "", "gif"]) +def test_item_schema_rejects_unknown_preview_type(bad_preview_type): + with pytest.raises(ValueError, match="preview_type"): + _schema(preview_type=bad_preview_type) + + +def test_combo_options_minimal_accepted(): + d = _combo().as_dict() + assert d["route"] == "/proxy/foo" + assert d["refresh_button"] is True + assert "item_schema" in d + + +@pytest.mark.parametrize( + "route", + [ + "/proxy/foo", + "/voices", + ], +) +def test_combo_options_accepts_valid_routes(route): + _combo(route=route) + + +@pytest.mark.parametrize( + "route", + [ + "", + "api.example.com/voices", + "voices", + "ftp-no-scheme", + "http://localhost:9000/voices", + "https://api.example.com/v1/voices", + ], +) +def test_combo_options_rejects_non_relative_routes(route): + with pytest.raises(ValueError, match="'route'"): + _combo(route=route) + + +@pytest.mark.parametrize("bad_auto_select", ["middle", "FIRST", "", "firstlast"]) +def test_combo_options_rejects_unknown_auto_select(bad_auto_select): + with pytest.raises(ValueError, match="auto_select"): + _combo(auto_select=bad_auto_select) + + +@pytest.mark.parametrize("bad_refresh", [1, 127]) +def test_combo_options_refresh_in_forbidden_range_rejected(bad_refresh): + with pytest.raises(ValueError, match="refresh"): + _combo(refresh=bad_refresh) + + +@pytest.mark.parametrize("ok_refresh", [0, -1, 128]) +def test_combo_options_refresh_valid_values_accepted(ok_refresh): + _combo(refresh=ok_refresh) + + +def test_combo_options_timeout_negative_rejected(): + with pytest.raises(ValueError, match="timeout"): + _combo(timeout=-1) + + +def test_combo_options_max_retries_negative_rejected(): + with pytest.raises(ValueError, match="max_retries"): + _combo(max_retries=-1) + + +def test_combo_options_as_dict_prunes_none_fields(): + d = _combo().as_dict() + for pruned in ("response_key", "refresh", "timeout", "max_retries", "auto_select"): + assert pruned not in d + + +def test_combo_input_accepts_remote_combo_alone(): + Combo.Input("voice", remote_combo=_combo()) + + +def test_combo_input_rejects_remote_plus_remote_combo(): + with pytest.raises(ValueError, match="remote.*remote_combo"): + Combo.Input( + "voice", + remote=RemoteOptions(route="/r", refresh_button=True), + remote_combo=_combo(), + ) + + +def test_combo_input_rejects_options_plus_remote_combo(): + with pytest.raises(ValueError, match="options.*remote_combo"): + Combo.Input("voice", options=["a", "b"], remote_combo=_combo())