mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-07 07:36:37 +00:00
chore: remove dead code, deprecate unused plugin APIs (over-engineering audit)
Whole-tree audit. Every symbol was checked against core, the plugin monorepo and all eight third-party plugins in plugins.json first. - Deprecate (removal 3.10.0) plugin-facing methods nothing calls: LogoDownloader bulk download, ConfigManager backup/secret wrappers, APIHelper extras, BackgroundDataService poll API, PluginManager / PluginStateManager info readers, and a few CacheManager, FontManager, BaseOddsManager, DynamicTeamResolver methods and PluginTestCase. plugin_api_usage.py learns their receiver names; DEPRECATIONS doc regenerated. - Remove core-internal dead code: CacheMetrics, Vegas status/stats plumbing, sync "new cycle" message (followers ignore unknown types), unused operation types, test-only PluginCatalog readers, IPC to_dict and ping, _parse_form_value, CacheStrategyProtocol, ErrorAggregator callbacks, duplicate web response helpers. - Web UI: drop never-mounted json-file-manager.js, the example widget, utils/error_handler.js, four uncalled PluginAPI methods, and 29 escapeHtml shims (call window.LEDEscape directly). Public globals, BaseWidget and widget names unchanged. - Remove six one-off scripts (owner decision) and the unused markupsafe and pytest-mock pins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -41,8 +41,7 @@ def mock_plugin_catalog():
|
||||
"""
|
||||
from src.plugin_system.plugin_catalog import PluginCatalog
|
||||
catalog = MagicMock(spec=PluginCatalog)
|
||||
for name in ('plugins_dir', 'config_manager', 'schema_manager',
|
||||
'plugin_manifests', 'plugin_directories'):
|
||||
for name in ('plugins_dir', 'plugin_manifests', 'plugin_directories'):
|
||||
setattr(catalog, name, MagicMock())
|
||||
return catalog
|
||||
|
||||
|
||||
@@ -12,9 +12,10 @@
|
||||
//
|
||||
// CodeQL reported 83 js/incomplete-html-attribute-sanitization alerts for
|
||||
// exactly this. The web UI now has one implementation, window.LEDEscape in
|
||||
// app-early.js, and the old per-file escapers are one-line names for it. This
|
||||
// suite runs LEDEscape and every one of those names as shipped, and fails if a
|
||||
// hand-rolled escaper appears anywhere else in web_interface/.
|
||||
// app-early.js, which every page and widget calls directly (BaseWidget keeps
|
||||
// an escapeHtml method for plugin widgets). This suite runs LEDEscape and that
|
||||
// method as shipped, and fails if a hand-rolled escaper appears anywhere else
|
||||
// in web_interface/.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
@@ -85,30 +86,6 @@ const ESCAPERS = [
|
||||
['app-early.js (LEDEscape.attr)', null, null, 'attr'],
|
||||
['base-widget.js (BaseWidget.escapeHtml)',
|
||||
'static/v3/js/widgets/base-widget.js', 'escapeHtml(text) {', 'escapeHtml', true],
|
||||
['plugins_manager.js (top-level escapeHtml)',
|
||||
'static/v3/plugins_manager.js', 'function escapeHtml(text) {', 'escapeHtml', false],
|
||||
['plugins_manager.js (starlark escapeHtml)',
|
||||
'static/v3/plugins_manager.js', 'function escapeHtml(str) {', 'escapeHtml', false],
|
||||
['json-file-manager.js (_esc)',
|
||||
'static/v3/js/widgets/json-file-manager.js', '_esc(str) {', '_esc', true],
|
||||
['plugin-file-manager.js (escHtml)',
|
||||
'static/v3/js/widgets/plugin-file-manager.js', 'function escHtml(s) {', 'escHtml', false],
|
||||
['plugins_manager.js (escapeAttribute)',
|
||||
'static/v3/plugins_manager.js', 'function escapeAttribute(text) {', 'escapeAttribute', false],
|
||||
['notification.js (escapeHtml)',
|
||||
'static/v3/js/widgets/notification.js', 'function escapeHtml(text) {', 'escapeHtml', false],
|
||||
['google-calendar-picker.js (escapeHtml)',
|
||||
'static/v3/js/widgets/google-calendar-picker.js', 'function escapeHtml(str) {', 'escapeHtml', false],
|
||||
['text-input.js (escapeHtml)',
|
||||
'static/v3/js/widgets/text-input.js', 'function escapeHtml(text) {', 'escapeHtml', false],
|
||||
['slider.js (escapeAttr)',
|
||||
'static/v3/js/widgets/slider.js', 'function escapeAttr(text) {', 'escapeAttr', false],
|
||||
['tools.html (escHtml)',
|
||||
'templates/v3/partials/tools.html', 'function escHtml(s) {', 'escHtml', false],
|
||||
['tools.html (phEscape)',
|
||||
'templates/v3/partials/tools.html', 'function phEscape(s) {', 'phEscape', false],
|
||||
['logs.html (escapeHtml)',
|
||||
'templates/v3/partials/logs.html', 'function escapeHtml(text) {', 'escapeHtml', false],
|
||||
// cache.html, backup_restore.html, operation_history.html and display.html
|
||||
// have no script any more (display.html's two escapers were never called):
|
||||
// their js/pages/ modules draw server data with textContent, and each
|
||||
@@ -169,9 +146,7 @@ console.log('\n4b. LEDEscape.jsStringAttr: a JS string literal that survives an
|
||||
|
||||
console.log('\n4c. no hand-rolled escaper outside app-early.js');
|
||||
{
|
||||
const skip = new Set(['static/v3/js/app-early.js',
|
||||
// documentation example, kept self-contained on purpose
|
||||
'static/v3/js/widgets/example-color-picker.js']);
|
||||
const skip = new Set(['static/v3/js/app-early.js']);
|
||||
const found = [];
|
||||
const walk = dir => fs.readdirSync(dir, { withFileTypes: true }).forEach(e => {
|
||||
const p = path.join(dir, e.name);
|
||||
@@ -308,7 +283,7 @@ console.log('\n6. url-input onInput: previewLink.href is guarded at the sink');
|
||||
|
||||
// ── plugin-file-manager: cell edits travel via data-*, not inline handlers ──
|
||||
// A JSON key/day from an uploaded file used to be spliced, HTML-escaped,
|
||||
// into an oninput="...('${escHtml(col)}'...)" attribute. escHtml neutralises
|
||||
// into an oninput="...('${escHtml(col)}'...)" attribute. Escaping neutralises
|
||||
// a quote for an ordinary attribute, but here the value also has to survive
|
||||
// as a *JS string literal* -- the browser HTML-decodes the attribute before
|
||||
// running it as script, which turns the escaped quote back into a real one
|
||||
@@ -332,11 +307,10 @@ console.log("\n7. plugin-file-manager: cell edits never go through an inline han
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const escHtmlFn = loadFn('static/v3/js/widgets/plugin-file-manager.js', 'function escHtml(s) {', 'escHtml', false);
|
||||
const renderEntryTableSrc = extractFn('function renderEntryTable(fieldId, container, content) {');
|
||||
|
||||
const calls = [];
|
||||
const fakeWindow = { _pfmCellEdit: (fieldId, day, col, value) => calls.push({ fieldId, day, col, value }) };
|
||||
const fakeWindow = { LEDEscape, _pfmCellEdit: (fieldId, day, col, value) => calls.push({ fieldId, day, col, value }) };
|
||||
|
||||
class FakeContainer {
|
||||
constructor() { this._html = ''; this._listeners = {}; }
|
||||
@@ -354,12 +328,11 @@ console.log("\n7. plugin-file-manager: cell edits never go through an inline han
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-eval
|
||||
const renderEntryTable = eval(`(function(getState, escHtml, safeSetHTML, window){
|
||||
const renderEntryTable = eval(`(function(getState, safeSetHTML, window){
|
||||
${renderEntryTableSrc}
|
||||
return renderEntryTable;
|
||||
})`)(
|
||||
() => ({ entriesPerPage: 20, _tablePage: 1 }),
|
||||
escHtmlFn,
|
||||
(target, html) => { target.innerHTML = html; },
|
||||
fakeWindow
|
||||
);
|
||||
|
||||
@@ -62,12 +62,12 @@ global.setGridHtmlIfChanged = (container, html) => { container.innerHTML = html;
|
||||
|
||||
// eslint-disable-next-line no-eval
|
||||
eval([
|
||||
'function escapeHtml(text) {', 'function escapeAttribute(text) {', 'function jsStringAttr(value) {',
|
||||
'function jsStringAttr(value) {',
|
||||
'function renderPluginStore(plugins) {', 'function renderSavedRepositories(repositories) {',
|
||||
'function renderCustomRegistryPlugins(plugins, registryUrl) {',
|
||||
].map(extract).join('\n') + '\nglobal.jsStringAttr = jsStringAttr; global.escapeHtml = escapeHtml;'
|
||||
].map(extract).join('\n') + '\nglobal.jsStringAttr = jsStringAttr;'
|
||||
+ '\nglobal.renderPluginStore = renderPluginStore; global.renderSavedRepositories = renderSavedRepositories;'
|
||||
+ '\nglobal.renderCustomRegistryPlugins = renderCustomRegistryPlugins; global.escapeAttribute = escapeAttribute;');
|
||||
+ '\nglobal.renderCustomRegistryPlugins = renderCustomRegistryPlugins;');
|
||||
|
||||
// ── minimal HTML start-tag tokenizer ───────────────────────────────────────
|
||||
function decodeEntities(s) {
|
||||
|
||||
@@ -16,7 +16,7 @@ const container = {
|
||||
innerHTML: '',
|
||||
querySelectorAll: () => [], // no skeletons in this harness
|
||||
};
|
||||
// escapeHtml() escapes via a detached element, so mirror what a browser does
|
||||
// LEDEscape.html() escapes via a detached element, so mirror what a browser does
|
||||
// when you read innerHTML back off textContent: & < > are escaped, quotes are not.
|
||||
class FakeEl {
|
||||
set textContent(v) { this._t = String(v == null ? '' : v); }
|
||||
@@ -36,7 +36,7 @@ global.PLUGIN_DEBUG = false;
|
||||
global.debugLog = () => {};
|
||||
function setupInstalledEventDelegation() {} // stubbed; tested separately
|
||||
|
||||
eval(slice('function escapeHtml(text)', '\nfunction isNewPlugin'));
|
||||
eval(slice('function jsStringAttr(value)', '\nfunction isNewPlugin'));
|
||||
eval(slice('function renderInstalledCards(plugins, total)',
|
||||
'// Set up event delegation for plugin action buttons'));
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ global.installedPlugins = [];
|
||||
|
||||
// eslint-disable-next-line no-eval
|
||||
eval([
|
||||
'function escapeHtml(text) {', 'function escapeAttribute(text) {', 'function jsStringAttr(value) {',
|
||||
'function jsStringAttr(value) {',
|
||||
'function isStorePluginInstalled(pluginIdOrPlugin) {',
|
||||
'function findInstalledStorePlugin(pluginIdOrPlugin) {', 'function renderPluginStore(plugins) {',
|
||||
].map(extract).join('\n') + '\nglobal.renderPluginStore = renderPluginStore;'
|
||||
|
||||
@@ -253,9 +253,10 @@ const noSleep = { sleep: async () => {} };
|
||||
for (const endpoint of bad) codes.push(await refusal(endpoint));
|
||||
ok('an endpoint that could leave the API path is refused before fetch()',
|
||||
codes.every(c => c === 'INVALID_ENDPOINT') && urls.length === 0, { codes, urls });
|
||||
await PluginAPI.resetPluginConfig('a/../b&x=1');
|
||||
global.debugLog = () => {}; // GETs go through the throttler, which logs
|
||||
await PluginAPI.getPluginHealth('a/../b&x=1');
|
||||
ok('a plugin id is encoded into the URL, not spliced into it',
|
||||
urls[0] === '/api/v3/plugins/config/reset?plugin_id=a%2F..%2Fb%26x%3D1', urls);
|
||||
urls[0] === '/api/v3/plugins/health/a%2F..%2Fb%26x%3D1', urls);
|
||||
delete global.fetch;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Tests for CacheManager and cache components.
|
||||
|
||||
Tests cache functionality including memory cache, disk cache, strategy, and metrics.
|
||||
Tests cache functionality including memory cache, disk cache, and strategy.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
@@ -11,7 +11,6 @@ from src.cache_manager import CacheManager
|
||||
from src.cache.memory_cache import MemoryCache
|
||||
from src.cache.disk_cache import DiskCache
|
||||
from src.cache.cache_strategy import CacheStrategy
|
||||
from src.cache.cache_metrics import CacheMetrics
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@@ -26,7 +25,6 @@ class TestCacheManager:
|
||||
assert hasattr(cm, '_memory_cache_component')
|
||||
assert hasattr(cm, '_disk_cache_component')
|
||||
assert hasattr(cm, '_strategy_component')
|
||||
assert hasattr(cm, '_metrics_component')
|
||||
|
||||
def test_set_and_get(self, tmp_path):
|
||||
"""Test basic set and get operations."""
|
||||
@@ -196,50 +194,6 @@ class TestMemoryCache:
|
||||
assert stats["max_size"] == 1000 # default
|
||||
|
||||
|
||||
class TestCacheMetrics:
|
||||
"""Test CacheMetrics functionality."""
|
||||
|
||||
def test_record_hit(self):
|
||||
"""Test recording cache hit."""
|
||||
metrics = CacheMetrics()
|
||||
metrics.record_hit()
|
||||
stats = metrics.get_metrics()
|
||||
|
||||
# get_metrics() returns calculated values, not raw hits/misses
|
||||
assert stats['total_requests'] == 1
|
||||
assert stats['cache_hit_rate'] == 1.0 # 1 hit out of 1 request
|
||||
|
||||
def test_record_miss(self):
|
||||
"""Test recording cache miss."""
|
||||
metrics = CacheMetrics()
|
||||
metrics.record_miss()
|
||||
stats = metrics.get_metrics()
|
||||
|
||||
# get_metrics() returns calculated values, not raw hits/misses
|
||||
assert stats['total_requests'] == 1
|
||||
assert stats['cache_hit_rate'] == 0.0 # 0 hits out of 1 request
|
||||
|
||||
def test_record_fetch_time(self):
|
||||
"""Test recording fetch time."""
|
||||
metrics = CacheMetrics()
|
||||
metrics.record_fetch_time(0.5)
|
||||
stats = metrics.get_metrics()
|
||||
|
||||
assert stats['fetch_count'] == 1
|
||||
assert stats['total_fetch_time'] == 0.5
|
||||
assert stats['average_fetch_time'] == 0.5
|
||||
|
||||
def test_cache_hit_rate(self):
|
||||
"""Test cache hit rate calculation."""
|
||||
metrics = CacheMetrics()
|
||||
metrics.record_hit()
|
||||
metrics.record_hit()
|
||||
metrics.record_miss()
|
||||
|
||||
stats = metrics.get_metrics()
|
||||
assert stats['cache_hit_rate'] == pytest.approx(0.666, abs=0.01)
|
||||
|
||||
|
||||
class TestDiskCache:
|
||||
"""Test DiskCache functionality."""
|
||||
|
||||
@@ -370,36 +324,6 @@ class TestDiskCache:
|
||||
|
||||
# Should handle gracefully
|
||||
assert result is None or isinstance(result, dict)
|
||||
|
||||
def test_record_background_hit(self):
|
||||
"""Test recording background cache hit."""
|
||||
metrics = CacheMetrics()
|
||||
metrics.record_hit(cache_type='background')
|
||||
stats = metrics.get_metrics()
|
||||
|
||||
assert stats['total_requests'] == 1
|
||||
assert stats['background_hit_rate'] == 1.0
|
||||
|
||||
def test_record_background_miss(self):
|
||||
"""Test recording background cache miss."""
|
||||
metrics = CacheMetrics()
|
||||
metrics.record_miss(cache_type='background')
|
||||
stats = metrics.get_metrics()
|
||||
|
||||
assert stats['total_requests'] == 1
|
||||
assert stats['background_hit_rate'] == 0.0
|
||||
|
||||
def test_multiple_fetch_times(self):
|
||||
"""Test recording multiple fetch times."""
|
||||
metrics = CacheMetrics()
|
||||
metrics.record_fetch_time(0.5)
|
||||
metrics.record_fetch_time(1.0)
|
||||
metrics.record_fetch_time(0.3)
|
||||
|
||||
stats = metrics.get_metrics()
|
||||
assert stats['fetch_count'] == 3
|
||||
assert stats['total_fetch_time'] == 1.8
|
||||
assert stats['average_fetch_time'] == pytest.approx(0.6, abs=0.01)
|
||||
|
||||
|
||||
class TestDiskCacheWriteEconomy:
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
"""CacheStrategy intervals, pinned across the whole input grid.
|
||||
|
||||
The strategy table used to carry a per-sport defaults dict whose every value
|
||||
was 60, and a soccer branch identical to its else. These tests pin the
|
||||
returned strategy for every data type x sport key x config shape, so
|
||||
simplifying the lookup cannot change what any caller gets back. They were
|
||||
written against the pre-cleanup code and pass on it unchanged, except for
|
||||
the legacy `<sport>_scoreboard` config shape (see below), which that code
|
||||
still read.
|
||||
was 60, a soccer branch identical to its else, and a config lookup of
|
||||
`<sport>_scoreboard` sections that only the replaced built-in scoreboards
|
||||
had. These tests pin the returned strategy for every data type x sport key,
|
||||
so simplifying the lookup cannot change what any caller gets back.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
@@ -14,46 +12,6 @@ import pytest
|
||||
from src.cache.cache_strategy import CacheStrategy
|
||||
|
||||
|
||||
class _Cfg:
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
|
||||
|
||||
class _NoConfigAttr:
|
||||
pass
|
||||
|
||||
|
||||
# Plugin config sections are keyed by plugin id. Their intervals belong to the
|
||||
# plugin, and the strategy table has never read them.
|
||||
_PLUGIN_ID_CONFIG = {
|
||||
pid: {"live_update_interval": 5, "recent_update_interval": 7,
|
||||
"upcoming_update_interval": 9}
|
||||
for pid in ("football-scoreboard", "basketball-scoreboard",
|
||||
"baseball-scoreboard", "hockey-scoreboard", "soccer-scoreboard")
|
||||
}
|
||||
|
||||
# `<sport>_scoreboard` sections come from the built-in scoreboards the plugin
|
||||
# system replaced. An install upgraded from that era can still carry them in
|
||||
# config.json (nothing deletes them). No current caller passes a sport key to
|
||||
# the strategy, but a stale section must not steer cache TTLs if one does.
|
||||
_LEGACY_SCOREBOARD_CONFIG = {
|
||||
f"{sport}_scoreboard": {"live_update_interval": 5,
|
||||
"recent_update_interval": 7,
|
||||
"upcoming_update_interval": 9}
|
||||
for sport in ("nfl", "nba", "mlb", "nhl", "soccer", "ncaa_fb",
|
||||
"ncaa_baseball", "ncaam_basketball", "milb")
|
||||
}
|
||||
|
||||
CONFIG_MANAGERS = {
|
||||
"no_config_manager": None,
|
||||
"empty_config": _Cfg({}),
|
||||
"plugin_id_config": _Cfg(_PLUGIN_ID_CONFIG),
|
||||
"legacy_scoreboard_config": _Cfg(_LEGACY_SCOREBOARD_CONFIG),
|
||||
"config_is_none": _Cfg(None),
|
||||
"config_is_not_a_dict": _Cfg("x"),
|
||||
"config_manager_without_config": _NoConfigAttr(),
|
||||
}
|
||||
|
||||
SPORT_KEYS = [None, "", "nfl", "nba", "mlb", "nhl", "soccer", "ncaa_fb",
|
||||
"ncaa_baseball", "ncaam_basketball", "milb",
|
||||
"football-scoreboard", "curling"]
|
||||
@@ -93,16 +51,8 @@ def _expected(data_type, sport_key):
|
||||
return FIXED.get(data_type, DEFAULT)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cm_name", sorted(CONFIG_MANAGERS))
|
||||
def test_live_interval_is_60_for_every_sport(cm_name):
|
||||
strategy = CacheStrategy(config_manager=CONFIG_MANAGERS[cm_name])
|
||||
for sport_key in SPORT_KEYS:
|
||||
assert strategy.get_sport_live_interval(sport_key) == 60, sport_key
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cm_name", sorted(CONFIG_MANAGERS))
|
||||
def test_strategy_table_for_every_data_type_and_sport(cm_name):
|
||||
strategy = CacheStrategy(config_manager=CONFIG_MANAGERS[cm_name])
|
||||
def test_strategy_table_for_every_data_type_and_sport():
|
||||
strategy = CacheStrategy()
|
||||
data_types = ["live_scores", "sports_live", *FIXED, "unknown", ""]
|
||||
for data_type in data_types:
|
||||
for sport_key in SPORT_KEYS:
|
||||
|
||||
@@ -32,9 +32,46 @@ DEPRECATED_3_9 = {
|
||||
],
|
||||
}
|
||||
|
||||
#: Deprecated after the October 2026 over-engineering audit, for removal in
|
||||
#: 3.10.0: nothing in core, the monorepo or the registry's third-party plugins
|
||||
#: calls them.
|
||||
DEPRECATED_3_10 = {
|
||||
"src.logo_downloader.LogoDownloader": [
|
||||
"fetch_teams_data", "extract_teams_from_data", "download_missing_logos_for_league",
|
||||
"download_all_ncaa_football_logos", "download_all_missing_logos",
|
||||
"convert_image_to_rgba", "convert_all_logos_to_rgba",
|
||||
],
|
||||
"src.config_manager.ConfigManager": [
|
||||
"rollback_config", "list_backups", "validate_config_file", "get_secret",
|
||||
"cleanup_orphaned_plugin_configs", "validate_all_plugin_configs",
|
||||
],
|
||||
"src.common.api_helper.APIHelper": [
|
||||
"fetch_espn_scoreboard", "fetch_espn_standings", "fetch_espn_rankings",
|
||||
"set_cache", "get_cache", "set_rate_limit", "get_request_stats",
|
||||
],
|
||||
"src.plugin_system.testing.plugin_test_base.PluginTestCase": ["setUp"],
|
||||
"src.background_data_service.BackgroundDataService": [
|
||||
"get_result", "is_request_complete", "get_request_status",
|
||||
],
|
||||
"src.plugin_system.plugin_manager.PluginManager": [
|
||||
"get_all_plugins", "get_plugin_info", "get_all_plugin_info",
|
||||
"get_plugin_display_modes", "find_plugin_for_mode",
|
||||
],
|
||||
"src.plugin_system.plugin_state.PluginStateManager": [
|
||||
"is_loaded", "is_running", "is_error", "get_last_update", "get_error_info",
|
||||
"get_state_info",
|
||||
],
|
||||
"src.cache_manager.CacheManager": ["load_cache", "generate_sport_cache_key"],
|
||||
"src.font_manager.FontManager": ["measure_text", "get_native_bdf_size"],
|
||||
"src.base_odds_manager.BaseOddsManager": ["get_odds_for_games", "format_odds_summary"],
|
||||
"src.dynamic_team_resolver.DynamicTeamResolver": [
|
||||
"get_available_dynamic_teams", "is_dynamic_team",
|
||||
],
|
||||
}
|
||||
|
||||
#: Every pinned marker: (class path, method) -> the release that removes it.
|
||||
PINNED = {(path, name): removal
|
||||
for removal, table in (("3.9.0", DEPRECATED_3_9),)
|
||||
for removal, table in (("3.9.0", DEPRECATED_3_9), ("3.10.0", DEPRECATED_3_10))
|
||||
for path, names in table.items() for name in names}
|
||||
|
||||
|
||||
|
||||
@@ -145,7 +145,6 @@ def vegas_coordinator(controller):
|
||||
coord.render_pipeline.target_fps = float(coord.vegas_config.target_fps)
|
||||
coord.stream_manager = MagicMock()
|
||||
coord.display_manager = controller.display_manager
|
||||
coord.stats = {'cycles_completed': 0, 'interruptions': 0}
|
||||
coord._state_lock = threading.Lock()
|
||||
coord._is_active = True
|
||||
coord._is_paused = False
|
||||
|
||||
@@ -218,24 +218,6 @@ class TestPatternDetection:
|
||||
assert pattern is not None
|
||||
assert pattern.severity in ["error", "critical"]
|
||||
|
||||
def test_pattern_callback_called(self):
|
||||
"""Pattern detection callback should be called."""
|
||||
aggregator = ErrorAggregator(pattern_threshold=2)
|
||||
|
||||
callback_called = []
|
||||
|
||||
def callback(pattern):
|
||||
callback_called.append(pattern)
|
||||
|
||||
aggregator.on_pattern_detected(callback)
|
||||
|
||||
# Trigger pattern
|
||||
for _ in range(3):
|
||||
aggregator.record_error(error=ValueError("Pattern trigger"))
|
||||
|
||||
assert len(callback_called) == 1
|
||||
assert callback_called[0].error_type == "ValueError"
|
||||
|
||||
|
||||
class TestErrorSummary:
|
||||
"""Test error summary generation."""
|
||||
@@ -318,37 +300,6 @@ class TestPluginHealth:
|
||||
assert health["recent_error_count"] == 10
|
||||
|
||||
|
||||
class TestRecordClearing:
|
||||
"""Test clearing old records."""
|
||||
|
||||
def test_clear_old_records(self):
|
||||
"""Old records should be cleared."""
|
||||
aggregator = ErrorAggregator()
|
||||
|
||||
# Add a record
|
||||
aggregator.record_error(error=ValueError("Old error"))
|
||||
|
||||
# Manually age the record
|
||||
aggregator._records[0].timestamp = datetime.now() - timedelta(hours=48)
|
||||
|
||||
# Clear records older than 24 hours
|
||||
cleared = aggregator.clear_old_records(max_age_hours=24)
|
||||
|
||||
assert cleared == 1
|
||||
assert len(aggregator._records) == 0
|
||||
|
||||
def test_recent_records_not_cleared(self):
|
||||
"""Recent records should not be cleared."""
|
||||
aggregator = ErrorAggregator()
|
||||
|
||||
aggregator.record_error(error=ValueError("Recent error"))
|
||||
|
||||
cleared = aggregator.clear_old_records(max_age_hours=24)
|
||||
|
||||
assert cleared == 0
|
||||
assert len(aggregator._records) == 1
|
||||
|
||||
|
||||
class TestThreadSafety:
|
||||
"""Test thread safety of error aggregator."""
|
||||
|
||||
|
||||
@@ -330,9 +330,6 @@ class _Stream:
|
||||
def get_grouped_content_for_composition(self):
|
||||
return self.groups[0]
|
||||
|
||||
def get_active_plugin_ids(self):
|
||||
return [pid for pid, _ in self.groups[0]]
|
||||
|
||||
def take_next_group(self, count=None, offscreen_only=False):
|
||||
if self._i >= len(self.groups):
|
||||
return []
|
||||
|
||||
@@ -60,7 +60,9 @@ class TestRoundTrip:
|
||||
def test_start_args_round_trip(self):
|
||||
args = OnDemandStartArgs(plugin_id='clock', mode='clock_main', duration=45.0,
|
||||
pinned=True)
|
||||
assert OnDemandStartArgs.from_dict(_wire(args.to_dict())) == args
|
||||
wire = _wire({'plugin_id': 'clock', 'mode': 'clock_main', 'duration': 45.0,
|
||||
'pinned': True})
|
||||
assert OnDemandStartArgs.from_dict(wire) == args
|
||||
|
||||
def test_encoded_messages_are_ascii_single_lines(self):
|
||||
data = c.encode_message({'v': 1, 'id': 'x', 'cmd': 'ping',
|
||||
|
||||
@@ -349,7 +349,6 @@ class TestVegasChecksEveryFrame:
|
||||
coord.render_pipeline.frame_interval = 0.0
|
||||
coord.render_pipeline.target_fps = 90
|
||||
coord.display_manager = MagicMock()
|
||||
coord.stats = {'cycles_completed': 0, 'interruptions': 0}
|
||||
coord._state_lock = threading.Lock()
|
||||
coord._is_active = True
|
||||
coord._is_paused = False
|
||||
|
||||
+14
-14
@@ -215,7 +215,7 @@ class TestWhereTheServerListens:
|
||||
assert srv.server_socket_path({}) is None
|
||||
assert ControlServer('x.sock').start() is False
|
||||
with pytest.raises(client.ControlError) as e:
|
||||
client.ping(paths=['x.sock'])
|
||||
client.request(Command.PING, paths=['x.sock'])
|
||||
assert e.value.reason == 'unsupported'
|
||||
|
||||
|
||||
@@ -271,7 +271,7 @@ def _read_line(s):
|
||||
class TestLiveSocket:
|
||||
def test_client_round_trip(self, live, sock_path):
|
||||
live()
|
||||
assert client.ping(paths=[sock_path]) == {'pong': True}
|
||||
assert client.request(Command.PING, paths=[sock_path]) == {'pong': True}
|
||||
assert client.hello(paths=[sock_path])['version'] == 1
|
||||
assert client.on_demand_status(paths=[sock_path])['current_mode'] == 'clock'
|
||||
|
||||
@@ -335,7 +335,7 @@ class TestLiveSocket:
|
||||
assert s.recv(10) == b'' # and hung up
|
||||
finally:
|
||||
s.close()
|
||||
assert client.ping(paths=[sock_path]) == {'pong': True}
|
||||
assert client.request(Command.PING, paths=[sock_path]) == {'pong': True}
|
||||
|
||||
def test_a_client_that_hangs_up_mid_message(self, live, sock_path):
|
||||
server = live()
|
||||
@@ -343,7 +343,7 @@ class TestLiveSocket:
|
||||
s.sendall(b'{"v":1,"id":"half","cmd":"on_demand.st')
|
||||
s.close()
|
||||
time.sleep(0.2)
|
||||
assert client.ping(paths=[sock_path]) == {'pong': True}
|
||||
assert client.request(Command.PING, paths=[sock_path]) == {'pong': True}
|
||||
assert server.drain() == []
|
||||
|
||||
def test_a_slow_client_is_dropped_and_blocks_nobody(self, live, sock_path):
|
||||
@@ -352,7 +352,7 @@ class TestLiveSocket:
|
||||
try:
|
||||
slow.sendall(b'{"v":1,') # ...and never finishes
|
||||
t0 = time.monotonic()
|
||||
assert client.ping(paths=[sock_path]) == {'pong': True}
|
||||
assert client.request(Command.PING, paths=[sock_path]) == {'pong': True}
|
||||
assert time.monotonic() - t0 < 0.5, 'a slow client held up another'
|
||||
assert slow.recv(100) == b'' # hung up on, not answered
|
||||
finally:
|
||||
@@ -380,7 +380,7 @@ class TestLiveSocket:
|
||||
for s in held:
|
||||
s.close()
|
||||
time.sleep(0.3)
|
||||
assert client.ping(paths=[sock_path]) == {'pong': True}
|
||||
assert client.request(Command.PING, paths=[sock_path]) == {'pong': True}
|
||||
|
||||
def test_many_concurrent_clients(self, live, sock_path):
|
||||
server = live(queue_size=64)
|
||||
@@ -408,7 +408,7 @@ class TestLiveSocket:
|
||||
s = ControlServer(sock_path, status_provider=lambda: status)
|
||||
try:
|
||||
assert s.start()
|
||||
assert client.ping(paths=[sock_path]) == {'pong': True}
|
||||
assert client.request(Command.PING, paths=[sock_path]) == {'pong': True}
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
@@ -416,7 +416,7 @@ class TestLiveSocket:
|
||||
live()
|
||||
second = ControlServer(sock_path, status_provider=lambda: status)
|
||||
assert second.start() is False
|
||||
assert client.ping(paths=[sock_path]) == {'pong': True}
|
||||
assert client.request(Command.PING, paths=[sock_path]) == {'pong': True}
|
||||
|
||||
def test_a_regular_file_is_never_removed(self, sock_path):
|
||||
with open(sock_path, 'w') as f:
|
||||
@@ -434,24 +434,24 @@ class TestLiveSocket:
|
||||
assert second.start()
|
||||
try:
|
||||
first.close() # must not unlink second's file
|
||||
assert client.ping(paths=[sock_path]) == {'pong': True}
|
||||
assert client.request(Command.PING, paths=[sock_path]) == {'pong': True}
|
||||
finally:
|
||||
second.close()
|
||||
|
||||
def test_client_reasons(self, sock_path, tmp_path):
|
||||
with pytest.raises(client.ControlError) as e:
|
||||
client.ping(paths=[sock_path])
|
||||
client.request(Command.PING, paths=[sock_path])
|
||||
assert e.value.reason == 'no_socket'
|
||||
dead = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
dead.bind(sock_path)
|
||||
try:
|
||||
with pytest.raises(client.ControlError) as e:
|
||||
client.ping(paths=[sock_path])
|
||||
client.request(Command.PING, paths=[sock_path])
|
||||
assert e.value.reason == 'refused'
|
||||
finally:
|
||||
dead.close()
|
||||
with pytest.raises(client.ControlError) as e:
|
||||
client.ping(paths=[])
|
||||
client.request(Command.PING, paths=[])
|
||||
assert e.value.reason == 'disabled'
|
||||
with pytest.raises(client.ControlError) as e:
|
||||
client.on_demand_start('x', None, None, paths=[sock_path])
|
||||
@@ -464,7 +464,7 @@ class TestLiveSocket:
|
||||
try:
|
||||
t0 = time.monotonic()
|
||||
with pytest.raises(client.ControlError) as e:
|
||||
client.ping(paths=[sock_path], timeout=0.3)
|
||||
client.request(Command.PING, paths=[sock_path], timeout=0.3)
|
||||
assert e.value.reason == 'timeout'
|
||||
assert time.monotonic() - t0 < 1.0
|
||||
finally:
|
||||
@@ -542,7 +542,7 @@ class TestPermissions:
|
||||
os.setgroups([])
|
||||
os.setgid(gid)
|
||||
os.setuid(nobody.pw_uid)
|
||||
result = json.dumps(client.ping(paths=[sock_path]))
|
||||
result = json.dumps(client.request(Command.PING, paths=[sock_path]))
|
||||
except client.ControlError as e:
|
||||
result = 'error:' + e.reason
|
||||
except Exception as e: # report anything else to the parent
|
||||
|
||||
@@ -522,7 +522,7 @@ class TestLiveStream:
|
||||
# max_clients is 2 and three streams are open: subscribers gave
|
||||
# their request slots back.
|
||||
for _ in range(4):
|
||||
assert client.ping(paths=[path]) == {'pong': True}
|
||||
assert client.request(Command.PING, paths=[path]) == {'pong': True}
|
||||
hub.publish('display', _display(mode='weather'), volatile=('last_updated',))
|
||||
assert _wait_until(lambda: all(
|
||||
s.latest()['state']['display']['mode'] == 'weather' for s in subs))
|
||||
|
||||
@@ -54,7 +54,7 @@ def test_second_pending_operation_for_a_plugin_is_refused(op_queue):
|
||||
assert _wait_for(lambda: op_queue.get_operation_status(first).status
|
||||
== OperationStatus.COMPLETED)
|
||||
# Once it has finished, the plugin accepts a new operation again.
|
||||
op_queue.enqueue_operation(OperationType.UPDATE, "demo")
|
||||
op_queue.enqueue_operation(OperationType.UNINSTALL, "demo")
|
||||
|
||||
|
||||
def test_operations_map_is_trimmed_with_history(op_queue):
|
||||
@@ -64,8 +64,8 @@ def test_operations_map_is_trimmed_with_history(op_queue):
|
||||
assert _wait_for(lambda: all(
|
||||
(op_queue.get_operation_status(i) is None
|
||||
or op_queue.get_operation_status(i).status == OperationStatus.COMPLETED)
|
||||
for i in ids) and len(op_queue.get_operation_history()) == 3)
|
||||
for i in ids) and len(op_queue._operation_history) == 3)
|
||||
|
||||
assert len(op_queue._operations) == 3
|
||||
kept = {op.operation_id for op in op_queue.get_operation_history()}
|
||||
kept = {op.operation_id for op in op_queue._operation_history}
|
||||
assert set(op_queue._operations) == kept
|
||||
|
||||
@@ -404,7 +404,6 @@ class TestVegasWiring:
|
||||
off = VegasModeConfig.from_config(
|
||||
{"display": {"vegas_scroll": {"prefetch_gate": False}}})
|
||||
assert off.prefetch_gate is False
|
||||
assert off.to_dict()["prefetch_gate"] is False
|
||||
assert VegasModeConfig.from_config(
|
||||
{"display": {"vegas_scroll": {}}}).prefetch_gate is True
|
||||
|
||||
|
||||
@@ -187,9 +187,6 @@ def test_vegas_extends_without_building_the_image(no_fromarray):
|
||||
def get_grouped_content_for_composition(self):
|
||||
return groups[0]
|
||||
|
||||
def get_active_plugin_ids(self):
|
||||
return ["a"]
|
||||
|
||||
def take_next_group(self, count=None, offscreen_only=False):
|
||||
self.i += 1
|
||||
return groups[self.i] if self.i < len(groups) else []
|
||||
|
||||
+17
-46
@@ -78,7 +78,6 @@ def make_manager(role=SyncRole.STANDALONE, hw_config=None):
|
||||
mgr._last_leader_frame_time = 0.0
|
||||
mgr._frame_lock = threading.Lock()
|
||||
mgr._leader_ip = None
|
||||
mgr._on_new_cycle = None
|
||||
mgr._on_scroll_image = None
|
||||
mgr._pending_scroll_image = None
|
||||
mgr._scroll_image_lock = threading.Lock()
|
||||
@@ -495,31 +494,35 @@ class TestFollowerRecvLoop:
|
||||
assert mgr._peer_compatible is True
|
||||
assert mgr.logger.error.called is False
|
||||
|
||||
def test_scroll_x_switches_to_follower_and_builds_cycle(self):
|
||||
def test_scroll_x_switches_to_follower(self):
|
||||
mgr = make_manager(role=SyncRole.FOLLOWER)
|
||||
calls = []
|
||||
mgr._on_new_cycle = lambda: calls.append(1)
|
||||
self._drive(mgr, json.dumps({"t": "sx", "x": 12.34}).encode())
|
||||
assert mgr._follower_state is FollowerState.FOLLOWER
|
||||
assert mgr.get_latest_scroll_x() == 12.34
|
||||
assert calls == [1]
|
||||
|
||||
def test_scroll_x_while_already_following_does_not_rebuild(self):
|
||||
def test_scroll_x_while_already_following_updates_the_position(self):
|
||||
mgr = make_manager(role=SyncRole.FOLLOWER)
|
||||
mgr._follower_state = FollowerState.FOLLOWER
|
||||
calls = []
|
||||
mgr._on_new_cycle = lambda: calls.append(1)
|
||||
self._drive(mgr, json.dumps({"t": "sx", "x": 5.0}).encode())
|
||||
assert mgr.get_latest_scroll_x() == 5.0
|
||||
assert calls == []
|
||||
|
||||
def test_new_cycle_message_triggers_callback(self):
|
||||
@pytest.mark.parametrize("payload", [{"t": "nc"}, {"t": "some-future-type"}])
|
||||
def test_an_older_or_newer_leaders_message_is_ignored(self, payload):
|
||||
# Older leaders send "nc" at each new cycle. Nothing uses it, and a
|
||||
# follower must take it -- or any type it does not know -- quietly:
|
||||
# not as a frame, not as a malformed packet, no error back-off.
|
||||
mgr = make_manager(role=SyncRole.FOLLOWER)
|
||||
mgr._follower_state = FollowerState.FOLLOWER
|
||||
calls = []
|
||||
mgr._on_new_cycle = lambda: calls.append(1)
|
||||
self._drive(mgr, json.dumps({"t": "nc"}).encode())
|
||||
assert calls == [1]
|
||||
sleeps = MagicMock()
|
||||
with patch.object(sync_manager, "time",
|
||||
SimpleNamespace(time=time.time, monotonic=time.monotonic,
|
||||
sleep=sleeps)):
|
||||
self._drive(mgr, json.dumps(payload).encode())
|
||||
assert mgr._follower_state is FollowerState.FOLLOWER
|
||||
assert mgr.get_latest_frame() is None
|
||||
assert mgr.get_latest_scroll_x() is None
|
||||
assert not mgr.logger.debug.called
|
||||
sleeps.assert_not_called()
|
||||
|
||||
def test_non_object_json_does_not_reach_the_outer_handler(self):
|
||||
# A bare JSON scalar parses, then msg.get() raises AttributeError.
|
||||
@@ -547,28 +550,6 @@ class TestFollowerRecvLoop:
|
||||
assert mgr.get_latest_scroll_x() is None
|
||||
sleeps.assert_not_called()
|
||||
|
||||
def test_callback_failure_is_not_mistaken_for_a_malformed_packet(self, monkeypatch):
|
||||
# A payload that parses is a control message, full stop. If the
|
||||
# callback it triggers raises one of the types the field guard
|
||||
# catches, that fault belongs to the callback: it must not send
|
||||
# the packet to the image decoder, which would report it as a
|
||||
# decode error and bury the real cause. The loop still survives
|
||||
# it — the outer handler catches it like any other fault.
|
||||
mgr = make_manager(role=SyncRole.FOLLOWER)
|
||||
mgr._follower_state = FollowerState.FOLLOWER
|
||||
|
||||
def boom():
|
||||
raise ValueError("callback is broken")
|
||||
|
||||
mgr._on_new_cycle = boom
|
||||
fake_clock(monkeypatch, sleep_fn=MagicMock())
|
||||
self._drive(mgr, json.dumps({"t": "nc"}).encode())
|
||||
|
||||
logged = " | ".join(str(c) for c in mgr.logger.debug.call_args_list)
|
||||
assert "callback is broken" in logged
|
||||
assert "frame decode error" not in logged
|
||||
assert "malformed control message" not in logged
|
||||
|
||||
def test_oversized_legacy_frame_is_rejected_before_decode(self, monkeypatch):
|
||||
# The UDP path is reachable by any host on the LAN, so it caps
|
||||
# dimensions before load() just as the TCP image server does.
|
||||
@@ -603,12 +584,9 @@ class TestFollowerRecvLoop:
|
||||
for payload in (b'{"t": "sx", "x": ' + literal.encode() + b'}',
|
||||
json.dumps({"t": "sx", "x": literal}).encode()):
|
||||
mgr = make_manager(role=SyncRole.FOLLOWER)
|
||||
calls = []
|
||||
mgr._on_new_cycle = lambda: calls.append(1)
|
||||
self._drive(mgr, payload)
|
||||
assert mgr.get_latest_scroll_x() is None
|
||||
assert mgr._follower_state is FollowerState.STANDALONE
|
||||
assert calls == []
|
||||
|
||||
def test_non_finite_scroll_x_leaves_a_good_value_in_place(self):
|
||||
# The reject must not clear the last usable position either — a
|
||||
@@ -697,17 +675,10 @@ class TestSendControlMessages:
|
||||
msg = json.loads(mgr._send_sock.sendto.call_args[0][0].decode())
|
||||
assert msg == {"t": "sx", "x": 3.14}
|
||||
|
||||
def test_send_new_cycle(self):
|
||||
mgr = self._connected_leader()
|
||||
mgr.send_new_cycle()
|
||||
msg = json.loads(mgr._send_sock.sendto.call_args[0][0].decode())
|
||||
assert msg == {"t": "nc"}
|
||||
|
||||
def test_control_messages_noop_when_disconnected(self):
|
||||
mgr = self._connected_leader()
|
||||
mgr._leader_state = LeaderState.NO_PEER
|
||||
mgr.send_scroll_x(1.0)
|
||||
mgr.send_new_cycle()
|
||||
assert not mgr._send_sock.sendto.called
|
||||
|
||||
def test_set_leader_width(self):
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""
|
||||
Tests for src/vegas_mode/config.py
|
||||
|
||||
Covers VegasModeConfig: from_config, to_dict, get_frame_interval,
|
||||
Covers VegasModeConfig: from_config, get_frame_interval,
|
||||
get_ordered_plugins, validate.
|
||||
"""
|
||||
|
||||
import dataclasses
|
||||
|
||||
import pytest
|
||||
from src.vegas_mode.config import VegasModeConfig
|
||||
|
||||
@@ -49,7 +51,8 @@ class TestVegasModeConfigDefaults:
|
||||
(Path(__file__).resolve().parent.parent / "config"
|
||||
/ "config.template.json").read_text(encoding="utf-8"))
|
||||
shipped = template["display"]["vegas_scroll"]
|
||||
defaults = VegasModeConfig().to_dict()
|
||||
defaults = dataclasses.asdict(VegasModeConfig())
|
||||
defaults["excluded_plugins"] = sorted(defaults["excluded_plugins"])
|
||||
mismatched = {k: (v, defaults[k]) for k, v in shipped.items()
|
||||
if k in defaults and defaults[k] != v}
|
||||
assert not mismatched, f"template vs code default: {mismatched}"
|
||||
@@ -57,9 +60,9 @@ class TestVegasModeConfigDefaults:
|
||||
def test_missing_keys_read_the_field_defaults(self):
|
||||
# from_config used to repeat every default; with no keys set it must
|
||||
# produce exactly the dataclass defaults.
|
||||
assert VegasModeConfig.from_config({}).to_dict() == VegasModeConfig().to_dict()
|
||||
assert (VegasModeConfig.from_config({"display": {"vegas_scroll": {}}}).to_dict()
|
||||
== VegasModeConfig().to_dict())
|
||||
assert VegasModeConfig.from_config({}) == VegasModeConfig()
|
||||
assert (VegasModeConfig.from_config({"display": {"vegas_scroll": {}}})
|
||||
== VegasModeConfig())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -114,42 +117,6 @@ class TestFromConfig:
|
||||
assert cfg.frame_based_scrolling is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# to_dict
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestToDict:
|
||||
def test_roundtrip(self):
|
||||
original = VegasModeConfig(
|
||||
enabled=True,
|
||||
scroll_speed=75.0,
|
||||
separator_width=24,
|
||||
plugin_order=["a", "b"],
|
||||
excluded_plugins={"z"},
|
||||
target_fps=100,
|
||||
)
|
||||
d = original.to_dict()
|
||||
assert d["enabled"] is True
|
||||
assert d["scroll_speed"] == 75.0
|
||||
assert d["separator_width"] == 24
|
||||
assert d["plugin_order"] == ["a", "b"]
|
||||
assert "z" in d["excluded_plugins"]
|
||||
assert d["target_fps"] == 100
|
||||
|
||||
def test_excluded_plugins_is_list(self):
|
||||
cfg = VegasModeConfig(excluded_plugins={"x"})
|
||||
d = cfg.to_dict()
|
||||
assert isinstance(d["excluded_plugins"], list)
|
||||
|
||||
def test_all_keys_present(self):
|
||||
d = VegasModeConfig().to_dict()
|
||||
for key in ("enabled", "scroll_speed", "separator_width", "plugin_order",
|
||||
"excluded_plugins", "target_fps", "buffer_ahead",
|
||||
"frame_based_scrolling", "scroll_delay",
|
||||
"dynamic_duration_enabled", "min_cycle_duration", "max_cycle_duration"):
|
||||
assert key in d
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_frame_interval
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -189,7 +189,6 @@ class TestCoordinatorWiring:
|
||||
coordinator.render_pipeline.is_cycle_complete.return_value = False
|
||||
coordinator.render_pipeline.should_recompose.return_value = False
|
||||
coordinator.stream_manager = MagicMock()
|
||||
coordinator.stats = {'cycles_completed': 0}
|
||||
coordinator._state_lock = threading.Lock()
|
||||
coordinator._is_active = True
|
||||
coordinator._is_paused = False
|
||||
|
||||
@@ -25,7 +25,6 @@ def _coordinator(active=True):
|
||||
c.render_pipeline.is_cycle_complete.return_value = False
|
||||
c.stream_manager = MagicMock()
|
||||
c.plugin_adapter = MagicMock()
|
||||
c.stats = {'cycles_completed': 0, 'config_updates': 0}
|
||||
c._state_lock = threading.Lock()
|
||||
c._is_active = active
|
||||
c._is_paused = False
|
||||
|
||||
@@ -27,7 +27,6 @@ def _coordinator(plugins):
|
||||
coord.stream_manager = MagicMock()
|
||||
coord.display_manager = MagicMock()
|
||||
coord.plugin_manager = SimpleNamespace(plugins=plugins, get_plugin=plugins.get)
|
||||
coord.stats = {'cycles_completed': 0, 'interruptions': 0}
|
||||
coord._state_lock = threading.Lock()
|
||||
coord._is_active = True
|
||||
coord._is_paused = False
|
||||
@@ -99,7 +98,6 @@ def test_vegas_resumes_after_a_live_priority_pause():
|
||||
|
||||
def test_stop_clears_a_live_priority_pause():
|
||||
coord = _live_coordinator(['nfl_live'])
|
||||
coord._start_time = None
|
||||
coord._restore_switch_interval = lambda: None
|
||||
coord._remove_render_gate = lambda: None
|
||||
coord.run_iteration()
|
||||
|
||||
@@ -26,9 +26,6 @@ class FakeStream:
|
||||
def get_grouped_content_for_composition(self):
|
||||
return [('a', [Image.new('RGB', (4000, H), (255, 255, 255))])]
|
||||
|
||||
def get_active_plugin_ids(self):
|
||||
return ['a']
|
||||
|
||||
|
||||
class FakeDM:
|
||||
width = W
|
||||
|
||||
@@ -3,6 +3,7 @@ Tests for the Vegas mode density work: dead-space trimming in PluginAdapter
|
||||
and the configurable lead-in gap in ScrollHelper.
|
||||
"""
|
||||
|
||||
import dataclasses
|
||||
from contextlib import contextmanager
|
||||
|
||||
import pytest
|
||||
@@ -298,9 +299,6 @@ class TestPluginBoundaryGaps:
|
||||
def get_grouped_content_for_composition(self):
|
||||
return grouped
|
||||
|
||||
def get_active_plugin_ids(self):
|
||||
return [pid for pid, _ in grouped]
|
||||
|
||||
class DM:
|
||||
width = DISPLAY_W
|
||||
height = DISPLAY_H
|
||||
@@ -615,10 +613,10 @@ class TestConfigSurface:
|
||||
assert cfg.lead_in_width == 0
|
||||
assert cfg.content_padding == 8
|
||||
|
||||
def test_round_trips_through_to_dict(self):
|
||||
def test_round_trips_through_asdict(self):
|
||||
cfg = VegasModeConfig(trim_threshold=20, lead_in_width=64)
|
||||
restored = VegasModeConfig.from_config(
|
||||
{'display': {'vegas_scroll': cfg.to_dict()}})
|
||||
{'display': {'vegas_scroll': dataclasses.asdict(cfg)}})
|
||||
assert restored.trim_threshold == 20
|
||||
assert restored.lead_in_width == 64
|
||||
|
||||
@@ -698,9 +696,6 @@ class TestMeasuredSeparation:
|
||||
def get_grouped_content_for_composition(self):
|
||||
return grouped
|
||||
|
||||
def get_active_plugin_ids(self):
|
||||
return [pid for pid, _ in grouped]
|
||||
|
||||
class DM:
|
||||
width = DISPLAY_W
|
||||
height = DISPLAY_H
|
||||
@@ -816,9 +811,6 @@ class TestCycleEndsBeforeWrap:
|
||||
def get_grouped_content_for_composition(self):
|
||||
return [('a', [Image.new('RGB', (strip_width, DISPLAY_H), (255, 255, 255))])]
|
||||
|
||||
def get_active_plugin_ids(self):
|
||||
return ['a']
|
||||
|
||||
class DM:
|
||||
width = DISPLAY_W
|
||||
height = DISPLAY_H
|
||||
@@ -962,9 +954,6 @@ class TestBudgetUsesMeasuredGaps:
|
||||
def get_grouped_content_for_composition(self):
|
||||
return [('rows', selected)]
|
||||
|
||||
def get_active_plugin_ids(self):
|
||||
return ['rows']
|
||||
|
||||
class DM:
|
||||
width = DISPLAY_W
|
||||
height = DISPLAY_H
|
||||
@@ -1258,9 +1247,6 @@ class TestContinuousExtension:
|
||||
def get_grouped_content_for_composition(self):
|
||||
return groups[0] if groups else []
|
||||
|
||||
def get_active_plugin_ids(self):
|
||||
return [pid for pid, _ in (groups[0] if groups else [])]
|
||||
|
||||
def take_next_group(self, count=None, offscreen_only=False):
|
||||
self.calls.append(offscreen_only)
|
||||
if self._i >= len(groups):
|
||||
@@ -1450,9 +1436,6 @@ class TestDeferredDraining:
|
||||
def get_grouped_content_for_composition(self):
|
||||
return [('seed', [Image.new('RGB', (600, DISPLAY_H), (255, 255, 255))])]
|
||||
|
||||
def get_active_plugin_ids(self):
|
||||
return ['seed']
|
||||
|
||||
def take_next_group(self, count=None, offscreen_only=False):
|
||||
if self._served:
|
||||
return []
|
||||
|
||||
@@ -49,9 +49,6 @@ class _Stream:
|
||||
def get_grouped_content_for_composition(self):
|
||||
return self.groups[0]
|
||||
|
||||
def get_active_plugin_ids(self):
|
||||
return [pid for pid, _ in self.groups[0]]
|
||||
|
||||
def take_next_group(self, count=None, offscreen_only=False):
|
||||
self.i += 1
|
||||
return self.groups[self.i] if self.i < len(self.groups) else []
|
||||
|
||||
@@ -43,9 +43,6 @@ class _Stream:
|
||||
def get_grouped_content_for_composition(self):
|
||||
return self.groups[0]
|
||||
|
||||
def get_active_plugin_ids(self):
|
||||
return ["p"]
|
||||
|
||||
def take_next_group(self, count=None, offscreen_only=False):
|
||||
self.i += 1
|
||||
return self.groups[self.i] if self.i < len(self.groups) else []
|
||||
|
||||
@@ -189,7 +189,7 @@ def test_with_live_refresh_off_the_same_run_is_plain_content(dm, tmp_path, ticke
|
||||
start_width = pipeline.scroll_helper.total_scroll_width
|
||||
assert _run_until(
|
||||
coordinator,
|
||||
lambda: pipeline.stats.get('extensions', 0) >= 1, seconds=10.0)
|
||||
lambda: pipeline.extensions >= 1, seconds=10.0)
|
||||
assert pipeline.live_records() == ()
|
||||
assert np.asarray(pipeline.scroll_helper.cached_array).shape[1] > 0
|
||||
assert start_width > 0
|
||||
|
||||
@@ -46,9 +46,6 @@ class _Stream:
|
||||
def get_grouped_content_for_composition(self):
|
||||
return self.first
|
||||
|
||||
def get_active_plugin_ids(self):
|
||||
return ["p"]
|
||||
|
||||
def take_next_group(self, count=None, offscreen_only=False):
|
||||
self.taken += 1
|
||||
return [("q", [_image(40, 99)])]
|
||||
|
||||
@@ -67,9 +67,6 @@ class _Stream:
|
||||
def get_grouped_content_for_composition(self):
|
||||
return self.groups[0]
|
||||
|
||||
def get_active_plugin_ids(self):
|
||||
return []
|
||||
|
||||
def take_next_group(self, count=None, offscreen_only=False):
|
||||
self.i += 1
|
||||
return self.groups[self.i] if self.i < len(self.groups) else []
|
||||
|
||||
@@ -65,9 +65,6 @@ class _Stream:
|
||||
def get_grouped_content_for_composition(self):
|
||||
return self.groups[0]
|
||||
|
||||
def get_active_plugin_ids(self):
|
||||
return []
|
||||
|
||||
def take_next_group(self, count=None, offscreen_only=False):
|
||||
self.i += 1
|
||||
return self.groups[self.i] if self.i < len(self.groups) else []
|
||||
|
||||
@@ -52,9 +52,6 @@ class FakeStream:
|
||||
def get_static_layout(self):
|
||||
return [(pid, w is None) for pid, w in self.layout]
|
||||
|
||||
def get_active_plugin_ids(self):
|
||||
return [pid for pid, _ in self.layout]
|
||||
|
||||
def is_static_plugin(self, plugin_id):
|
||||
return plugin_id in self.statics
|
||||
|
||||
@@ -163,7 +160,6 @@ class TestStreamManagerSkipsStaticContent:
|
||||
sm.plugin_manager = SimpleNamespace(plugins=plugins)
|
||||
sm.plugin_adapter = MagicMock()
|
||||
sm.plugin_adapter.get_content.return_value = [block(10)]
|
||||
sm.stats = {'segments_fetched': 0, 'fetch_errors': 0}
|
||||
sm.refresh = lambda: None
|
||||
return sm
|
||||
|
||||
@@ -213,7 +209,6 @@ class TestCoordinatorStaticPause:
|
||||
coord._live_priority_active = False
|
||||
coord._live_priority_check = None
|
||||
coord._interrupt_check = None
|
||||
coord.stats = {'static_pauses': 0}
|
||||
return coord
|
||||
|
||||
def _plugin(self):
|
||||
|
||||
@@ -174,7 +174,7 @@ def test_widget_bundle_is_served_and_requested(client):
|
||||
assert resp.mimetype == "application/javascript"
|
||||
body = resp.get_data(as_text=True)
|
||||
assert body.index("/* registry.js */") < body.index("/* notification.js */")
|
||||
assert "/* json-file-manager.js */" in body
|
||||
assert "/* plugin-loader.js */" in body
|
||||
|
||||
|
||||
def test_durations_page_groups_by_plugin(client):
|
||||
|
||||
@@ -66,31 +66,6 @@ class TestPluginOperationsIntegration(unittest.TestCase):
|
||||
history = self.operation_history.get_history(plugin_id=plugin_id)
|
||||
self.assertEqual([r.operation_type for r in history], ["install"])
|
||||
|
||||
def test_update_operation_flow(self):
|
||||
"""Test complete update operation flow."""
|
||||
plugin_id = "test-plugin"
|
||||
|
||||
# Enqueue update operation
|
||||
operation_id = self.operation_queue.enqueue_operation(
|
||||
OperationType.UPDATE,
|
||||
plugin_id,
|
||||
{"from_version": "1.0.0", "to_version": "2.0.0"}
|
||||
)
|
||||
|
||||
self.assertIsNotNone(operation_id)
|
||||
|
||||
# Record in history
|
||||
self.operation_history.record_operation(
|
||||
operation_type="update",
|
||||
plugin_id=plugin_id,
|
||||
status="in_progress",
|
||||
operation_id=operation_id
|
||||
)
|
||||
|
||||
# Verify history
|
||||
history = self.operation_history.get_history(plugin_id=plugin_id)
|
||||
self.assertEqual([r.operation_type for r in history], ["update"])
|
||||
|
||||
def test_uninstall_operation_flow(self):
|
||||
"""Test complete uninstall operation flow."""
|
||||
plugin_id = "test-plugin"
|
||||
@@ -162,7 +137,7 @@ class TestPluginOperationsIntegration(unittest.TestCase):
|
||||
# The prevention only works for truly concurrent (pending/running) operations
|
||||
try:
|
||||
op2_id = self.operation_queue.enqueue_operation(
|
||||
OperationType.UPDATE,
|
||||
OperationType.UNINSTALL,
|
||||
plugin_id
|
||||
)
|
||||
# If no exception, the first operation may have completed already
|
||||
|
||||
@@ -22,7 +22,6 @@ from web_interface.blueprints.api_v3 import ( # noqa: E402
|
||||
_is_plugin_update_available,
|
||||
_coerce_to_bool,
|
||||
deep_merge,
|
||||
_parse_form_value,
|
||||
_get_schema_property,
|
||||
_set_nested_value,
|
||||
_SKIP_FIELD,
|
||||
@@ -116,44 +115,6 @@ class TestDeepMerge:
|
||||
assert result["keep"] is base["keep"] # untouched subtree is shared
|
||||
|
||||
|
||||
class TestParseFormValue:
|
||||
def test_boolean_strings(self):
|
||||
assert _parse_form_value("true") is True
|
||||
assert _parse_form_value("False") is False
|
||||
|
||||
def test_null_like_strings(self):
|
||||
assert _parse_form_value("null") is None
|
||||
assert _parse_form_value("none") is None
|
||||
assert _parse_form_value("") is None
|
||||
|
||||
def test_none_passthrough(self):
|
||||
assert _parse_form_value(None) is None
|
||||
|
||||
def test_numbers(self):
|
||||
assert _parse_form_value("42") == 42
|
||||
assert isinstance(_parse_form_value("42"), int)
|
||||
assert _parse_form_value("3.5") == 3.5
|
||||
assert isinstance(_parse_form_value("3.5"), float)
|
||||
|
||||
def test_json_array_parsed_before_numbers(self):
|
||||
# RGB arrays like "[255, 0, 0]" must come back as lists.
|
||||
assert _parse_form_value("[255, 0, 0]") == [255, 0, 0]
|
||||
|
||||
def test_json_object(self):
|
||||
assert _parse_form_value('{"a": 1}') == {"a": 1}
|
||||
|
||||
def test_malformed_json_falls_back_to_string(self):
|
||||
assert _parse_form_value("[not json") == "[not json"
|
||||
|
||||
def test_plain_string_returned_unstripped(self):
|
||||
# The original value (not the stripped copy) is returned.
|
||||
assert _parse_form_value(" hello ") == " hello "
|
||||
|
||||
def test_non_string_passthrough(self):
|
||||
assert _parse_form_value(7) == 7
|
||||
assert _parse_form_value([1, 2]) == [1, 2]
|
||||
|
||||
|
||||
class TestGetSchemaProperty:
|
||||
SCHEMA: ClassVar[Dict[str, Any]] = {
|
||||
"properties": {
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
"""
|
||||
Tests for the response builders in src/web_interface/error_handler.py and
|
||||
the success path in src/web_interface/api_helpers.py.
|
||||
Tests for the response builders in src/web_interface/api_helpers.py.
|
||||
|
||||
describe_exception() in the same module is already covered by
|
||||
describe_exception() in error_handler.py is already covered by
|
||||
test/test_web_error_detail.py and is not duplicated here.
|
||||
|
||||
Regression coverage for one fixed bug: create_success_response used
|
||||
Regression coverage for one fixed bug: the success builder used
|
||||
truthiness for `message` and `metadata` while using `is not None` for
|
||||
`data`, so an explicitly-passed "" or {} was silently dropped —
|
||||
api_helpers.success_response() repeated the same gate, which is the path
|
||||
every api_v3 endpoint actually calls.
|
||||
success_response() repeated the same gate, which is the path every api_v3
|
||||
endpoint actually calls.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from src.web_interface.api_helpers import exception_error_response, success_response
|
||||
from src.web_interface.error_handler import (
|
||||
create_error_response,
|
||||
create_success_response,
|
||||
from src.web_interface.api_helpers import (
|
||||
error_response,
|
||||
exception_error_response,
|
||||
success_response,
|
||||
)
|
||||
from src.web_interface.errors import ErrorCode, WebInterfaceError
|
||||
|
||||
@@ -28,23 +27,23 @@ def app():
|
||||
return Flask(__name__)
|
||||
|
||||
|
||||
class TestCreateErrorResponse:
|
||||
class TestErrorResponse:
|
||||
def test_returns_response_and_status_tuple(self, app):
|
||||
with app.test_request_context():
|
||||
response, status = create_error_response(
|
||||
response, status = error_response(
|
||||
ErrorCode.CONFIG_SAVE_FAILED, "could not save")
|
||||
assert status == 500
|
||||
assert response.get_json()["message"] == "could not save"
|
||||
|
||||
def test_status_code_passthrough(self, app):
|
||||
with app.test_request_context():
|
||||
_, status = create_error_response(
|
||||
_, status = error_response(
|
||||
ErrorCode.INVALID_INPUT, "bad", status_code=400)
|
||||
assert status == 400
|
||||
|
||||
def test_body_matches_the_error_dataclass(self, app):
|
||||
with app.test_request_context():
|
||||
response, _ = create_error_response(
|
||||
response, _ = error_response(
|
||||
ErrorCode.NETWORK_ERROR, "offline",
|
||||
details="connection refused", context={"url": "http://x"})
|
||||
expected = WebInterfaceError(
|
||||
@@ -54,12 +53,12 @@ class TestCreateErrorResponse:
|
||||
|
||||
def test_none_context_produces_no_context_key(self, app):
|
||||
with app.test_request_context():
|
||||
response, _ = create_error_response(ErrorCode.SYSTEM_ERROR, "boom")
|
||||
response, _ = error_response(ErrorCode.SYSTEM_ERROR, "boom")
|
||||
assert "context" not in response.get_json()
|
||||
|
||||
def test_suggested_fixes_passed_through(self, app):
|
||||
with app.test_request_context():
|
||||
response, _ = create_error_response(
|
||||
response, _ = error_response(
|
||||
ErrorCode.SYSTEM_ERROR, "boom", suggested_fixes=["Try again"])
|
||||
assert response.get_json()["suggested_fixes"] == ["Try again"]
|
||||
|
||||
@@ -74,7 +73,6 @@ class TestExceptionErrorResponse:
|
||||
|
||||
@staticmethod
|
||||
def _by_hand(exc, code, with_context):
|
||||
from src.web_interface.api_helpers import error_response
|
||||
error = WebInterfaceError.from_exception(exc, code)
|
||||
if with_context:
|
||||
return error_response(error.error_code, error.message,
|
||||
@@ -117,39 +115,45 @@ class TestExceptionErrorResponse:
|
||||
assert "context" not in response.get_json()
|
||||
|
||||
|
||||
class TestCreateSuccessResponse:
|
||||
def _success_body(**kwargs):
|
||||
"""success_response()'s body, outside any request timing."""
|
||||
with Flask(__name__).test_request_context():
|
||||
return success_response(**kwargs).get_json()
|
||||
|
||||
|
||||
class TestSuccessResponseBody:
|
||||
def test_bare_success(self):
|
||||
assert create_success_response() == {"status": "success"}
|
||||
assert _success_body() == {"status": "success"}
|
||||
|
||||
def test_data_included(self):
|
||||
assert create_success_response(data={"a": 1})["data"] == {"a": 1}
|
||||
assert _success_body(data={"a": 1})["data"] == {"a": 1}
|
||||
|
||||
@pytest.mark.parametrize("falsy", [0, "", False, {}, []])
|
||||
def test_falsy_data_is_still_included(self, falsy):
|
||||
assert create_success_response(data=falsy)["data"] == falsy
|
||||
assert _success_body(data=falsy)["data"] == falsy
|
||||
|
||||
def test_none_data_omitted(self):
|
||||
assert "data" not in create_success_response(data=None)
|
||||
assert "data" not in _success_body(data=None)
|
||||
|
||||
def test_message_included(self):
|
||||
assert create_success_response(message="done")["message"] == "done"
|
||||
assert _success_body(message="done")["message"] == "done"
|
||||
|
||||
def test_empty_message_is_still_included(self):
|
||||
# Regression: `if message:` dropped an explicitly-passed "".
|
||||
assert create_success_response(message="")["message"] == ""
|
||||
assert _success_body(message="")["message"] == ""
|
||||
|
||||
def test_none_message_omitted(self):
|
||||
assert "message" not in create_success_response(message=None)
|
||||
assert "message" not in _success_body(message=None)
|
||||
|
||||
def test_metadata_included(self):
|
||||
assert create_success_response(metadata={"v": 1})["metadata"] == {"v": 1}
|
||||
assert _success_body(metadata={"v": 1})["metadata"] == {"v": 1}
|
||||
|
||||
def test_empty_metadata_is_still_included(self):
|
||||
# Regression: `if metadata:` dropped an explicitly-passed {}.
|
||||
assert create_success_response(metadata={})["metadata"] == {}
|
||||
assert _success_body(metadata={})["metadata"] == {}
|
||||
|
||||
def test_none_metadata_omitted(self):
|
||||
assert "metadata" not in create_success_response(metadata=None)
|
||||
assert "metadata" not in _success_body(metadata=None)
|
||||
|
||||
|
||||
class TestSuccessResponseHelper:
|
||||
@@ -162,8 +166,8 @@ class TestSuccessResponseHelper:
|
||||
|
||||
def test_explicit_empty_metadata_survives_the_wrapper(self, app):
|
||||
# Regression: the wrapper re-gated metadata on truthiness after
|
||||
# create_success_response had already included it, so {} was
|
||||
# dropped again on the way out.
|
||||
# the body builder had already included it, so {} was dropped
|
||||
# again on the way out.
|
||||
with app.test_request_context():
|
||||
body = success_response(data=None, metadata={}).get_json()
|
||||
assert body["metadata"] == {}
|
||||
|
||||
@@ -55,7 +55,7 @@ class TestPluginOperationQueue(unittest.TestCase):
|
||||
# behavior may differ. For this test, we'll verify the mechanism exists.
|
||||
try:
|
||||
self.queue.enqueue_operation(
|
||||
OperationType.UPDATE,
|
||||
OperationType.UNINSTALL,
|
||||
"test-plugin"
|
||||
)
|
||||
# If no exception, the first operation may have completed
|
||||
@@ -64,21 +64,6 @@ class TestPluginOperationQueue(unittest.TestCase):
|
||||
# Expected behavior - concurrent operation prevented
|
||||
pass
|
||||
|
||||
def test_operation_cancellation(self):
|
||||
"""Test cancelling a pending operation."""
|
||||
operation_id = self.queue.enqueue_operation(
|
||||
OperationType.INSTALL,
|
||||
"test-plugin"
|
||||
)
|
||||
|
||||
# Cancel operation
|
||||
success = self.queue.cancel_operation(operation_id)
|
||||
self.assertTrue(success)
|
||||
|
||||
# Check status
|
||||
operation = self.queue.get_operation_status(operation_id)
|
||||
self.assertEqual(operation.status, OperationStatus.CANCELLED)
|
||||
|
||||
def test_operation_history(self):
|
||||
"""Test operation history tracking."""
|
||||
# Enqueue and complete an operation
|
||||
@@ -92,7 +77,7 @@ class TestPluginOperationQueue(unittest.TestCase):
|
||||
time.sleep(0.5)
|
||||
|
||||
# Check history
|
||||
history = self.queue.get_operation_history(limit=10)
|
||||
history = self.queue._operation_history
|
||||
self.assertGreater(len(history), 0)
|
||||
|
||||
# Find our operation in history
|
||||
|
||||
@@ -100,7 +100,6 @@ class TestStateReconciliation(unittest.TestCase):
|
||||
inconsistency = result.inconsistencies_found[0]
|
||||
self.assertEqual(inconsistency.plugin_id, "plugin1")
|
||||
self.assertEqual(inconsistency.inconsistency_type, InconsistencyType.PLUGIN_MISSING_IN_CONFIG)
|
||||
self.assertTrue(inconsistency.can_auto_fix)
|
||||
self.assertEqual(inconsistency.fix_action, FixAction.AUTO_FIX)
|
||||
|
||||
def test_plugin_missing_on_disk(self):
|
||||
@@ -115,7 +114,6 @@ class TestStateReconciliation(unittest.TestCase):
|
||||
inconsistency = result.inconsistencies_found[0]
|
||||
self.assertEqual(inconsistency.plugin_id, "plugin1")
|
||||
self.assertEqual(inconsistency.inconsistency_type, InconsistencyType.PLUGIN_MISSING_ON_DISK)
|
||||
self.assertFalse(inconsistency.can_auto_fix)
|
||||
self.assertEqual(inconsistency.fix_action, FixAction.MANUAL_FIX_REQUIRED)
|
||||
|
||||
def test_enabled_but_not_loaded_is_reported_not_fixed(self):
|
||||
@@ -139,7 +137,6 @@ class TestStateReconciliation(unittest.TestCase):
|
||||
inconsistency = result.inconsistencies_found[0]
|
||||
self.assertEqual(inconsistency.inconsistency_type, InconsistencyType.PLUGIN_ENABLED_MISMATCH)
|
||||
self.assertEqual(inconsistency.fix_action, FixAction.NO_ACTION)
|
||||
self.assertFalse(inconsistency.can_auto_fix)
|
||||
self.assertIn("No module named", inconsistency.description)
|
||||
self.assertEqual(result.inconsistencies_fixed, [])
|
||||
self.assertEqual(result.inconsistencies_manual, [])
|
||||
@@ -410,7 +407,6 @@ class TestStateReconciliationUnrecoverable(unittest.TestCase):
|
||||
# Still one inconsistency, still no install attempt, no new registry fetch
|
||||
self.assertEqual(len(result.inconsistencies_found), 1)
|
||||
inc = result.inconsistencies_found[0]
|
||||
self.assertFalse(inc.can_auto_fix)
|
||||
self.assertEqual(inc.fix_action, FixAction.MANUAL_FIX_REQUIRED)
|
||||
self.store_manager.install_plugin.assert_not_called()
|
||||
self.store_manager.fetch_registry.assert_not_called()
|
||||
@@ -458,7 +454,6 @@ class TestStateReconciliationUnrecoverable(unittest.TestCase):
|
||||
|
||||
self.assertEqual(len(result.inconsistencies_found), 1)
|
||||
inc = result.inconsistencies_found[0]
|
||||
self.assertFalse(inc.can_auto_fix)
|
||||
self.assertEqual(inc.fix_action, FixAction.MANUAL_FIX_REQUIRED)
|
||||
self.store_manager.install_plugin.assert_not_called()
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ class Web:
|
||||
self.config_manager.template_path = str(tmp_path / "no-template.json")
|
||||
self.schema_manager = SchemaManager(plugins_dir=self.plugins_dir, project_root=tmp_path,
|
||||
config_manager=self.config_manager)
|
||||
self.catalog = PluginCatalog(self.plugins_dir, self.config_manager, self.schema_manager)
|
||||
self.catalog = PluginCatalog(self.plugins_dir)
|
||||
|
||||
api = self.api = api_v3_module.api_v3
|
||||
api.config_manager = self.config_manager
|
||||
@@ -242,7 +242,8 @@ class TestTheWebProcessNeverRunsAPlugin:
|
||||
_bump_version(web, "1.1.0")
|
||||
body = web.post("/api/v3/plugins/update", {"plugin_id": PLUGIN_ID})
|
||||
assert body["data"]["update_status"] == "updated"
|
||||
assert web.catalog.get_installed_version(PLUGIN_ID) == "1.1.0"
|
||||
# The route rescans the catalog, which now has the new manifest.
|
||||
assert web.catalog.get_manifest(PLUGIN_ID)["version"] == "1.1.0"
|
||||
assert web.ran() == []
|
||||
|
||||
def test_installing_it(self, web):
|
||||
@@ -497,16 +498,15 @@ class TestCatalogReadsWhatIsInstalled:
|
||||
assert expected, f"no plugins under {root}"
|
||||
before = set(sys.modules)
|
||||
schema_manager = SchemaManager(plugins_dir=root, project_root=PROJECT_ROOT)
|
||||
catalog = PluginCatalog(root, schema_manager=schema_manager)
|
||||
catalog = PluginCatalog(root)
|
||||
|
||||
assert set(catalog.discover_plugins()) == set(expected)
|
||||
for plugin_id, (plugin_dir, manifest) in expected.items():
|
||||
assert catalog.get_manifest(plugin_id) == manifest
|
||||
assert catalog.get_plugin_directory(plugin_id) == str(plugin_dir)
|
||||
assert catalog.get_installed_version(plugin_id) == manifest.get("version", "")
|
||||
assert catalog.get_plugin_display_modes(plugin_id) == manifest.get("display_modes", [])
|
||||
if (plugin_dir / "config_schema.json").exists():
|
||||
schema = catalog.get_schema(plugin_id, use_cache=False)
|
||||
schema = schema_manager.load_schema(plugin_id, use_cache=False)
|
||||
assert isinstance(schema, dict) and "properties" in schema, plugin_id
|
||||
|
||||
imported = [name for name in set(sys.modules) - before
|
||||
@@ -537,16 +537,16 @@ class TestCatalogReadsWhatIsInstalled:
|
||||
assert catalog.discover_plugins() == []
|
||||
assert catalog.get_manifest("ci-fixture-plugin") is None
|
||||
|
||||
def test_enabled_follows_the_display_rule(self, tmp_path):
|
||||
def test_enabled_follows_the_display_rule(self, api_v3_module):
|
||||
config = MagicMock()
|
||||
config.load_config.return_value = {"a": {"enabled": True}, "b": {}, "c": "junk"}
|
||||
catalog = PluginCatalog(tmp_path, config_manager=config)
|
||||
assert catalog.is_enabled("a") is True
|
||||
api_v3_module.api_v3.config_manager = config
|
||||
enabled = api_v3_module._plugin_enabled_in_config
|
||||
assert enabled("a") is True
|
||||
# The display runs a plugin only when its section says so.
|
||||
assert catalog.is_enabled("b") is False
|
||||
assert catalog.is_enabled("c") is False
|
||||
assert catalog.is_enabled("missing") is False
|
||||
assert catalog.get_config("c") == {}
|
||||
assert enabled("b") is False
|
||||
assert enabled("c") is False
|
||||
assert enabled("missing") is False
|
||||
|
||||
def test_it_has_nothing_that_runs_a_plugin(self, tmp_path):
|
||||
catalog = PluginCatalog(tmp_path)
|
||||
|
||||
Reference in New Issue
Block a user