mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2026-05-20 14:07:30 +08:00
Merge branch 'master' into 20260503a_temporal_downscale_ratio
This commit is contained in:
commit
3d3fbf9129
31
.github/workflows/openapi-lint.yml
vendored
Normal file
31
.github/workflows/openapi-lint.yml
vendored
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
name: OpenAPI Lint
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'openapi.yaml'
|
||||||
|
- '.spectral.yaml'
|
||||||
|
- '.github/workflows/openapi-lint.yml'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
spectral:
|
||||||
|
name: Run Spectral
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Install Spectral
|
||||||
|
run: npm install -g @stoplight/spectral-cli@6
|
||||||
|
|
||||||
|
- name: Lint openapi.yaml
|
||||||
|
run: spectral lint openapi.yaml --ruleset .spectral.yaml --fail-severity=error
|
||||||
91
.spectral.yaml
Normal file
91
.spectral.yaml
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
extends:
|
||||||
|
- spectral:oas
|
||||||
|
|
||||||
|
# Severity levels: error, warn, info, hint, off
|
||||||
|
# Rules from the built-in "spectral:oas" ruleset are active by default.
|
||||||
|
# Below we tune severity and add custom rules for our conventions.
|
||||||
|
#
|
||||||
|
# This ruleset mirrors Comfy-Org/cloud/.spectral.yaml so specs across the
|
||||||
|
# organization are linted against a single consistent standard.
|
||||||
|
|
||||||
|
rules:
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Built-in rule severity overrides
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
operation-operationId: error
|
||||||
|
operation-description: warn
|
||||||
|
operation-tag-defined: error
|
||||||
|
info-contact: off
|
||||||
|
info-description: warn
|
||||||
|
no-eval-in-markdown: error
|
||||||
|
no-$ref-siblings: error
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Custom rules: naming conventions
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Property names should be snake_case
|
||||||
|
property-name-snake-case:
|
||||||
|
description: Property names must be snake_case
|
||||||
|
severity: warn
|
||||||
|
given: "$.components.schemas.*.properties[*]~"
|
||||||
|
then:
|
||||||
|
function: pattern
|
||||||
|
functionOptions:
|
||||||
|
match: "^[a-z][a-z0-9]*(_[a-z0-9]+)*$"
|
||||||
|
|
||||||
|
# Operation IDs should be camelCase
|
||||||
|
operation-id-camel-case:
|
||||||
|
description: Operation IDs must be camelCase
|
||||||
|
severity: warn
|
||||||
|
given: "$.paths.*.*.operationId"
|
||||||
|
then:
|
||||||
|
function: pattern
|
||||||
|
functionOptions:
|
||||||
|
match: "^[a-z][a-zA-Z0-9]*$"
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Custom rules: response conventions
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Error responses (4xx, 5xx) should use a consistent shape
|
||||||
|
error-response-schema:
|
||||||
|
description: Error responses should reference a standard error schema
|
||||||
|
severity: hint
|
||||||
|
given: "$.paths.*.*.responses[?(@property >= '400' && @property < '600')].content['application/json'].schema"
|
||||||
|
then:
|
||||||
|
field: "$ref"
|
||||||
|
function: truthy
|
||||||
|
|
||||||
|
# All 2xx responses with JSON body should have a schema
|
||||||
|
response-schema-defined:
|
||||||
|
description: Success responses with JSON content should define a schema
|
||||||
|
severity: warn
|
||||||
|
given: "$.paths.*.*.responses[?(@property >= '200' && @property < '300')].content['application/json']"
|
||||||
|
then:
|
||||||
|
field: schema
|
||||||
|
function: truthy
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Custom rules: best practices
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Path parameters must have a description
|
||||||
|
path-param-description:
|
||||||
|
description: Path parameters should have a description
|
||||||
|
severity: warn
|
||||||
|
given:
|
||||||
|
- "$.paths.*.parameters[?(@.in == 'path')]"
|
||||||
|
- "$.paths.*.*.parameters[?(@.in == 'path')]"
|
||||||
|
then:
|
||||||
|
field: description
|
||||||
|
function: truthy
|
||||||
|
|
||||||
|
# Schemas should have a description
|
||||||
|
schema-description:
|
||||||
|
description: Component schemas should have a description
|
||||||
|
severity: hint
|
||||||
|
given: "$.components.schemas.*"
|
||||||
|
then:
|
||||||
|
field: description
|
||||||
|
function: truthy
|
||||||
@ -395,7 +395,6 @@ class Combo(ComfyTypeIO):
|
|||||||
@comfytype(io_type="COMBO")
|
@comfytype(io_type="COMBO")
|
||||||
class MultiCombo(ComfyTypeI):
|
class MultiCombo(ComfyTypeI):
|
||||||
'''Multiselect Combo input (dropdown for selecting potentially more than one value).'''
|
'''Multiselect Combo input (dropdown for selecting potentially more than one value).'''
|
||||||
# TODO: something is wrong with the serialization, frontend does not recognize it as multiselect
|
|
||||||
Type = list[str]
|
Type = list[str]
|
||||||
class Input(Combo.Input):
|
class Input(Combo.Input):
|
||||||
def __init__(self, id: str, options: list[str], display_name: str=None, optional=False, tooltip: str=None, lazy: bool=None,
|
def __init__(self, id: str, options: list[str], display_name: str=None, optional=False, tooltip: str=None, lazy: bool=None,
|
||||||
@ -408,12 +407,14 @@ class MultiCombo(ComfyTypeI):
|
|||||||
self.default: list[str]
|
self.default: list[str]
|
||||||
|
|
||||||
def as_dict(self):
|
def as_dict(self):
|
||||||
to_return = super().as_dict() | prune_dict({
|
# Frontend expects `multi_select` to be an object config (not a boolean).
|
||||||
"multi_select": self.multiselect,
|
# Keep top-level `multiselect` from Combo.Input for backwards compatibility.
|
||||||
"placeholder": self.placeholder,
|
return super().as_dict() | prune_dict({
|
||||||
"chip": self.chip,
|
"multi_select": prune_dict({
|
||||||
|
"placeholder": self.placeholder,
|
||||||
|
"chip": self.chip,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
return to_return
|
|
||||||
|
|
||||||
@comfytype(io_type="IMAGE")
|
@comfytype(io_type="IMAGE")
|
||||||
class Image(ComfyTypeIO):
|
class Image(ComfyTypeIO):
|
||||||
|
|||||||
@ -147,7 +147,6 @@ class LTXVEmptyLatentAudio(io.ComfyNode):
|
|||||||
|
|
||||||
z_channels = audio_vae.latent_channels
|
z_channels = audio_vae.latent_channels
|
||||||
audio_freq = audio_vae.first_stage_model.latent_frequency_bins
|
audio_freq = audio_vae.first_stage_model.latent_frequency_bins
|
||||||
sampling_rate = int(audio_vae.first_stage_model.sample_rate)
|
|
||||||
|
|
||||||
num_audio_latents = audio_vae.first_stage_model.num_of_latents_from_frames(frames_number, frame_rate)
|
num_audio_latents = audio_vae.first_stage_model.num_of_latents_from_frames(frames_number, frame_rate)
|
||||||
|
|
||||||
@ -159,7 +158,6 @@ class LTXVEmptyLatentAudio(io.ComfyNode):
|
|||||||
return io.NodeOutput(
|
return io.NodeOutput(
|
||||||
{
|
{
|
||||||
"samples": audio_latents,
|
"samples": audio_latents,
|
||||||
"sample_rate": sampling_rate,
|
|
||||||
"type": "audio",
|
"type": "audio",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1019,7 +1019,12 @@ async def validate_inputs(prompt_id, prompt, item, validated, visiting=None):
|
|||||||
combo_options = extra_info.get("options", [])
|
combo_options = extra_info.get("options", [])
|
||||||
else:
|
else:
|
||||||
combo_options = input_type
|
combo_options = input_type
|
||||||
if val not in combo_options:
|
is_multiselect = extra_info.get("multiselect", False)
|
||||||
|
if is_multiselect and isinstance(val, list):
|
||||||
|
invalid_vals = [v for v in val if v not in combo_options]
|
||||||
|
else:
|
||||||
|
invalid_vals = [val] if val not in combo_options else []
|
||||||
|
if invalid_vals:
|
||||||
input_config = info
|
input_config = info
|
||||||
list_info = ""
|
list_info = ""
|
||||||
|
|
||||||
@ -1034,7 +1039,7 @@ async def validate_inputs(prompt_id, prompt, item, validated, visiting=None):
|
|||||||
error = {
|
error = {
|
||||||
"type": "value_not_in_list",
|
"type": "value_not_in_list",
|
||||||
"message": "Value not in list",
|
"message": "Value not in list",
|
||||||
"details": f"{x}: '{val}' not in {list_info}",
|
"details": f"{x}: {', '.join(repr(v) for v in invalid_vals)} not in {list_info}",
|
||||||
"extra_info": {
|
"extra_info": {
|
||||||
"input_name": x,
|
"input_name": x,
|
||||||
"input_config": input_config,
|
"input_config": input_config,
|
||||||
|
|||||||
2
nodes.py
2
nodes.py
@ -2262,7 +2262,7 @@ async def load_custom_node(module_path: str, ignore=set(), module_parent="custom
|
|||||||
logging.warning(f"Error while calling comfy_entrypoint in {module_path}: {e}")
|
logging.warning(f"Error while calling comfy_entrypoint in {module_path}: {e}")
|
||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
logging.warning(f"Skip {module_path} module for custom nodes due to the lack of NODE_CLASS_MAPPINGS or NODES_LIST (need one).")
|
logging.warning(f"Skip {module_path} module for custom nodes due to the lack of NODE_CLASS_MAPPINGS or comfy_entrypoint (need one).")
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.warning(traceback.format_exc())
|
logging.warning(traceback.format_exc())
|
||||||
|
|||||||
122
openapi.yaml
122
openapi.yaml
@ -631,7 +631,7 @@ paths:
|
|||||||
operationId: getFeatures
|
operationId: getFeatures
|
||||||
tags: [system]
|
tags: [system]
|
||||||
summary: Get enabled feature flags
|
summary: Get enabled feature flags
|
||||||
description: Returns a dictionary of feature flag names to their enabled state.
|
description: Returns a dictionary of feature flag names to their enabled state. Cloud deployments may include additional typed fields alongside the boolean flags.
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Feature flags
|
description: Feature flags
|
||||||
@ -641,6 +641,43 @@ paths:
|
|||||||
type: object
|
type: object
|
||||||
additionalProperties:
|
additionalProperties:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
properties:
|
||||||
|
max_upload_size:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
minimum: 0
|
||||||
|
description: "Maximum file upload size in bytes."
|
||||||
|
free_tier_credits:
|
||||||
|
type: integer
|
||||||
|
format: int32
|
||||||
|
minimum: 0
|
||||||
|
nullable: true
|
||||||
|
x-runtime: [cloud]
|
||||||
|
description: "[cloud-only] Credits available to free-tier users. Local ComfyUI returns null."
|
||||||
|
posthog_api_host:
|
||||||
|
type: string
|
||||||
|
format: uri
|
||||||
|
nullable: true
|
||||||
|
x-runtime: [cloud]
|
||||||
|
description: "[cloud-only] PostHog analytics proxy URL for frontend telemetry. Local ComfyUI returns null."
|
||||||
|
max_concurrent_jobs:
|
||||||
|
type: integer
|
||||||
|
format: int32
|
||||||
|
minimum: 0
|
||||||
|
nullable: true
|
||||||
|
x-runtime: [cloud]
|
||||||
|
description: "[cloud-only] Maximum concurrent jobs the authenticated user can run. Local ComfyUI returns null."
|
||||||
|
workflow_templates_version:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
x-runtime: [cloud]
|
||||||
|
description: "[cloud-only] Version identifier for the workflow templates bundle. Local ComfyUI returns null."
|
||||||
|
workflow_templates_source:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
enum: [dynamic_config_override, workflow_templates_version_json]
|
||||||
|
x-runtime: [cloud]
|
||||||
|
description: "[cloud-only] How the templates version was resolved. Local ComfyUI returns null."
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Node / Object Info
|
# Node / Object Info
|
||||||
@ -1497,6 +1534,24 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
enum: [asc, desc]
|
enum: [asc, desc]
|
||||||
description: Sort direction
|
description: Sort direction
|
||||||
|
- name: job_ids
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
x-runtime: [cloud]
|
||||||
|
description: "[cloud-only] Comma-separated UUIDs to filter assets by associated job."
|
||||||
|
- name: include_public
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: boolean
|
||||||
|
x-runtime: [cloud]
|
||||||
|
description: "[cloud-only] Include workspace-public assets in addition to the caller's own."
|
||||||
|
- name: asset_hash
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
x-runtime: [cloud]
|
||||||
|
description: "[cloud-only] Filter by exact content hash."
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Asset list
|
description: Asset list
|
||||||
@ -1542,6 +1597,49 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
format: uuid
|
format: uuid
|
||||||
description: ID of an existing asset to use as the preview image
|
description: ID of an existing asset to use as the preview image
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
nullable: true
|
||||||
|
x-runtime: [cloud]
|
||||||
|
description: "[cloud-only] Client-supplied asset ID for idempotent creation. If an asset with this ID already exists, the existing asset is returned."
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
x-runtime: [cloud]
|
||||||
|
description: "[cloud-only] URL-based asset upload. Caller supplies a URL instead of a file body; the server fetches the content."
|
||||||
|
required:
|
||||||
|
- url
|
||||||
|
properties:
|
||||||
|
url:
|
||||||
|
type: string
|
||||||
|
format: uri
|
||||||
|
description: "[cloud-only] URL of the file to import as an asset"
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description: Display name for the asset
|
||||||
|
tags:
|
||||||
|
type: string
|
||||||
|
description: Comma-separated tags
|
||||||
|
user_metadata:
|
||||||
|
type: string
|
||||||
|
description: JSON-encoded user metadata
|
||||||
|
hash:
|
||||||
|
type: string
|
||||||
|
description: "Blake3 hash of the file content (e.g. blake3:abc123...)"
|
||||||
|
mime_type:
|
||||||
|
type: string
|
||||||
|
description: MIME type of the file (overrides auto-detected type)
|
||||||
|
preview_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: ID of an existing asset to use as the preview image
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
nullable: true
|
||||||
|
x-runtime: [cloud]
|
||||||
|
description: "[cloud-only] Client-supplied asset ID for idempotent creation. If an asset with this ID already exists, the existing asset is returned."
|
||||||
responses:
|
responses:
|
||||||
"201":
|
"201":
|
||||||
description: Asset created
|
description: Asset created
|
||||||
@ -1580,6 +1678,11 @@ paths:
|
|||||||
user_metadata:
|
user_metadata:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
|
mime_type:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
x-runtime: [cloud]
|
||||||
|
description: "[cloud-only] MIME type of the content, so the type is preserved without re-inspecting content. Ignored by local ComfyUI."
|
||||||
responses:
|
responses:
|
||||||
"201":
|
"201":
|
||||||
description: Asset created from hash
|
description: Asset created from hash
|
||||||
@ -1644,6 +1747,11 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
format: uuid
|
format: uuid
|
||||||
description: ID of the asset to use as the preview
|
description: ID of the asset to use as the preview
|
||||||
|
mime_type:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
x-runtime: [cloud]
|
||||||
|
description: "[cloud-only] MIME type override when auto-detection was wrong. Ignored by local ComfyUI."
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Asset updated
|
description: Asset updated
|
||||||
@ -2004,21 +2112,13 @@ components:
|
|||||||
format: uuid
|
format: uuid
|
||||||
nullable: true
|
nullable: true
|
||||||
x-runtime: [cloud]
|
x-runtime: [cloud]
|
||||||
description: |
|
description: "[cloud-only] Cloud workflow entity ID for tracking and gallery association. Ignored by local ComfyUI."
|
||||||
UUID identifying a hosted-cloud workflow entity to associate with this
|
|
||||||
job. Local ComfyUI doesn't track workflow entities and returns `null`
|
|
||||||
(or omits the field). The `x-runtime: [cloud]` extension marks this
|
|
||||||
as populated only by the hosted-cloud runtime; absence of the tag
|
|
||||||
means a field is populated by all runtimes.
|
|
||||||
workflow_version_id:
|
workflow_version_id:
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
format: uuid
|
||||||
nullable: true
|
nullable: true
|
||||||
x-runtime: [cloud]
|
x-runtime: [cloud]
|
||||||
description: |
|
description: "[cloud-only] Cloud workflow version ID for pinning execution to a specific version. Ignored by local ComfyUI."
|
||||||
UUID identifying a hosted-cloud workflow version to associate with
|
|
||||||
this job. Local ComfyUI returns `null` (or omits the field). See
|
|
||||||
`workflow_id` above for `x-runtime` semantics.
|
|
||||||
|
|
||||||
PromptResponse:
|
PromptResponse:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@ -560,7 +560,7 @@ class PromptServer():
|
|||||||
buffer.seek(0)
|
buffer.seek(0)
|
||||||
|
|
||||||
return web.Response(body=buffer.read(), content_type=f'image/{image_format}',
|
return web.Response(body=buffer.read(), content_type=f'image/{image_format}',
|
||||||
headers={"Content-Disposition": f"filename=\"{filename}\""})
|
headers={"Content-Disposition": f"attachment; filename=\"{filename}\""})
|
||||||
|
|
||||||
if 'channel' not in request.rel_url.query:
|
if 'channel' not in request.rel_url.query:
|
||||||
channel = 'rgba'
|
channel = 'rgba'
|
||||||
@ -580,7 +580,7 @@ class PromptServer():
|
|||||||
buffer.seek(0)
|
buffer.seek(0)
|
||||||
|
|
||||||
return web.Response(body=buffer.read(), content_type='image/png',
|
return web.Response(body=buffer.read(), content_type='image/png',
|
||||||
headers={"Content-Disposition": f"filename=\"{filename}\""})
|
headers={"Content-Disposition": f"attachment; filename=\"{filename}\""})
|
||||||
|
|
||||||
elif channel == 'a':
|
elif channel == 'a':
|
||||||
with Image.open(file) as img:
|
with Image.open(file) as img:
|
||||||
@ -597,7 +597,7 @@ class PromptServer():
|
|||||||
alpha_buffer.seek(0)
|
alpha_buffer.seek(0)
|
||||||
|
|
||||||
return web.Response(body=alpha_buffer.read(), content_type='image/png',
|
return web.Response(body=alpha_buffer.read(), content_type='image/png',
|
||||||
headers={"Content-Disposition": f"filename=\"{filename}\""})
|
headers={"Content-Disposition": f"attachment; filename=\"{filename}\""})
|
||||||
else:
|
else:
|
||||||
# Use the content type from asset resolution if available,
|
# Use the content type from asset resolution if available,
|
||||||
# otherwise guess from the filename.
|
# otherwise guess from the filename.
|
||||||
@ -614,7 +614,7 @@ class PromptServer():
|
|||||||
return web.FileResponse(
|
return web.FileResponse(
|
||||||
file,
|
file,
|
||||||
headers={
|
headers={
|
||||||
"Content-Disposition": f"filename=\"{filename}\"",
|
"Content-Disposition": f"attachment; filename=\"{filename}\"",
|
||||||
"Content-Type": content_type
|
"Content-Type": content_type
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
78
tests-unit/comfy_api_test/multicombo_serialization_test.py
Normal file
78
tests-unit/comfy_api_test/multicombo_serialization_test.py
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
from comfy_api.latest._io import Combo, MultiCombo
|
||||||
|
|
||||||
|
|
||||||
|
def test_multicombo_serializes_multi_select_as_object():
|
||||||
|
multi_combo = MultiCombo.Input(
|
||||||
|
id="providers",
|
||||||
|
options=["a", "b", "c"],
|
||||||
|
default=["a"],
|
||||||
|
)
|
||||||
|
|
||||||
|
serialized = multi_combo.as_dict()
|
||||||
|
|
||||||
|
assert serialized["multiselect"] is True
|
||||||
|
assert "multi_select" in serialized
|
||||||
|
assert serialized["multi_select"] == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_multicombo_serializes_multi_select_with_placeholder_and_chip():
|
||||||
|
multi_combo = MultiCombo.Input(
|
||||||
|
id="providers",
|
||||||
|
options=["a", "b", "c"],
|
||||||
|
default=["a"],
|
||||||
|
placeholder="Select providers",
|
||||||
|
chip=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
serialized = multi_combo.as_dict()
|
||||||
|
|
||||||
|
assert serialized["multiselect"] is True
|
||||||
|
assert serialized["multi_select"] == {
|
||||||
|
"placeholder": "Select providers",
|
||||||
|
"chip": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_combo_does_not_serialize_multiselect():
|
||||||
|
"""Regular Combo should not have multiselect in its serialized output."""
|
||||||
|
combo = Combo.Input(
|
||||||
|
id="choice",
|
||||||
|
options=["a", "b", "c"],
|
||||||
|
)
|
||||||
|
|
||||||
|
serialized = combo.as_dict()
|
||||||
|
|
||||||
|
# Combo sets multiselect=False, but prune_dict keeps False (not None),
|
||||||
|
# so it should be present but False
|
||||||
|
assert serialized.get("multiselect") is False
|
||||||
|
assert "multi_select" not in serialized
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_combo_values(val, combo_options, is_multiselect):
|
||||||
|
"""Reproduce the validation logic from execution.py for testing."""
|
||||||
|
if is_multiselect and isinstance(val, list):
|
||||||
|
return [v for v in val if v not in combo_options]
|
||||||
|
else:
|
||||||
|
return [val] if val not in combo_options else []
|
||||||
|
|
||||||
|
|
||||||
|
def test_multicombo_validation_accepts_valid_list():
|
||||||
|
options = ["a", "b", "c"]
|
||||||
|
assert _validate_combo_values(["a", "b"], options, True) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_multicombo_validation_rejects_invalid_values():
|
||||||
|
options = ["a", "b", "c"]
|
||||||
|
assert _validate_combo_values(["a", "x"], options, True) == ["x"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_multicombo_validation_accepts_empty_list():
|
||||||
|
options = ["a", "b", "c"]
|
||||||
|
assert _validate_combo_values([], options, True) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_combo_validation_rejects_list_even_with_valid_items():
|
||||||
|
"""A regular Combo should not accept a list value."""
|
||||||
|
options = ["a", "b", "c"]
|
||||||
|
invalid = _validate_combo_values(["a", "b"], options, False)
|
||||||
|
assert len(invalid) > 0
|
||||||
Loading…
Reference in New Issue
Block a user