From d69dfbbaee95d6f0afa99096b9a8cb3654c26b1e Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Tue, 28 Jul 2026 20:32:09 -0400 Subject: [PATCH] Align Vegas API bounds with validate(), fix audit config plumbing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both from review feedback on #423. The web API's accepted ranges disagreed with VegasModeConfig.validate(), which is what actually gates Vegas starting: scroll_speed 1-100 -> 1-200 (a slider value of 150 returned 400) separator_width 0-500 -> 0-128 target_fps 1-200 -> 30-200 buffer_ahead 1-20 -> 1-5 The three loose ones were the dangerous direction: the value saved with a 200, then VegasModeCoordinator.start() failed validation with only a log line, so the ticker silently never ran. The UI already matched validate() in all four cases, so the API was the odd one out. test_vegas_api_bounds_match_validate parses the numeric_fields map out of api_v3 and asserts every bound against validate(), plus that validate() accepts both endpoints and rejects just outside them, so these cannot drift apart again. That test immediately caught a missing upper bound on min_plugin_width, now added — unbounded it would drop every segment and leave a blank ticker. Separately, vegas_audit.py constructed PluginAdapter without the config, so it fell back to VegasModeConfig() defaults and would report trimming and width-budget behaviour that differed from the user's config.json. It now passes the loaded config exactly as the coordinator does. This is the same class of drift the explicit lead_gap and grouping arguments already guard against. Output is unchanged on a rig whose config matches the defaults. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ --- scripts/dev/vegas_audit.py | 7 ++- src/vegas_mode/config.py | 5 ++ test/test_vegas_density.py | 76 ++++++++++++++++++++++++++++++ web_interface/blueprints/api_v3.py | 18 +++++-- 4 files changed, 100 insertions(+), 6 deletions(-) diff --git a/scripts/dev/vegas_audit.py b/scripts/dev/vegas_audit.py index e41bb607..e3fdffa7 100644 --- a/scripts/dev/vegas_audit.py +++ b/scripts/dev/vegas_audit.py @@ -208,7 +208,12 @@ def main() -> int: display_manager = VisualTestDisplayManager(width=width, height=height) cache_manager = MockCacheManager() plugin_manager = MockPluginManager() - adapter = PluginAdapter(display_manager) + # Pass the loaded config, exactly as VegasModeCoordinator does. Omitting it + # makes PluginAdapter fall back to VegasModeConfig() defaults, so the audit + # would silently report trimming and width-budget behaviour that differs + # from the user's config.json — the same drift the lead_gap and grouping + # arguments below exist to avoid. + adapter = PluginAdapter(display_manager, vegas) if not args.json: print(f"Vegas audit — display {width}x{height}, scroll {speed:g}px/s, " diff --git a/src/vegas_mode/config.py b/src/vegas_mode/config.py index e02b111c..38a56bc9 100644 --- a/src/vegas_mode/config.py +++ b/src/vegas_mode/config.py @@ -230,6 +230,11 @@ class VegasModeConfig: if self.min_plugin_width < 0: errors.append( f"min_plugin_width must be >= 0, got {self.min_plugin_width}") + # Bounded because every segment narrower than this is dropped — an + # unbounded value would discard every plugin and leave a blank ticker. + if self.min_plugin_width > 512: + errors.append( + f"min_plugin_width must be <= 512, got {self.min_plugin_width}") if self.lead_in_width < 0: errors.append( diff --git a/test/test_vegas_density.py b/test/test_vegas_density.py index b4109c36..708136bb 100644 --- a/test/test_vegas_density.py +++ b/test/test_vegas_density.py @@ -398,6 +398,82 @@ class TestStreamGrouping: assert len(sm.get_all_content_for_composition()) == 5 +class TestApiBoundsMatchValidate: + """ + The web API's accepted range for each Vegas setting must agree with + VegasModeConfig.validate(), which is what actually gates Vegas starting. + + A looser API bound saves a value with a 200 and then makes + VegasModeCoordinator.start() bail out with only a log line, so the ticker + silently never runs. A tighter one rejects a legitimate value with a 400. + Both happened before this test existed. + """ + + # (config key, min, max) as validate() enforces them. + EXPECTED = { + 'scroll_speed': (1, 200), + 'separator_width': (0, 128), + 'intra_plugin_gap': (0, 128), + 'target_fps': (30, 200), + 'buffer_ahead': (1, 5), + 'trim_threshold': (0, 254), + 'content_padding': (0, 128), + 'min_plugin_width': (0, 512), + 'plugins_per_cycle': (1, 50), + } + + def _api_numeric_fields(self): + """Extract the numeric_fields map from api_v3 without importing Flask.""" + import ast + import pathlib + src = pathlib.Path('web_interface/blueprints/api_v3.py').read_text() + tree = ast.parse(src) + for node in ast.walk(tree): + if not isinstance(node, ast.Assign): + continue + targets = [t.id for t in node.targets if isinstance(t, ast.Name)] + if 'numeric_fields' not in targets: + continue + if not isinstance(node.value, ast.Dict): + continue + found = {} + for key, value in zip(node.value.keys, node.value.values): + if not isinstance(key, ast.Constant): + continue + if not str(key.value).startswith('vegas_'): + break + cfg_key, lo, hi = [ast.literal_eval(e) for e in value.elts] + found[cfg_key] = (lo, hi) + if found: + return found + raise AssertionError("could not locate the vegas numeric_fields map") + + def test_every_bound_matches_validate(self): + api = self._api_numeric_fields() + mismatched = { + key: (api[key], expected) + for key, expected in self.EXPECTED.items() + if key in api and api[key] != expected + } + assert not mismatched, f"API bounds disagree with validate(): {mismatched}" + + @pytest.mark.parametrize('key,bounds', sorted(EXPECTED.items())) + def test_validate_accepts_both_endpoints(self, key, bounds): + lo, hi = bounds + for value in (lo, hi): + cfg = VegasModeConfig(**{key: value}) + errors = [e for e in cfg.validate() if key in e] + assert not errors, f"{key}={value} should be valid, got {errors}" + + @pytest.mark.parametrize('key,bounds', sorted(EXPECTED.items())) + def test_validate_rejects_just_outside(self, key, bounds): + lo, hi = bounds + for value in (lo - 1, hi + 1): + cfg = VegasModeConfig(**{key: value}) + errors = [e for e in cfg.validate() if key in e] + assert errors, f"{key}={value} should be rejected" + + class TestCycleSizing: def test_plugins_per_cycle_defaults_above_buffer_ahead(self): cfg = VegasModeConfig() diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index c6ea9286..47cc86d3 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -961,13 +961,21 @@ def save_main_config(): }), 400 vegas_config['max_plugin_width_ratio'] = ratio - # Handle numeric settings with validation + # Handle numeric settings with validation. + # + # These bounds must match VegasModeConfig.validate(), which is what + # actually gates Vegas starting. Where they were looser, a value + # saved with a 200 and then made VegasModeCoordinator.start() bail + # out with only a log line, so the ticker silently never ran. + # Where they were tighter (scroll_speed capped at 100 against a + # slider that goes to 200), a legitimate value was rejected with a + # 400. See test_vegas_api_bounds_match_validate. numeric_fields = { - 'vegas_scroll_speed': ('scroll_speed', 1, 100), - 'vegas_separator_width': ('separator_width', 0, 500), + 'vegas_scroll_speed': ('scroll_speed', 1, 200), + 'vegas_separator_width': ('separator_width', 0, 128), 'vegas_intra_plugin_gap': ('intra_plugin_gap', 0, 128), - 'vegas_target_fps': ('target_fps', 1, 200), - 'vegas_buffer_ahead': ('buffer_ahead', 1, 20), + 'vegas_target_fps': ('target_fps', 30, 200), + 'vegas_buffer_ahead': ('buffer_ahead', 1, 5), 'vegas_trim_threshold': ('trim_threshold', 0, 254), 'vegas_content_padding': ('content_padding', 0, 128), 'vegas_min_plugin_width': ('min_plugin_width', 0, 512),