From 8e7a553c1e9ab7cd68ed07ad30c9e698decd4ae8 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:16:24 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=9D=E7=95=99=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E5=BC=80=E5=8F=91=E7=83=AD=E9=87=8D=E8=BD=BD=E4=B8=8E=E4=BB=A3?= =?UTF-8?q?=E9=99=85=E5=80=99=E9=80=89=20(#6483)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(plugin): preserve development source admission * test(plugin): cover runtime payload hot reload --- app/application/plugin/catalog.py | 10 ++-- app/application/plugin/source.py | 6 +++ tests/test_plugin_catalog_service.py | 31 ++++++++++-- tests/test_plugin_install_gateway.py | 75 ++++++++++++++++++++++++++++ tests/test_plugin_local_sync.py | 40 +++++++++++++++ tests/test_plugin_source_policy.py | 30 +++++++++++ 6 files changed, 184 insertions(+), 8 deletions(-) diff --git a/app/application/plugin/catalog.py b/app/application/plugin/catalog.py index 753583148..4d0f6ad67 100644 --- a/app/application/plugin/catalog.py +++ b/app/application/plugin/catalog.py @@ -332,7 +332,7 @@ class PluginCatalogService: base_plugins: list[Any], markets: list[str], ) -> list[Any]: - """每个仓库保留同一插件的最高兼容版本,供来源准入继续决策。""" + """每个仓库和代际保留同一插件的最高版本,供来源准入继续决策。""" higher_keys = { ( plugin.repo_url, @@ -357,9 +357,13 @@ class PluginCatalogService: return markets.index(plugin.repo_url) return len(markets) - result_by_source: dict[tuple[Optional[str], str], Any] = {} + result_by_source: dict[tuple[Optional[str], str, Optional[str]], Any] = {} for plugin in sorted(all_plugins, key=repo_order): - key = (plugin.repo_url, normalize_physical_plugin_id(plugin.id)) + key = ( + plugin.repo_url, + normalize_physical_plugin_id(plugin.id), + plugin.package_version, + ) exists = result_by_source.get(key) if not exists or self._version_compare( plugin.plugin_version, diff --git a/app/application/plugin/source.py b/app/application/plugin/source.py index 01ce4dc93..c1995a6d0 100644 --- a/app/application/plugin/source.py +++ b/app/application/plugin/source.py @@ -586,6 +586,12 @@ def select_plugin_candidate( and local_selection.status is PluginSelectionStatus.INCOMPLETE ): return local_selection + # 精确本地来源用于开发同步;未指定来源的启动与更新才参与在线版本协调。 + if explicit_source and local_candidates is not None: + return local_selection or PluginSelection( + status=PluginSelectionStatus.UNAVAILABLE, + reason="所选本地仓库中没有该插件", + ) online = inventory.candidates_for(plugin_id) if not online: diff --git a/tests/test_plugin_catalog_service.py b/tests/test_plugin_catalog_service.py index dd8581ca8..1749fb195 100644 --- a/tests/test_plugin_catalog_service.py +++ b/tests/test_plugin_catalog_service.py @@ -8,12 +8,18 @@ from packaging.version import Version from app.application.plugin.catalog import PluginCatalogService -def _plugin(plugin_id: str, version: str, repo_url: str): +def _plugin( + plugin_id: str, + version: str, + repo_url: str, + package_version: str | None = "v3", +): """构造目录合并测试使用的最小插件 DTO。""" return SimpleNamespace( id=plugin_id, plugin_version=version, repo_url=repo_url, + package_version=package_version, ) @@ -78,21 +84,36 @@ def test_merge_treats_plugin_id_casing_as_one_physical_plugin(): assert result == [local] -def test_merge_by_source_treats_plugin_id_casing_as_one_physical_plugin(): - """同一仓库跨代际大小写不一致时只保留最高版本候选。""" +def test_merge_by_source_deduplicates_plugin_id_casing_within_generation(): + """同一仓库和代际内的 ID 大小写差异不能产生重复候选。""" service = _service() old = _plugin("DownloadCenter", "3.2.1", "https://market-a") new = _plugin("downloadcenter", "3.3.2", "https://market-a") result = service.merge_by_source( - [new], - [old], + [old, new], + [], ["https://market-a"], ) assert result == [new] +def test_merge_by_source_preserves_candidates_across_generations(): + """跨代际候选必须保留,由来源准入按代际优先级继续决策。""" + service = _service() + v3 = _plugin("DownloadCenter", "3.2.1", "https://market-a", "v3") + v2 = _plugin("downloadcenter", "9.0.0", "https://market-a", "v2") + + result = service.merge_by_source( + [v3, v2], + [], + ["https://market-a"], + ) + + assert result == [v3, v2] + + def test_load_maps_market_entries_with_installed_snapshot(): """单市场读取只获取一次已安装快照并按索引顺序映射 DTO。""" mapper = Mock(side_effect=lambda plugin_id, *_args: plugin_id) diff --git a/tests/test_plugin_install_gateway.py b/tests/test_plugin_install_gateway.py index 18d8362b3..5d6c2a3a8 100644 --- a/tests/test_plugin_install_gateway.py +++ b/tests/test_plugin_install_gateway.py @@ -175,6 +175,81 @@ async def test_local_only_requires_explicit_online_binding() -> None: assert explicit_admission.trusted_source_key == online.source_key +@pytest.mark.asyncio +async def test_explicit_local_sync_uses_local_candidate_and_execution_mode() -> None: + """文件监控明确指定本地候选时必须同步本地载荷。""" + online = PluginMarketCandidate( + plugin_id="DemoPlugin", + source_key="github:jxxghp/moviepilot-plugins", + source_type=TrustedPluginSourceType.OFFICIAL, + repo_url=REPO_URL, + package_generation="v3", + plugin_version="9.0.0", + dto={"v3": True}, + ) + local = PluginLocalCandidate( + plugin_id="DemoPlugin", + repo_url="local://DemoPlugin?version=v3", + package_generation="v3", + plugin_version="1.0.0", + dto={"v3": True}, + ) + identity = PluginIdentity( + plugin_id="DemoPlugin", + normalized_plugin_id="demoplugin", + trusted_source_type=TrustedPluginSourceType.OFFICIAL, + trusted_source_key="github:jxxghp/moviepilot-plugins", + binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT, + payload_source_type=PluginPayloadSourceType.OFFICIAL, + payload_source_key="github:jxxghp/moviepilot-plugins", + declared_version="9.0.0", + package_generation="v3", + declared_metadata=PluginDeclaredMetadata.from_package( + {"name": "Demo online", "v3": True}, + declaration_version="9.0.0", + manifest_matches_payload=True, + ), + payload_receipt="sha256:" + "1" * 64, + revision=2, + created_at=NOW, + updated_at=NOW, + bound_at=NOW, + payload_applied_at=NOW, + ) + executor = AsyncMock() + executor.execute.return_value = type( + "Result", + (), + {"success": True, "message": ""}, + )() + gateway = PluginInstallGateway( + inventory=AsyncMock( + return_value=CandidateInventory( + (MarketRead.present(REPO_URL, (online,)),), + (local,), + local_read=LocalCandidateRead.present((local,)), + ) + ), + identity=AsyncMock(return_value=identity), + candidate_compatibility=lambda _candidate: (True, ""), + executor=executor, + clock=lambda: NOW, + ) + + result = await gateway.install( + plugin_id="DemoPlugin", + repo_url=local.repo_url, + package_version="v3", + force=True, + local_sync=True, + explicit_source=True, + ) + + assert result.success is True + assert executor.execute.await_args.kwargs["admission"].candidate is local + assert executor.execute.await_args.kwargs["local_sync"] is True + + @pytest.mark.asyncio async def test_gateway_rejects_source_conflict_before_package_execution() -> None: """来源准入失败时不进入文件和数据库事务。""" diff --git a/tests/test_plugin_local_sync.py b/tests/test_plugin_local_sync.py index 482e9aa45..ed055bb29 100644 --- a/tests/test_plugin_local_sync.py +++ b/tests/test_plugin_local_sync.py @@ -12,6 +12,7 @@ from app.adapters.external.market import PluginHelper from app.foundation.singleton import Singleton from app.runtime.events import Event, eventmanager from app.runtime.extensions.plugin_manager import PluginManager +from app.runtime.extensions.plugin.paths import PluginPathResolver from app.runtime.extensions.plugin.system import get_plugin_system from app.scheduler import Scheduler from app.schemas.types import EventType, SystemConfigKey @@ -780,6 +781,45 @@ def test_local_python_and_federated_changes_share_one_batch_sync( reload_spy.assert_called_once_with("DemoPlugin") +def test_runtime_python_change_reloads_without_local_repository_sync( + tmp_path, + monkeypatch, + plugin_manager: PluginManager, +) -> None: + """直接修改运行目录中的 Python 文件只重载当前载荷。""" + runtime_dir = tmp_path / "app" / "plugins" / "demoplugin" + runtime_file = runtime_dir / "__init__.py" + runtime_dir.mkdir(parents=True) + runtime_file.write_text( + "from app.plugins import _PluginBase\n" + "class DemoPlugin(_PluginBase):\n" + " plugin_name = 'Demo'\n", + encoding="utf-8", + ) + _configure_local_watcher( + monkeypatch, + tmp_path, + tmp_path / "unused-local-repository", + {(Change.modified, str(runtime_file))}, + ) + plugin_manager._plugin_paths = PluginPathResolver( + runtime_root=tmp_path / "app" / "plugins", + running=lambda: plugin_manager.running_plugins, + system=get_plugin_system, + strict_system_version=lambda: False, + log=Mock(), + ) + sync_spy = Mock() + reload_spy = Mock() + monkeypatch.setattr(plugin_manager, "_sync_local_plugin_if_installed", sync_spy) + monkeypatch.setattr(plugin_manager, "_reload_plugin_tree_from_monitor", reload_spy) + + plugin_manager._run_file_watcher() + + sync_spy.assert_not_called() + reload_spy.assert_called_once_with("DemoPlugin") + + def test_plugin_reload_refreshes_scheduler_services_idempotently(monkeypatch): """插件重载事件必须按当前服务拓扑幂等刷新 Scheduler。""" current_func = Mock() diff --git a/tests/test_plugin_source_policy.py b/tests/test_plugin_source_policy.py index 0cabafe9f..a2cfe4b58 100644 --- a/tests/test_plugin_source_policy.py +++ b/tests/test_plugin_source_policy.py @@ -348,6 +348,36 @@ def test_bound_online_and_local_candidates_choose_higher_version() -> None: assert local_higher.candidate is local +def test_explicit_local_candidate_bypasses_bound_online_version_comparison() -> None: + """本地开发同步明确指定的候选不受绑定仓库版本号阻断。""" + local = PluginLocalCandidate( + plugin_id="DemoPlugin", + repo_url="local://DemoPlugin?version=v3", + package_generation="v3", + plugin_version="1.0.0", + ) + selection = select_plugin_candidate( + _inventory( + MarketRead.present( + "market-a", + (_online(THIRD_PARTY_SOURCE, version="9.0.0"),), + ), + local=(local,), + ), + plugin_id="DemoPlugin", + generations=("v3", "v2", "v1"), + identity=_identity( + TrustedPluginSourceType.THIRD_PARTY, + THIRD_PARTY_SOURCE, + ), + local_candidates=(local,), + explicit_source=True, + ) + + assert selection.status is PluginSelectionStatus.SELECTED + assert selection.candidate is local + + def test_equal_bound_and_local_versions_keep_current_payload_source() -> None: """相同版本保持当前载荷来源,避免每次启动在本地与在线之间切换。""" local = PluginLocalCandidate(