mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2026-05-30 19:07:25 +08:00
Compare commits
5 Commits
0a9da4e5c9
...
59ba3b7df3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59ba3b7df3 | ||
|
|
c33d26c283 | ||
|
|
f3ea976cba | ||
|
|
5538f62b0b | ||
|
|
defb663b94 |
@ -43,7 +43,67 @@ class UploadType(str, Enum):
|
|||||||
model = "file_upload"
|
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:
|
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",
|
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):
|
timeout: int=None, max_retries: int=None, refresh: int=None):
|
||||||
self.route = route
|
self.route = route
|
||||||
@ -70,6 +130,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):
|
class NumberDisplay(str, Enum):
|
||||||
number = "number"
|
number = "number"
|
||||||
slider = "slider"
|
slider = "slider"
|
||||||
@ -359,11 +493,16 @@ class Combo(ComfyTypeIO):
|
|||||||
upload: UploadType=None,
|
upload: UploadType=None,
|
||||||
image_folder: FolderType=None,
|
image_folder: FolderType=None,
|
||||||
remote: RemoteOptions=None,
|
remote: RemoteOptions=None,
|
||||||
|
remote_combo: RemoteComboOptions=None,
|
||||||
socketless: bool=None,
|
socketless: bool=None,
|
||||||
extra_dict=None,
|
extra_dict=None,
|
||||||
raw_link: bool=None,
|
raw_link: bool=None,
|
||||||
advanced: 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):
|
if isinstance(options, type) and issubclass(options, Enum):
|
||||||
options = [v.value for v in options]
|
options = [v.value for v in options]
|
||||||
if isinstance(default, Enum):
|
if isinstance(default, Enum):
|
||||||
@ -375,6 +514,7 @@ class Combo(ComfyTypeIO):
|
|||||||
self.upload = upload
|
self.upload = upload
|
||||||
self.image_folder = image_folder
|
self.image_folder = image_folder
|
||||||
self.remote = remote
|
self.remote = remote
|
||||||
|
self.remote_combo = remote_combo
|
||||||
self.default: str
|
self.default: str
|
||||||
|
|
||||||
def as_dict(self):
|
def as_dict(self):
|
||||||
@ -385,6 +525,7 @@ class Combo(ComfyTypeIO):
|
|||||||
**({self.upload.value: True} if self.upload is not None else {}),
|
**({self.upload.value: True} if self.upload is not None else {}),
|
||||||
"image_folder": self.image_folder.value if self.image_folder else None,
|
"image_folder": self.image_folder.value if self.image_folder else None,
|
||||||
"remote": self.remote.as_dict() if self.remote 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):
|
class Output(Output):
|
||||||
@ -2221,7 +2362,9 @@ class NodeReplace:
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"FolderType",
|
"FolderType",
|
||||||
"UploadType",
|
"UploadType",
|
||||||
|
"RemoteItemSchema",
|
||||||
"RemoteOptions",
|
"RemoteOptions",
|
||||||
|
"RemoteComboOptions",
|
||||||
"NumberDisplay",
|
"NumberDisplay",
|
||||||
"ControlAfterGenerate",
|
"ControlAfterGenerate",
|
||||||
|
|
||||||
|
|||||||
@ -199,6 +199,9 @@ class FILMNet(nn.Module):
|
|||||||
def get_dtype(self):
|
def get_dtype(self):
|
||||||
return self.extract.extract_sublevels.convs[0][0].conv.weight.dtype
|
return self.extract.extract_sublevels.convs[0][0].conv.weight.dtype
|
||||||
|
|
||||||
|
def memory_used_forward(self, shape, dtype):
|
||||||
|
return 1700 * shape[1] * shape[2] * dtype.itemsize
|
||||||
|
|
||||||
def _build_warp_grids(self, H, W, device):
|
def _build_warp_grids(self, H, W, device):
|
||||||
"""Pre-compute warp grids for all pyramid levels."""
|
"""Pre-compute warp grids for all pyramid levels."""
|
||||||
if (H, W) in self._warp_grids:
|
if (H, W) in self._warp_grids:
|
||||||
|
|||||||
@ -74,6 +74,9 @@ class IFNet(nn.Module):
|
|||||||
def get_dtype(self):
|
def get_dtype(self):
|
||||||
return self.encode.cnn0.weight.dtype
|
return self.encode.cnn0.weight.dtype
|
||||||
|
|
||||||
|
def memory_used_forward(self, shape, dtype):
|
||||||
|
return 300 * shape[1] * shape[2] * dtype.itemsize
|
||||||
|
|
||||||
def _build_warp_grids(self, H, W, device):
|
def _build_warp_grids(self, H, W, device):
|
||||||
if (H, W) in self._warp_grids:
|
if (H, W) in self._warp_grids:
|
||||||
return
|
return
|
||||||
|
|||||||
@ -37,7 +37,7 @@ class FrameInterpolationModelLoader(io.ComfyNode):
|
|||||||
model = cls._detect_and_load(sd)
|
model = cls._detect_and_load(sd)
|
||||||
dtype = torch.float16 if model_management.should_use_fp16(model_management.get_torch_device()) else torch.float32
|
dtype = torch.float16 if model_management.should_use_fp16(model_management.get_torch_device()) else torch.float32
|
||||||
model.eval().to(dtype)
|
model.eval().to(dtype)
|
||||||
patcher = comfy.model_patcher.ModelPatcher(
|
patcher = comfy.model_patcher.CoreModelPatcher(
|
||||||
model,
|
model,
|
||||||
load_device=model_management.get_torch_device(),
|
load_device=model_management.get_torch_device(),
|
||||||
offload_device=model_management.unet_offload_device(),
|
offload_device=model_management.unet_offload_device(),
|
||||||
@ -98,16 +98,13 @@ class FrameInterpolate(io.ComfyNode):
|
|||||||
if num_frames < 2 or multiplier < 2:
|
if num_frames < 2 or multiplier < 2:
|
||||||
return io.NodeOutput(images)
|
return io.NodeOutput(images)
|
||||||
|
|
||||||
model_management.load_model_gpu(interp_model)
|
|
||||||
device = interp_model.load_device
|
device = interp_model.load_device
|
||||||
dtype = interp_model.model_dtype()
|
dtype = interp_model.model_dtype()
|
||||||
inference_model = interp_model.model
|
inference_model = interp_model.model
|
||||||
|
activation_mem = inference_model.memory_used_forward(images.shape, dtype)
|
||||||
# Free VRAM for inference activations (model weights + ~20x a single frame's worth)
|
model_management.load_models_gpu([interp_model], memory_required=activation_mem)
|
||||||
H, W = images.shape[1], images.shape[2]
|
|
||||||
activation_mem = H * W * 3 * images.element_size() * 20
|
|
||||||
model_management.free_memory(activation_mem, device)
|
|
||||||
align = getattr(inference_model, "pad_align", 1)
|
align = getattr(inference_model, "pad_align", 1)
|
||||||
|
H, W = images.shape[1], images.shape[2]
|
||||||
|
|
||||||
# Prepare a single padded frame on device for determining output dimensions
|
# Prepare a single padded frame on device for determining output dimensions
|
||||||
def prepare_frame(idx):
|
def prepare_frame(idx):
|
||||||
|
|||||||
@ -666,12 +666,13 @@ class ColorTransfer(io.ComfyNode):
|
|||||||
def define_schema(cls):
|
def define_schema(cls):
|
||||||
return io.Schema(
|
return io.Schema(
|
||||||
node_id="ColorTransfer",
|
node_id="ColorTransfer",
|
||||||
|
display_name="Color Transfer",
|
||||||
category="image/postprocessing",
|
category="image/postprocessing",
|
||||||
description="Match the colors of one image to another using various algorithms.",
|
description="Match the colors of one image to another using various algorithms.",
|
||||||
search_aliases=["color match", "color grading", "color correction", "match colors", "color transform", "mkl", "reinhard", "histogram"],
|
search_aliases=["color match", "color grading", "color correction", "match colors", "color transform", "mkl", "reinhard", "histogram"],
|
||||||
inputs=[
|
inputs=[
|
||||||
io.Image.Input("image_target", tooltip="Image(s) to apply the color transform to."),
|
io.Image.Input("image_target", tooltip="Image(s) to apply the color transform to."),
|
||||||
io.Image.Input("image_ref", optional=True, tooltip="Reference image(s) to match colors to. If not provided, processing is skipped"),
|
io.Image.Input("image_ref", tooltip="Reference image(s) to match colors to."),
|
||||||
io.Combo.Input("method", options=['reinhard_lab', 'mkl_lab', 'histogram'],),
|
io.Combo.Input("method", options=['reinhard_lab', 'mkl_lab', 'histogram'],),
|
||||||
io.DynamicCombo.Input("source_stats",
|
io.DynamicCombo.Input("source_stats",
|
||||||
tooltip="per_frame: each frame matched to image_ref individually. uniform: pool stats across all source frames as baseline, match to image_ref. target_frame: use one chosen frame as the baseline for the transform to image_ref, applied uniformly to all frames (preserves relative differences)",
|
tooltip="per_frame: each frame matched to image_ref individually. uniform: pool stats across all source frames as baseline, match to image_ref. target_frame: use one chosen frame as the baseline for the transform to image_ref, applied uniformly to all frames (preserves relative differences)",
|
||||||
|
|||||||
@ -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 isinstance(input_type, list) or input_type == io.Combo.io_type:
|
||||||
if 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", [])
|
combo_options = extra_info.get("options", [])
|
||||||
else:
|
else:
|
||||||
combo_options = input_type
|
combo_options = input_type
|
||||||
|
|||||||
@ -28,7 +28,7 @@
|
|||||||
#config for a1111 ui
|
#config for a1111 ui
|
||||||
#all you have to do is uncomment this (remove the #) and change the base_path to where yours is installed
|
#all you have to do is uncomment this (remove the #) and change the base_path to where yours is installed
|
||||||
|
|
||||||
#a111:
|
#a1111:
|
||||||
# base_path: path/to/stable-diffusion-webui/
|
# base_path: path/to/stable-diffusion-webui/
|
||||||
# checkpoints: models/Stable-diffusion
|
# checkpoints: models/Stable-diffusion
|
||||||
# configs: models/Stable-diffusion
|
# configs: models/Stable-diffusion
|
||||||
|
|||||||
139
tests-unit/comfy_api_test/remote_combo_options_test.py
Normal file
139
tests-unit/comfy_api_test/remote_combo_options_test.py
Normal file
@ -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())
|
||||||
Loading…
Reference in New Issue
Block a user