diff --git a/.gitignore b/.gitignore index f535f26..041b0c9 100644 --- a/.gitignore +++ b/.gitignore @@ -222,8 +222,6 @@ __marimo__/ # Backup files *.bak -# GUI 状态持久化(用户本地偏好) -gui_state.json config/config.yml # 调度运行参数(运行时由 schedule.example.yml 拷贝生成,含邮件授权码,不进版本库;模板见 config/schedule.example.yml) diff --git a/CONVENTIONS.md b/CONVENTIONS.md index 38f0f31..a1a48a0 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -69,7 +69,7 @@ except subprocess.TimeoutExpired as e: ## 9. GUI 持久化边界 -`gui_state.json` 只存 `dungeon` / `sequence`;`enabled` 纯内存态,重启恢复全开,不持久化。 +无 UI 状态文件:日常副本/序列的真源是子脚本 config(编辑期实时落盘);`set_dungeon` 为 no-op 的脚本(绝区零/崩铁,上游自身已支持)不提供选择,chip 直接呈现 `dungeon_list.yml` 声明项。`enabled` 纯内存态,重启恢复全开,不持久化。 ## 10. 不随意修改 `.bak` / 备份文件 diff --git a/src/cli.py b/src/cli.py index c977bf3..ba5362d 100644 --- a/src/cli.py +++ b/src/cli.py @@ -18,10 +18,10 @@ import warnings from src.config.set_config import supports_weekly -from src.config.subscript import get_script_name -from src.service.chain_service import ChainService -from src.service.script_service import ScriptService +from src.service.app_service import AppService +from src.utils_config import get_script from src.utils_shutdown import shutdown_sys +from src.utils_sub_config import get_script_name logger = logging.getLogger(__name__) @@ -217,8 +217,8 @@ def _run_selftest(out_path: str | None) -> int: """ result: dict = {"status": "fail", "checks": {}} try: - service = ChainService() - data = service.load_config() + app_service = AppService() + data = app_service.load_config() result["checks"]["service_ready"] = True result["checks"]["script_count"] = len(data["script_list"]) result["checks"]["config_loaded"] = True @@ -237,9 +237,9 @@ def _run_check_config(out_path: str | None) -> int: 返回退出码 0=全部合法 / 1=存在不合法项。 """ try: - service = ChainService() - all_config_data = service.load_config() - invalid = service.collect_invalid_scripts(all_config_data["script_list"]) + app_service = AppService() + all_config_data = app_service.load_config() + invalid = app_service.collect_invalid_scripts(all_config_data["script_list"]) result = { "status": "ok" if not invalid else "invalid", "script_count": len(all_config_data["script_list"]), @@ -254,8 +254,8 @@ def _run_check_config(out_path: str | None) -> int: def _run_list_scripts(out_path: str | None) -> int: """CLI: 列出所有脚本唯一标识(exe 用进程名,脚本文件用 display_name)。""" - service = ChainService() - all_config_data = service.load_config() + app_service = AppService() + all_config_data = app_service.load_config() names = [get_script_name(s) for s in all_config_data["script_list"]] result = {"script_count": len(names), "scripts": names} _emit_json("list_scripts", result, out_path) @@ -267,7 +267,7 @@ def _run_get_script(script_name: str, out_path: str | None) -> int: 返回退出码 0=找到 / 1=不存在。 """ - script = ScriptService().get_script(script_name) + script = get_script(script_name) if script is None: _emit_json( "get_script", @@ -281,8 +281,8 @@ def _run_get_script(script_name: str, out_path: str | None) -> int: def _run_dump_config(out_path: str | None) -> int: """CLI: 导出完整 config.yml(JSON)。""" - service = ChainService() - all_config_data = service.load_config() + app_service = AppService() + all_config_data = app_service.load_config() _emit_json("dump_config", all_config_data, out_path) return 0 @@ -296,7 +296,7 @@ def _run_check_weekly(out_path: str | None) -> int: 返回退出码 0=一致 / 1=存在不一致。 """ - result = ScriptService().check_weekly() + result = AppService().check_weekly() _emit_json("check_weekly", result, out_path) assert "status" in result, "[cli] check_weekly 结果缺少 status" return 0 if result["status"] == "ok" else 1 @@ -354,8 +354,8 @@ def _resolve_enable_keys( def _run_generate_chain(args) -> int: """CLI: 生成脚本链配置。返回退出码 0=成功 / 1=失败。""" - service = ChainService() - all_config_data = service.load_config() + app_service = AppService() + all_config_data = app_service.load_config() known = {get_script_name(s) for s in all_config_data["script_list"]} enabled_keys, err = _resolve_enable_keys(args.enable, known) @@ -407,12 +407,14 @@ def _run_generate_chain(args) -> int: ) return 1 # 仅做持久化(周几跑是长期配置),不实时写子脚本 config - service.set_weekly_start(script_name, start_day) + app_service.set_weekly_start(script_name, start_day) out = args.out if out: out = os.path.abspath(out) - out = service.generate_chain(all_config_data, enabled_keys, args.name, out_path=out) + out = app_service.generate_chain( + all_config_data, enabled_keys, args.name, out_path=out + ) _emit_cli("generate_chain", f"已生成脚本链配置: {out}") return 0 @@ -425,10 +427,10 @@ def _run_run_chain(args) -> int: return 1 extra_args = [] - service = ChainService() - command, cwd, _env = service.build_chain_command(chain_path, extra_args) + app_service = AppService() + command, cwd, _env = app_service.build_chain_command(chain_path, extra_args) _emit_cli("run_chain", f"运行: {cwd} {' '.join(command)}") - code = service.run_chain_command( + code = app_service.run_chain_command( chain_path, block=not args.no_block, extra_args=extra_args ) if args.no_block: @@ -449,14 +451,14 @@ def _run_scheduled(args) -> int: ``CREATE_NEW_CONSOLE`` 起),故等待阻塞无害;关闭该控制台即取消。链在点火时 才生成(按当天星期)。 """ - service = ChainService() - all_config_data = service.load_config() + app_service = AppService() + all_config_data = app_service.load_config() known = {get_script_name(s) for s in all_config_data["script_list"]} enabled_keys, err = _resolve_enable_keys(args.enable, known) if err: _emit_cli("schedule_run", err) return 1 - service.schedule_run( + app_service.schedule_run( enabled_keys, args.schedule_run, chain_name=args.name or "today", diff --git a/src/config/bgi.py b/src/config/bgi.py index 63be095..303db91 100644 --- a/src/config/bgi.py +++ b/src/config/bgi.py @@ -2,16 +2,16 @@ import os import shutil -from src.config.subscript import ( - get_process_name, - resolve_script_path, -) from src.utils import ( get_our_bgi_user_dir, require_config_yml_path, safe_path_join, ) from src.utils_logger import setup_logging +from src.utils_sub_config import ( + get_process_name, + resolve_script_path, +) from src.utils_yaml import load_yaml logger = logging.getLogger(__name__) diff --git a/src/config/dungeon_config.py b/src/config/dungeon_config.py index c06d4b9..9a6e9a2 100644 --- a/src/config/dungeon_config.py +++ b/src/config/dungeon_config.py @@ -1,7 +1,13 @@ +import os from typing import Any -from src.utils import get_root_dir, safe_path_join -from src.utils_yaml import load_yaml_optional +from src.config.set_config import get_dungeon_lists +from src.utils import ( + get_root_dir, + get_weekly_list_yml_path_under_root, + safe_path_join, +) +from src.utils_yaml import load_yaml, load_yaml_optional DungeonOptions = list[str] SequenceOptionsMap = dict[str, list[tuple[str, Any]]] @@ -98,3 +104,66 @@ def get_display_name( if val == actual_value: return display_name return str(actual_value) + + +def _load_weekly_map() -> dict: + """读取 weekly_list.yml(周常声明配置,进 git,必存在)。 + + 结构:{script_name: [{"name", "dungeons"?}, ...]}。周常起始日(周几起)另存于 + weekly_start.yml,不在本文件。 + """ + weekly_list_path = get_weekly_list_yml_path_under_root() + assert os.path.exists(weekly_list_path), ( + f"[dungeon_config] 周常声明配置缺失: {weekly_list_path}" + ) + data = load_yaml(weekly_list_path) + # 空文件或内容非 dict 都是声明配置损坏,直接暴露而非静默当成「无声明」。 + assert isinstance(data, dict), ( + f"[dungeon_config] 周常声明配置应为 dict(空文件或格式错误): {weekly_list_path}" + ) + return data + + +def get_weekly_map(script_name: str) -> list: + """返回某脚本支持的周常声明清单(weekly_list.yml)。 + + 每项:{"name", "dungeons"?}。dungeons 存在且有内容即表示该周常需选副本。 + 声明项若带 ``dungeons_source`` 标记,副本清单取自游戏脚本自身配置(运行期读取, + get_dungeon_lists),读不到时降级为 dungeons=[]。文件缺失或该脚本无声明时返回空列表。 + """ + defs_map = _load_weekly_map() + if script_name not in defs_map: + return [] + defs = list(defs_map[script_name]) + for d in defs: + source = d.get("dungeons_source") + if source: + # 副本清单来自外部(如 M7A 的 instance_names.json),运行期读取,不再手动维护; + # 读不到则降级为无可选副本(has_dungeon=False)。 + names = get_dungeon_lists(script_name, d["name"], source) + d["dungeons"] = names if names is not None else [] + return defs + + +def get_dungeon_map() -> dict: + """返回日常副本/序列配置映射(dungeon_list.yml)。 + + 声明项若带 ``dungeons_source`` 标记,其二级序列取自游戏脚本自身配置(运行期读取, + get_dungeon_lists),读不到时降级为 sequences=[]。文件缺失时返回空 dict。 + """ + data = load_dungeon_map() + for script_name, cfg in data.items(): + if not isinstance(cfg, dict): + continue + for d in cfg.get("dungeons", []): + if not isinstance(d, dict): + continue + source = d.get("dungeons_source") + if source: + # 二级序列来自外部(如 ok-ef 的 world_map.json),运行期读取,不手动维护; + # 读不到则降级为无可选序列(show_seq=False)。 + names = get_dungeon_lists(script_name, d["name"], source) + d["sequences"] = ( + [{"display": n, "value": n} for n in names] if names else [] + ) + return data diff --git a/src/config/set_config.md b/src/config/set_config.md index 9e792b2..8db4bb3 100644 --- a/src/config/set_config.md +++ b/src/config/set_config.md @@ -4,7 +4,7 @@ > 设计定位:`set_config` 是适配器,把异构 config 适配成统一调用;不是外观模式,外观整合职责归 `src/service/` 的 ChainService。 -> script_name 为全链路内部唯一标识,由 `get_script_name(script)` 获取,与进程名 `get_process_name` 区分。exe 脚本的 script_name 即进程名 basename 去后缀,如 `ok-ww`;python/bat 脚本文件的 script_name 即 display_name。注册表、`dungeon_list.yml`、`gui_state.json`、`weekly_timeouts.yml` 的 key 全用 script_name,display_name 仅用于展示。config.yml 加载经 `check_script_name_uniqueness` 断言唯一。 +> script_name 为全链路内部唯一标识,由 `get_script_name(script)` 获取,与进程名 `get_process_name` 区分。exe 脚本的 script_name 即进程名 basename 去后缀,如 `ok-ww`;python/bat 脚本文件的 script_name 即 display_name。注册表、`dungeon_list.yml`、`weekly_timeouts.yml` 的 key 全用 script_name,display_name 仅用于展示。config.yml 加载经 `check_script_name_uniqueness` 断言唯一。 ## 架构 @@ -84,9 +84,9 @@ | 鸣潮 | 否 | `Which to Farm` | 是 | 经 `super()._update_task(config, dungeon_name, None)` 复用副本写入,再按 `_sequence_map` 写序列;模拟领域需映射值,凝素/无音区直接用 sequence | | 原神 | 否 | `DomainName` | 否 | — | | 终末地 | 否 | `体力本` | 否 | — | -| 崩铁 | 否 | — | 否 | 日常无需适配(set_dungeon 为 no-op),反读回退 gui_state | +| 崩铁 | 否 | — | 否 | 日常无需适配(set_dungeon 为 no-op,上游自身已支持),chip 呈现声明项 | | 异环 | 否,覆盖做互斥切换 | `任务类型`(声明于 `_mode_specs`) | 是 | 完全自定义(不调 super):副本→模式经 `_dungeon_to_mode` 反查 `_mode_specs` 声明式映射(含 `task_field`+`seq_fields`);`DailyRoutineTask.json` 切换 `daily_anomaly`↔`daily_anomaly_hunter` 互斥启用,复用基类 `_load`/`_save`,仅路径 `_routine_config_rel_path` 不同 | -| 绝区零 | 是,空实现仅 print | — | — | 无需适配副本选择 | +| 绝区零 | 是,空实现仅 print | — | — | 无需适配副本选择(上游自身已支持) | | 粥 | 是,完全自定义 | — | 是 | 操作 `TaskQueue` 禁用全部→启用剿灭+选定+土,不写二级序列 | 标准流程:不覆盖 set_dungeon,靠 `_task_key` 适配;需二级序列支持则覆盖 `_update_task`(在 `super()._update_task(config, dungeon_name, None)` 后补序列);需完全自定义如粥或无需适配如绝区零才覆盖 set_dungeon。 diff --git a/src/config/set_config.py b/src/config/set_config.py index 5918266..eb3296c 100644 --- a/src/config/set_config.py +++ b/src/config/set_config.py @@ -4,10 +4,10 @@ import os from typing import Any -from src.config.subscript import ( - get_config_path as _get_config_path_impl, +from src.utils_sub_config import ( + get_sub_config_path as _get_config_path_impl, ) -from src.config.subscript import ( +from src.utils_sub_config import ( load_config, load_game_config, load_template, @@ -338,7 +338,7 @@ def _read_dungeon(self) -> tuple[str | None, str | int | None]: (无 ``_task_key`` / 非 ``_task_key`` + ``_task_map``),可完全自行实现而不调 super。 仅「脚本未安装」与「用户未选择」的副本部分返回 None;config 损坏或字段值未知 - 属异常,直接 assert 暴露,不静默回退(否则会被 gui_state 兜底掩盖)。 + 属异常,直接 assert 暴露,不静默回退(否则会被日常副本的声明项回退掩盖)。 Returns: ``(副本中文名, 序列值)``;无 _task_key(无适应)/ 脚本未安装 / 未选择时 @@ -1030,7 +1030,7 @@ class NTEConfig(ScriptConfig): "seq_fields": _anomaly_seq_key_map, # 副本 → 序号字段 }, "daily_anomaly_hunter": { - "task_field": None, + "task_field": None, # 追猎目标无任务类型通道 "seq_fields": {"追猎目标": "追猎目标"}, # 副本名即 boss 名 }, } @@ -1177,16 +1177,10 @@ def set_dungeon(self, dungeon_name: str, sequence: str | int | None = None) -> N def _read_dungeon(self) -> tuple[str | None, str | int | None]: """反读当前日常副本与二级序号(与 set_dungeon / _update_task 对称)。 - 当前玩法由 DailyRoutineTask.json 的 Routine Items 启用状态判定,经 ``_mode_specs`` - 查表解析当前模式(异象界域 / 追猎目标)。追猎目标无任务类型通道,set_dungeon - 不写 daily_anomaly.任务类型(陈旧值),故必须优先用启用状态,不能直接读 任务类型。 - - 两种模式数据落点不同(NTE 既有结构):追猎目标 boss 存于 routine 文件 - DailyRoutineTask.json 的 daily_anomaly_hunter 段;异象界域副本名+序号存于 - config 文件 DailyRoutineTaskConfigs.json 的 daily_anomaly 段。故按模式 id 分流读取。 - - NTE 无标准存储结构(不依赖 _task_key + _task_map 反转),完全自行实现。 - 脚本未安装(routine 缺失)返回 (None, None);routine/config 损坏属异常,assert 暴露。 + 当前玩法由 DailyRoutineTask.json 的 Routine Items 启用状态判定(非 任务类型 字段), + 经 ``_mode_specs`` 查表解析模式;两种模式的副本/序列数据均落 config 文件 + DailyRoutineTaskConfigs.json,routine 文件仅用于判定启用模式。脚本未安装或 routine + 缺失返回 (None, None);routine/config 损坏属异常,assert 暴露。 Returns: (副本中文名, 序号值);无启用玩法/未安装/未选择返回 (None, None)。 @@ -1213,33 +1207,26 @@ def _read_dungeon(self) -> tuple[str | None, str | int | None]: ) if mode_id is None: return None, None # 无启用玩法 → 未选择副本 - if mode_id == "daily_anomaly_hunter": - # 追猎目标:boss 名存于 routine 文件 daily_anomaly_hunter 段。 - # 该段/字段可能尚未落盘(用户在 NTE 自身 UI 启用追猎但未选 boss, - # 不经本工具 set_dungeon 写入):段或字段缺失按「已识别模式、未选 boss」 - # 处理为 None,而非断言——与读路径「容忍未配置」一致;结构性损坏(段 - # 类型非 dict)已在下方 isinstance 断言覆盖。 - section = routine.get("daily_anomaly_hunter", {}) - assert isinstance(section, dict), ( - f"[set_config][{self.display_name}] daily_anomaly_hunter 段必须是 dict" - ) - boss = section.get("追猎目标") - return "追猎目标", boss if boss not in (None, "") else None - # 异象界域:副本名+序号存于 config 文件 daily_anomaly 段。 + # 两种模式的副本/序列数据都落在 config 文件(与写入侧 _daily_section_dict + # 对称),故在此一次性加载;routine 文件只用于判定启用的模式。 config = self._load(allow_missing=True) if config is None: return None, None # 脚本未安装/未配置 assert isinstance(config, dict), ( - f"[set_config][{self.display_name}] DailyTask config.yaml 必须是 dict" + f"[set_config][{self.display_name}] DailyRoutineTaskConfigs.json 必须是 dict" ) - # daily_anomaly 段缺失按「未选副本」处理(读路径容忍未配置;段类型非 dict - # 的结构性损坏由下方 isinstance 断言覆盖,字段级缺失不视为损坏)。 - section = config.get("daily_anomaly", {}) + # 段名 = 模式 id;段缺失按「未配置」处理(容忍未落盘),段类型非 dict 属损坏、assert。 + section = config.get(mode_id, {}) assert isinstance(section, dict), ( - f"[set_config][{self.display_name}] daily_anomaly 段必须是 dict" + f"[set_config][{self.display_name}] {mode_id} 段必须是 dict" ) - dungeon = section.get("任务类型") # 段缺失时为 None(未选副本) - if dungeon in (None, ""): # 值为空串同样视为未选具体副本 + if mode_id == "daily_anomaly_hunter": + # 追猎目标:副本名即 boss 字段名(段缺失/空串按未选 boss,容忍未落盘)。 + boss = section.get("追猎目标") + return "追猎目标", boss if boss else None + # 异象界域:副本名在 任务类型 字段,序号经 _anomaly_seq_key_map 反查。 + dungeon = section.get("任务类型") + if dungeon in (None, ""): # 段缺失/字段为空串均视为未选具体副本 return None, None key = self._anomaly_seq_key_map.get(dungeon) sequence = section.get(key) if key else None diff --git a/src/gui/README.md b/src/gui/README.md index 6ce72ad..618b966 100644 --- a/src/gui/README.md +++ b/src/gui/README.md @@ -11,9 +11,9 @@ | controllers/background | 背景视频/图片/渐变、壁纸、背景路径解析 | config / subscript | | controllers/task_card | 日常副本 / 周常周几,数据 + 选择持久化 | config / service / utils_weekly | | controllers/launch | 启动胶囊,启动当前 / 启动全部 | game_list / task_card / service | -| controllers/links | 悬浮条:主页/B站/GitHub/目录/设置/启动游戏 | config / subscript / utils | +| controllers/links | 悬浮条:主页/B站/GitHub/目录/设置/启动游戏 | config / utils_sub_config / utils | | controllers/window | 窗口控制:最小化/关闭/拖动 | 无 | -| icons | 脚本 exe 图标 + QML 矢量图标提供器 | config.subscript | +| icons | 脚本 exe 图标 + QML 矢量图标提供器 | utils_sub_config | | dialogs | 单脚本配置弹窗 + 确认回调 | config / service | 依赖单向:main_window 组合各控制器,控制器间构造注入;QmlBridge 是 QML 唯一门面。qml/ 组件经 Loader 相对路径加载,文件名与 controllers/ 同名。 @@ -39,7 +39,9 @@ config.yml 写入权统一归 ChainService,GUI 弹窗不直接写盘: ## UI 状态持久化 -gui_state.json 存 dungeon/sequence/weekly_start。enabled 是纯内存态,重启恢复全开,经 ChainService.load_ui_state / save_ui_state 读写。 +日常副本/序列的真源是子脚本 config(编辑期实时落盘,无 UI 状态文件);set_dungeon 为 +no-op 的脚本(绝区零/崩铁,上游自身已支持)不提供选择,chip 直接呈现 dungeon_list.yml +声明的唯一选项。enabled 是纯内存态,重启恢复全开。 ## 添加功能配方 diff --git a/src/gui/controllers/background.py b/src/gui/controllers/background.py index ed05053..01344cc 100644 --- a/src/gui/controllers/background.py +++ b/src/gui/controllers/background.py @@ -12,7 +12,7 @@ from PySide6.QtWidgets import QFileDialog from src.config.set_config import get_background_rel_path -from src.config.subscript import get_script_root_dir_soft, resolve_script_path +from src.utils_sub_config import get_script_root_dir_soft, resolve_script_path logger = logging.getLogger(__name__) diff --git a/src/gui/controllers/game_list.py b/src/gui/controllers/game_list.py index c9ab9ca..def0cd4 100644 --- a/src/gui/controllers/game_list.py +++ b/src/gui/controllers/game_list.py @@ -19,8 +19,8 @@ from PySide6.QtWidgets import QMessageBox from src.config.set_config import set_weekly_start_day -from src.config.subscript import get_script_name from src.gui.icons import get_script_icon +from src.utils_sub_config import get_script_name # 游戏图标停用底色(渐变兜底水印等场景复用) C_GAME_DIM = "#161C28" @@ -161,9 +161,9 @@ class GameListController(QObject): toastRequested = Signal(str) gameAdded = Signal() - def __init__(self, service, toast, on_reload, parent=None): + def __init__(self, app_service, toast, on_reload, parent=None): super().__init__(parent) - self._service = service + self._app_service = app_service self._toast = toast self._on_reload = on_reload # 增删/改配置后触发门面级重载 self._games: list = [] @@ -199,7 +199,7 @@ def game_model(self): def reload_games(self): """从 config.yml 重建脚本列表。""" games = [] - for script in self._service.load_config().get("script_list", []): + for script in self._app_service.load_config().get("script_list", []): display_name = script["display_name"] games.append( { @@ -288,7 +288,7 @@ def reorderGames(self, src_index: int, dst_index: int): self._enabled.insert(dst_index, enabled) # 同步 config.yml 顺序(以 UI 顺序为准),持久化 - config_data = self._service.load_config() + config_data = self._app_service.load_config() scripts = config_data["script_list"] s_idx = next( ( @@ -301,7 +301,7 @@ def reorderGames(self, src_index: int, dst_index: int): assert s_idx is not None, "[bridge] config 中找不到源脚本" script = scripts.pop(s_idx) scripts.insert(dst_index, script) - self._service.save_config(config_data) + self._app_service.save_config(config_data) # 恢复选中(新 index 可能已变) new_index = next( @@ -330,10 +330,8 @@ def addScript(self): return file_path = os.path.normpath(file_path) existing = {g["script_name"] for g in self._games} - script_data = self._service._script_service.build_script_entry( - file_path, existing - ) - self._service.add_script(script_data) + script_data = self._app_service.build_script_entry(file_path, existing) + self._app_service.add_script(script_data) self._on_reload() self._toast(f"已添加 {script_data['display_name']}") self.gameAdded.emit() @@ -372,14 +370,14 @@ def configCurrent(self): game["display_name"], game["script_data"].get("script_path", ""), None, - script_service=self._service._script_service, + app_service=self._app_service, ) if dialog.exec() == QDialog.Accepted: assert dialog.pending_changes is not None, ( "[bridge] 配置弹窗 accept 但 pending_changes 为空" ) changes = dialog.pending_changes - new_script_name = self._service.update_script( + new_script_name = self._app_service.update_script( changes["old_script_name"], changes["new_display_name"], changes["config_patch"], @@ -406,5 +404,5 @@ def _sync_weekly_start_day(self, script_name: str, start_day: int) -> None: def _on_delete_script(self, script_name: str): """配置弹窗确认删除:落盘后重载脚本列表。""" - self._service.remove_script(script_name) + self._app_service.remove_script(script_name) self._on_reload() diff --git a/src/gui/controllers/launch.py b/src/gui/controllers/launch.py index 0b9cf4d..42c8e35 100644 --- a/src/gui/controllers/launch.py +++ b/src/gui/controllers/launch.py @@ -9,7 +9,6 @@ from PySide6.QtCore import QObject, Signal, Slot from PySide6.QtWidgets import QDialog, QMessageBox -from src.config.subscript import get_script_name, resolve_script_path from src.gui.run_confirm_dialog import RunConfirmDialog from src.utils import open_in_explorer from src.utils_runner import ( @@ -28,17 +27,18 @@ parse_timed_run, spawn_schedule_run, ) +from src.utils_sub_config import get_script_name, resolve_script_path from src.utils_weekly import next_target_datetime class LaunchController(QObject): toastRequested = Signal(str) - def __init__(self, game_list, task_card, service, toast, parent=None): + def __init__(self, game_list, task_card, app_service, toast, parent=None): super().__init__(parent) self._game_list = game_list self._task_card = task_card - self._service = service + self._app_service = app_service self._toast = toast @Slot() @@ -63,7 +63,7 @@ def launchAll(self): return if not self._confirm_run(enabled_script_names): return - schedule_data = self._service.load_schedule() + schedule_data = self._app_service.load_schedule() shutdown_delay = parse_shutdown(schedule_data) mute = parse_mute_run(schedule_data) close_running = parse_close_running(schedule_data) @@ -106,11 +106,11 @@ def launchScript(self): def _confirm_run(self, enabled_keys: set) -> bool: """运行前校验并确认(含自动关机 / 定时计划配置)。Returns: True 继续,False 取消。""" - config_data = self._service.load_config() + config_data = self._app_service.load_config() enabled_scripts = [ s for s in config_data["script_list"] if get_script_name(s) in enabled_keys ] - invalid = self._service.collect_invalid_scripts(enabled_scripts) + invalid = self._app_service.collect_invalid_scripts(enabled_scripts) if invalid: details = "\n".join(f"· {name}:{msg}" for name, msg in invalid) reply = QMessageBox.warning( @@ -124,7 +124,7 @@ def _confirm_run(self, enabled_keys: set) -> bool: return False # 回显 schedule 当前自动关机 / 定时计划配置到确认弹窗。 - schedule_data = self._service.load_schedule() + schedule_data = self._app_service.load_schedule() shutdown_cfg = schedule_data.get("shutdown") shutdown_enabled = bool( isinstance(shutdown_cfg, dict) and shutdown_cfg.get("after_run", False) @@ -172,5 +172,5 @@ def _confirm_run(self, enabled_keys: set) -> bool: apply_close_running_config(schedule_data, enabled=res["close_running_enabled"]) apply_rerun_config(schedule_data, enabled=res["rerun_enabled"]) apply_notify_config(schedule_data, enabled=res["notify_enabled"]) - self._service.save_schedule(schedule_data) + self._app_service.save_schedule(schedule_data) return True diff --git a/src/gui/controllers/links.py b/src/gui/controllers/links.py index ca13155..e0afa32 100644 --- a/src/gui/controllers/links.py +++ b/src/gui/controllers/links.py @@ -9,11 +9,11 @@ from PySide6.QtCore import QObject, Signal, Slot from src.config.set_config import get_game_exe_path as _get_game_exe_path -from src.config.subscript import resolve_script_path from src.link import get_game_link as _get_game_link from src.log import get_log_dir -from src.service.script_service import ScriptService +from src.service.app_service import AppService from src.utils import get_config_yml_path_under_root, open_in_explorer +from src.utils_sub_config import resolve_script_path # 通用占位链接(对应内容未配置时使用) _URL_HOME = "https://github.com/LevelDownRefine/OneDragon-Helper" @@ -23,11 +23,11 @@ class LinksController(QObject): toastRequested = Signal(str) - def __init__(self, game_list, toast, script_service=None, parent=None): + def __init__(self, game_list, toast, app_service=None, parent=None): super().__init__(parent) self._game_list = game_list self._toast = toast - self._script_service = script_service or ScriptService() + self._app_service = app_service or AppService() @Slot() def launchGame(self): @@ -121,7 +121,7 @@ def openSettings(self): def openScriptConfig(self): """打开当前脚本专属配置文件(python→源码;exe→内部 config),未适配或缺失时提示。""" game = self._game_list.current_game - path, error = self._script_service.config_file_path(game["script_name"]) + path, error = self._app_service.config_file_path(game["script_name"]) if error is not None: self._toast(f"{game['display_name']}:{error}") return diff --git a/src/gui/controllers/task_card.py b/src/gui/controllers/task_card.py index 73c790c..7d6d5ed 100644 --- a/src/gui/controllers/task_card.py +++ b/src/gui/controllers/task_card.py @@ -1,6 +1,6 @@ """任务卡控制器:日常副本 / 周常周几(数据 + 选择持久化)。 -独立 QObject,自管状态(_ui_state / _dungeon_*_cache)。当前游戏经构造注入的 +独立 QObject,自管状态(_dungeon_map_cache / _dungeon_options_cache)。当前游戏经构造注入的 game_list 引用读取。dungeonOptions 从缓存读取(build_dungeon_cache 时构建)。 启用控制不在此处:日常靠控制模式、周常靠周几起(均在别处实现)。 """ @@ -16,7 +16,6 @@ set_config, set_weekly_dungeon, ) -from src.service.script_service import ScriptService # 周常「周几以后开始执行」:值 1=周一 ~ 7=周日(对齐 get_week_num 的 0=周一 偏移 +1) WEEKDAY_NAMES = { @@ -34,15 +33,11 @@ class TaskCardController(QObject): taskStateChanged = Signal() toastRequested = Signal(str) - def __init__(self, game_list, service, toast, parent=None): + def __init__(self, game_list, app_service, toast, parent=None): super().__init__(parent) self._game_list = game_list - self._service = service + self._app_service = app_service self._toast = toast - # 周常声明只读服务:weekly_list.yml(支持哪些周常 / 是否需选副本 / 副本清单) - self._script_service = ScriptService() - # 任务卡状态:gui_state.json 的副本/序列/周常(按 script_name 索引) - self._ui_state = self._service.load_ui_state() # 副本下拉数据缓存:dungeon_list.yml 解析较贵且运行期不变, # build_dungeon_cache 时一次性构建。 self._dungeon_map_cache: dict = {} @@ -70,22 +65,22 @@ def daily_supported(self) -> bool: @property def daily_dungeon_text(self) -> str: - """日常副本 chip 文字(优先反读子脚本 config,无真相回退 gui_state.json)。 + """日常副本 chip 文字(优先反读子脚本 config,无真相回退声明的唯一选项)。 - 子脚本 config 是日常副本的真相源(selectDungeon 已实时落盘); - 绝区零/崩铁日常无副本适配(set_dungeon 为 no-op),只能回退 gui_state.json。 + 子脚本 config 是日常副本的真相源(selectDungeon 已实时落盘);绝区零/崩铁 + 的 set_dungeon 为 no-op(上游自身已支持到无需本工具配置),反读恒无真相, + 故回退 dungeon_list.yml 声明的首个选项——即 UI 上直接呈现为已选状态。 """ game = self._current script_name = game["script_name"] # 1) 优先反读子脚本 config(真相源) dungeon = get_dungeon(script_name) sequence = get_sequence(script_name) - # 2) 无真相/未设置:回退 gui_state.json(覆盖 no-op 脚本) - saved = self._ui_state.get(script_name, {}) + # 2) 无真相:回退声明的首个选项(no-op 脚本的已选态,不再持久化) if dungeon is None: - dungeon = saved.get("dungeon") - if sequence is None: - sequence = saved.get("sequence") + options = self.dungeon_options + if options: + dungeon = options[0]["name"] if not dungeon: return "选择副本" dungeon_cfg = self._dungeon_map_cache.get(script_name) @@ -97,13 +92,13 @@ def weekly_supported(self) -> bool: 唯一真相源为 weekly_list.yml:声明了该脚本周常即支持。 """ - return bool(self._script_service.get_weekly_defs(self._current["script_name"])) + return bool(self._app_service.get_weekly_map(self._current["script_name"])) @property def weekly_start_label(self) -> str: """周常起始日文字(周几起),供单脚本配置弹窗显示当前选择。""" game = self._current - start_day = self._script_service.get_weekly_start(game["script_name"]) + start_day = self._app_service.get_weekly_start(game["script_name"]) return "选择周几" if start_day is None else f"{WEEKDAY_NAMES[start_day]}起" @property @@ -113,52 +108,29 @@ def weekly_items(self) -> list[dict]: 每种周常:{name, has_dungeon, dungeon_label}。has_dungeon 由声明是否含 dungeons 字段(且有内容)推导,不再用 needs_instance 布尔字段; dungeon_label 为已选副本名,需选而未选时返回「选择副本」、无需选返回空。 - 声明(支持哪些周常/可选副本)来自 weekly_list.yml,已选副本来自 - gui_state.json 的 weekly_dungeons(与副本/序列同为 UI 状态)。 + 声明(支持哪些周常/可选副本)来自 weekly_list.yml;已选副本反读子脚本 + config(如 M7A instance_names)——周常侧无 no-op 脚本,故不设回退。 """ script_name = self._current["script_name"] - defs = self._script_service.get_weekly_defs(script_name) + defs = self._app_service.get_weekly_map(script_name) if not defs: return [] - saved_dungeons = self._weekly_dungeons(script_name) items = [] for d in defs: name = d["name"] has_dungeon = "dungeons" in d and bool(d["dungeons"]) label = "" if has_dungeon: - # 优先反读子脚本 config(真相源,如 M7A instance_names); - # 无真相/未设置回退 gui_state.json 的 weekly_dungeons。 + # 反读子脚本 config(真相源,如 M7A instance_names) label = "选择副本" cfg_dungeon = get_weekly_dungeon(script_name, name) if cfg_dungeon: label = cfg_dungeon - elif name in saved_dungeons and saved_dungeons[name]: - label = saved_dungeons[name] items.append( {"name": name, "has_dungeon": has_dungeon, "dungeon_label": label} ) return items - def _weekly_dungeons(self, script_name: str) -> dict: - """读某脚本各周常已选副本(gui_state.json 的 weekly_dungeons)。 - - Args: - script_name: 脚本唯一标识。 - - Returns: - {周常名: 已选副本名};无记录时返回空 dict。 - """ - if script_name not in self._ui_state: - return {} - saved = self._ui_state[script_name] - if "weekly_dungeons" not in saved: - return {} - dungeons = saved["weekly_dungeons"] - if not isinstance(dungeons, dict): - return {} - return dungeons - def weekly_dungeon_options(self, weekly_name: str) -> list[str]: """某周常的可选副本名列表(如历战余响的全体副本)。 @@ -172,7 +144,7 @@ def weekly_dungeon_options(self, weekly_name: str) -> list[str]: 副本名列表(含「无」);该周常未声明副本清单时返回空列表。 """ script_name = self._current["script_name"] - for d in self._script_service.get_weekly_defs(script_name): + for d in self._app_service.get_weekly_map(script_name): if d["name"] != weekly_name: continue return list(d["dungeons"]) if "dungeons" in d else [] @@ -183,14 +155,10 @@ def dungeon_options(self) -> list: """日常副本下拉数据:[{name, clear, sequences:[{label,value}]}, ...],从缓存读取。""" return self._dungeon_options_cache.get(self._current["script_name"], []) - @property - def ui_state(self) -> dict: - return self._ui_state - # ── 缓存构建(运行期不变)────────────────────────────────────────── def build_dungeon_cache(self, games: list): """一次性解析 dungeon_list.yml 并构建所有脚本的副本下拉数据(运行期不变)。""" - self._dungeon_map_cache = self._script_service.get_dungeon_map() + self._dungeon_map_cache = self._app_service.get_dungeon_map() self._dungeon_options_cache = { g["script_name"]: self._build_dungeon_options(g["script_name"]) for g in games @@ -234,12 +202,12 @@ def _build_dungeon_options(self, script_name: str) -> list: # ── 交互 ─────────────────────────────────────────────────────────── @Slot(str, "QVariant") def selectDungeon(self, dungeon_name: str, sequence): - """选择日常副本(持久化到 gui_state.json,并实时落盘子脚本 config)。""" + """选择日常副本(实时落盘子脚本 config)。 + + 绝区零/崩铁的 set_dungeon 为 no-op(上游已支持到无需本工具配置), + 其日常副本直接取 dungeon_list.yml 声明选项,不经本方法持久化。 + """ script_name = self._current["script_name"] - saved = self._ui_state.setdefault(script_name, {}) - saved["dungeon"] = dungeon_name - saved["sequence"] = sequence - self._service.save_ui_state(self._ui_state) # 实时落盘子脚本 config(与周常副本 selectWeeklyDungeon 一致); # 未选择选项已移除,下拉只含真实副本,此处不再区分清空调度。 if dungeon_name: @@ -248,17 +216,13 @@ def selectDungeon(self, dungeon_name: str, sequence): @Slot(str, str) def selectWeeklyDungeon(self, weekly_name: str, dungeon_name: str): - """选择某周常的副本(持久化 gui_state.json 并写脚本自身 config)。 + """选择某周常的副本(写回脚本自身 config)。 Args: weekly_name: 周常名(如「历战余响」)。 dungeon_name: 选中的副本名(来自 weekly_dungeon_options)。 """ script_name = self._current["script_name"] - # 1) 持久化到 gui_state.json 的 weekly_dungeons(与副本/序列同为 UI 状态) - saved = self._ui_state.setdefault(script_name, {}) - saved.setdefault("weekly_dungeons", {})[weekly_name] = dungeon_name - self._service.save_ui_state(self._ui_state) - # 2) 写回脚本自身 config(如 M7A config.yaml 的 instance_names[weekly_name]) + # 写回脚本自身 config(如 M7A config.yaml 的 instance_names[weekly_name]) set_weekly_dungeon(script_name, weekly_name, dungeon_name) self.refresh() diff --git a/src/gui/dialogs.py b/src/gui/dialogs.py index 9f76483..241d852 100644 --- a/src/gui/dialogs.py +++ b/src/gui/dialogs.py @@ -34,8 +34,8 @@ from src.config.set_config import ( supports_weekly, ) -from src.config.subscript import get_script_name -from src.service.script_service import ScriptService +from src.service.app_service import AppService +from src.utils_sub_config import get_script_name # ═══════════════════════ 弹窗样式(原 src/gui/theme.py 子集,2026-08-16 并入)═══════ DARK_BLUE = "#333957" # 深空蓝 @@ -347,7 +347,7 @@ def __init__( display_name, script_path="", parent=None, - script_service=None, + app_service=None, ): super().__init__(parent) self.setWindowTitle(f"配置 {display_name}") @@ -358,7 +358,7 @@ def __init__( ) self.display_name = display_name # 展示名 self.script_path = script_path - self._script_service = script_service or ScriptService() + self._app_service = app_service or AppService() self.pending_changes = None # accept() 后供调用方取表单字段与 weekly self.init_ui() @@ -485,7 +485,7 @@ def _on_kill_game_changed(self, state): def _find_script_data(self) -> dict: """从 config.yml 读取本脚本的完整数据字典;脚本不在表中返回空 dict。""" - script = self._script_service.get_script(self.script_name) + script = self._app_service.get_script(self.script_name) return script if script is not None else {} def load_data(self): @@ -511,20 +511,20 @@ def load_data(self): # 周几起(从 weekly_start.yml 读;不支持周常时跳过) if self._weekly_start_supported: - start_day = self._script_service.get_weekly_start(self.script_name) + start_day = self._app_service.get_weekly_start(self.script_name) self.weekly_start_combo.setCurrentIndex( 0 if start_day is None else int(start_day) ) # 每周超时 - timeouts = self._script_service.weekly_inputs(self.script_name) + timeouts = self._app_service.weekly_inputs(self.script_name) for idx, timeout_edit in enumerate(self.timeout_inputs): timeout_edit.setText(str(timeouts[idx])) def save_data(self): """收集表单数据存入 self.pending_changes 后 accept();写盘由调用方完成。 - 不再直接调 ScriptService.update_script() 写 config.yml——config.yml + 不再直接调 src.utils_config.update_script() 写 config.yml——config.yml 的写入权归 ChainService。weekly_timeouts 也由调用方决定是否持久化。 """ path_val = self.path_input.text().strip() @@ -539,7 +539,7 @@ def save_data(self): new_script_name = get_script_name( {"display_name": new_display_name, "script_path": path_val} ) - existing = self._script_service.get_script(new_script_name) + existing = self._app_service.get_script(new_script_name) if existing is not None and new_script_name != self.script_name: assert "display_name" in existing, ( "[dialogs] config 脚本数据缺少 display_name" @@ -563,7 +563,7 @@ def save_data(self): text = timeout_edit.text().strip() timeouts.append(int(text) if text else None) - # 周几起:权威值持久化到 weekly_start.yml(经 ScriptService)。游戏侧原生 config + # 周几起:权威值持久化到 weekly_start.yml(经 AppService.set_weekly_start / src.utils_weekly)。游戏侧原生 config # 起始日的同步不在此处进行——save_data 内 config.yml 的 script_path 尚未落盘, # 此时解析目录会拿到旧路径,导致写到错误/失效目录。统一由调用方在 # ChainService.update_script 落盘新路径后触发(见 game_list.configCurrent)。 @@ -571,7 +571,7 @@ def save_data(self): if self._weekly_start_supported: idx = self.weekly_start_combo.currentIndex() start_day = None if idx <= 0 else idx - self._script_service.set_weekly_start(self.script_name, start_day) + self._app_service.set_weekly_start(self.script_name, start_day) self.pending_changes = { "old_script_name": self.script_name, diff --git a/src/gui/icons.py b/src/gui/icons.py index 240c9f6..375cbdd 100644 --- a/src/gui/icons.py +++ b/src/gui/icons.py @@ -14,8 +14,8 @@ from PySide6.QtSvg import QSvgRenderer from PySide6.QtWidgets import QFileIconProvider -from src.config.subscript import resolve_script_path from src.utils import get_root_dir, safe_path_join +from src.utils_sub_config import resolve_script_path logger = logging.getLogger(__name__) diff --git a/src/gui/main_window.py b/src/gui/main_window.py index b74a7c5..4a940ba 100644 --- a/src/gui/main_window.py +++ b/src/gui/main_window.py @@ -14,8 +14,10 @@ from src.gui.controllers.task_card import TaskCardController from src.gui.controllers.window import WindowController from src.gui.icons import UiIconProvider -from src.service.chain_service import ChainService -from src.service.script_service import ScriptService +from src.service.app_service import AppService +from src.service.chain_service import ( + ChainService, # noqa: F401 # 重导出供测试 patch(test_qml_launcher 经 main_window.ChainService 打桩) +) class QmlBridge(QObject): @@ -31,16 +33,16 @@ class QmlBridge(QObject): def __init__(self): super().__init__() - self.service = ChainService() + self.app_service = AppService() # 组合各职责控制器:每个自管状态 + 信号;经构造注入显式依赖 self.game_list = GameListController( - service=self.service, + app_service=self.app_service, toast=self.toastRequested.emit, on_reload=lambda: self._reload_games(), ) self.task_card = TaskCardController( game_list=self.game_list, - service=self.service, + app_service=self.app_service, toast=self.toastRequested.emit, ) self.background = BackgroundController( @@ -51,13 +53,13 @@ def __init__(self): self.launch = LaunchController( game_list=self.game_list, task_card=self.task_card, - service=self.service, + app_service=self.app_service, toast=self.toastRequested.emit, ) self.links = LinksController( game_list=self.game_list, toast=self.toastRequested.emit, - script_service=ScriptService(), + app_service=self.app_service, ) self.window = WindowController() # UI 矢量图标提供器(无状态,门面持有) diff --git a/src/gui/qml/task_card.qml b/src/gui/qml/task_card.qml index 8ec4798..adffbd4 100644 --- a/src/gui/qml/task_card.qml +++ b/src/gui/qml/task_card.qml @@ -5,7 +5,7 @@ import OneDragonHelper 1.0 // 数据经 Bridge 暴露:taskTitle / taskAdapted / weeklySupported / dailyDungeonText / // weeklyItems / dungeonOptions;副本写回经 selectDungeon / selectWeeklyDungeon; // 周几起(weekly_start)经单脚本配置弹窗(dialogs)落盘 weekly_start.yml。 -// dungeon/sequence 持久化到 gui_state.json;周几起(weekly_start)持久化到 weekly_start.yml。 +// dungeon/sequence 持久化到子脚本 config;周几起(weekly_start)持久化到 weekly_start.yml。 // 启用控制不在此卡:日常靠控制模式、周常靠周几起(均在别处实现),本卡只做副本选择。 // // 「周几起」选择已迁至单脚本配置弹窗(≡ 按钮打开),本卡只显示周常名占位。 diff --git a/src/gui/shutdown_dialog.py b/src/gui/shutdown_dialog.py new file mode 100644 index 0000000..e53d194 --- /dev/null +++ b/src/gui/shutdown_dialog.py @@ -0,0 +1,107 @@ +"""关机倒计时确认窗(GUI 层)。 + +确认窗是 PySide6 实现,样式复用 ``src.gui.dialogs`` 的主题常量与弹窗基类(单一来源)。 +``utils_shutdown`` 只保留「确认后执行 shutdown 命令」的纯逻辑,弹窗经**延迟 import** +引入本模块,避免底层反向依赖上层(否则 ``schedule → utils_shutdown → +gui.dialogs → app_service → chain_service → schedule`` 成环)。 +""" + +import logging +import sys + +from PySide6.QtCore import Qt, QTimer +from PySide6.QtWidgets import ( + QApplication, + QDialog, + QLabel, + QVBoxLayout, +) + +from src.gui.dialogs import ( + BG_CARD, + FONT_SIZE_BODY, + TEXT, + FormDialogBase, + make_font, +) + +logger = logging.getLogger(__name__) + + +def confirm_shutdown(countdown: int) -> bool: + """弹倒计时确认窗并等待用户选择(关机流程的 GUI 侧入口)。 + + Args: + countdown: 倒计时秒数。 + + Returns: + 确认返回 True;取消/关窗/弹窗失败返回 False。 + """ + try: + if QApplication.instance() is None: + QApplication(sys.argv) + return ShutdownConfirmDialog(countdown).exec() == QDialog.DialogCode.Accepted + except Exception as e: # 无桌面等环境下 Qt 初始化会失败,属可预见 + logger.error("关机确认窗初始化失败 %s(%s),按取消处理", type(e).__name__, e) + return False + + +class ShutdownConfirmDialog(FormDialogBase): + """关机倒计时确认窗:倒计时归零或点「立即关机」→ accept;取消/关窗 → reject。 + + 复用 ``FormDialogBase`` 的样式与底部按钮行(取消 / 立即关机);倒计时由 + ``QTimer`` 每秒递减,归零即 accept。是否真关机由调用方据 ``exec()`` 结果决定。 + + Args: + countdown: 倒计时秒数,须大于 0。 + parent: 父窗口;关机窗由独立控制台进程弹出,通常无父窗口。 + """ + + def __init__(self, countdown: int, parent=None): + super().__init__(parent) + # 调用方(build_post_run_pipeline)仅在 delay>0 时挂关机步骤,0/负数是编程错误。 + assert countdown > 0 + self._remain = countdown + + self.setWindowTitle("即将关机") + self.setWindowFlag(Qt.WindowStaysOnTopHint) + self.setStyleSheet(f"background-color: {BG_CARD};") + self.setMinimumWidth(360) + + layout = QVBoxLayout(self) + layout.setContentsMargins(18, 18, 18, 18) + layout.setSpacing(14) + + self._label = QLabel(self) + self._label.setFont(make_font(size=FONT_SIZE_BODY)) + self._label.setStyleSheet(f"color: {TEXT}; background: transparent;") + layout.addWidget(self._label) + + layout.addLayout(self._make_footer("立即关机", self.accept)) + + self._timer = QTimer(self) + self._timer.setInterval(1000) + self._timer.timeout.connect(self._tick) + self._show_remain() + + def _show_remain(self) -> None: + """按剩余秒数刷新文案。""" + self._label.setText(f"系统将在 {self._remain} 秒后关机") + + def _tick(self) -> None: + """每秒递减一秒;归零停表并接受(关机)。""" + self._remain -= 1 + self._show_remain() + if self._remain <= 0: + self._timer.stop() + self.accept() + + def showEvent(self, event) -> None: + """显示即起倒计时(``exec()`` 为模态,显示后才计时才不会提前流逝)。""" + super().showEvent(event) + self._timer.start() + + def hideEvent(self, event) -> None: + """关闭即停表(accept/reject 都会走到),避免挂窗后定时器空转。""" + self._timer.stop() + super().hideEvent(event) diff --git a/src/launcher.py b/src/launcher.py index 0a5edbb..53dcf7d 100644 --- a/src/launcher.py +++ b/src/launcher.py @@ -17,14 +17,14 @@ from PySide6.QtWidgets import QApplication from src.cli import build_parser, run_cli -from src.config.subscript import ( +from src.gui.main_window import QmlBridge +from src.utils import get_config_yml_path_under_root, get_schedule_yml_path_under_root +from src.utils_logger import setup_logging +from src.utils_sub_config import ( generate_config_from_example, generate_schedule_from_example, resolve_script_path, ) -from src.gui.main_window import QmlBridge -from src.utils import get_config_yml_path_under_root, get_schedule_yml_path_under_root -from src.utils_logger import setup_logging # 全局默认字体:QML Text 默认字体中文字符 fallback;与旧 GUI 一致 FONT_FAMILY = "Microsoft YaHei" diff --git a/src/log/__init__.py b/src/log/__init__.py index a2c0e1a..471d325 100644 --- a/src/log/__init__.py +++ b/src/log/__init__.py @@ -3,7 +3,7 @@ 迁移自原 `scripts/collect_log.py`;核心解析逻辑(Parser 类、辅助函数、 `parse_log` / `parse_logs`)现置于本包。诊断入口为 `python -m src.log` (`__main__` 调用 `parse_logs(do_log=True)` 打印当日汇总报告)。 -本包不依赖项目其余运行时模块(除 `src.config.subscript.get_script_name`), +本包不依赖项目其余运行时模块(除 `src.utils_sub_config.get_script_name`), 保持独立可测试。 本包对外暴露的公开符号列入 `__all__`,下游可 `import src.log as collect_log` diff --git a/src/log/__main__.py b/src/log/__main__.py index 2f5e397..07f0252 100644 --- a/src/log/__main__.py +++ b/src/log/__main__.py @@ -7,8 +7,8 @@ import logging from pathlib import Path -from src.config.subscript import get_script_name from src.log.monitor import parse_logs +from src.utils_sub_config import get_script_name from src.utils_yaml import load_yaml if __name__ == "__main__": diff --git a/src/log/monitor.py b/src/log/monitor.py index 0060fcf..bade7e1 100644 --- a/src/log/monitor.py +++ b/src/log/monitor.py @@ -9,7 +9,7 @@ 本模块为 `src.log` 包的子模块,由 `python -m src.log`(__main__ 入口)或 GUI/service 以 `import src.log.monitor` 方式调用,不单独运行。除复用脚本唯一标识 `get_script_name`(见 -`src.config.subscript`)外,不依赖项目内其余模块;根目录由 `_get_root_dir` 推导,并直接 +`src.utils_sub_config`)外,不依赖项目内其余模块;根目录由 `_get_root_dir` 推导,并直接 读取 `config.yml`(经 `src.utils_yaml.load_yaml`,ruamel YAML 1.2 解析)。 """ @@ -22,8 +22,8 @@ from datetime import datetime, timedelta from pathlib import Path -from src.config.subscript import get_script_name from src.utils_logger import setup_logging +from src.utils_sub_config import get_script_name from src.utils_yaml import load_yaml, load_yaml_optional logger = logging.getLogger(__name__) @@ -105,7 +105,7 @@ class ScriptLogStatus: class BaseLogParser: - # 脚本唯一标识(与全链路一致,见 src.config.subscript.get_script_name): + # 脚本唯一标识(与全链路一致,见 src.utils_sub_config.get_script_name): # exe 脚本=进程名(script_path basename 去后缀,空格→-),python 脚本=display_name。 # parse_log 与 supported 推导都按它匹配,不再依赖易变的 display_name。 script_name: str = "" diff --git a/src/service/README.md b/src/service/README.md index da8d595..9e61863 100644 --- a/src/service/README.md +++ b/src/service/README.md @@ -1,30 +1,39 @@ -# src/service — 外观层 facade +# src/service — 服务层(AppService 组合根 + 平级 peer) -整合其余部分的外观:把 set_config、runner、gui_state.json、链生成与校验内聚为统一薄接口,对 GUI 与 CLI 暴露同一套调用面。从 src/gui/ 分离而出,无 Qt 依赖,故 GUI 与 CLI 共用同一实现,也便于无头测试。 +把 set_config、runner、链生成与校验内聚为统一薄接口,对 GUI 与 CLI 暴露同一套调用面。 +从 src/gui/ 分离而出,无 Qt 依赖,故 GUI 与 CLI 共用同一实现,也便于无头测试。 ## 设计定位 | 角色 | 说明 | |------|------| -| 外观,非适配器 | set_config 才是适配器;本层负责整合,是项目唯一外观 | -| 双入口薄适配 | GUI 与 CLI 都只做薄委托,真实实现在本层 | -| 写盘唯一路径 | config.yml 写入权统一归 ChainService | -| 无 Qt 依赖 | 纯业务逻辑,不承载 UI 渲染 | +| 组合根,非协调器 peer | `AppService` 装配各 peer 并薄委托,是 GUI/CLI 唯一入口;各 peer 互不越界 | +| 平级 peer | 单脚本配置(src.utils_config)/ ChainService 互不拥有,由组合根装配 | +| 周常运行期参数 | weekly_start.yml / weekly_timeouts.yml 的读写归 `src.utils_weekly` 模块函数(无状态、无 peer 实例);schedule.yml 归 schedule 模块函数 | +| 无 Qt 依赖 | 纯业务逻辑,不承载 UI 渲染(关机确认窗归 `src/gui/shutdown_dialog.py`) | ## 文件 | 模块 | 职责 | |------|------| -| chain_service.py | 核心 facade:config.yml 完整读写、UI 状态持久化、脚本链生成、合法性校验、runner 命令构造 | -| script_service.py | 单脚本视角:config.yml 单条目只读、weekly_timeouts.yml 读写与改名迁移 | -| chain_gen.py | 脚本链配置生成:由 enabled_names + gui_state 生成链配置并校验 | +| app_service.py | 组合根:装配 peer 并薄委托,GUI/CLI 唯一入口 | +| utils_config.py | 单脚本配置(原 script_service.py 已退化为模块函数):config.yml 完整读写(含条目增删改)+ get_script / build_script_entry / config_file_path | +| chain_service.py | 链编排 peer:链生成、合法性校验、runner 命令构造、调度运行入口 | +| chain_gen.py | 脚本链配置生成:由 enabled_names + 子脚本 config 生成链配置并校验 | +| schedule.py | schedule.yml 读写(模块函数)+ ScheduledRun 调度运行编排 | +| run_actions.py | pre_run / post_run 各 step 的具体动作 | ## 依赖方向 ``` launcher.py CLI ┐ - ├─▶ ChainService facade ─▶ set_config / runner / subscript / utils_runner -MainWindow GUI ┘ + ├─▶ AppService(组合根)─┬─▶ src.utils_config(单脚本配置)─▶ src.utils_weekly(协作同步 weekly) +MainWindow GUI ┘ ├─▶ dungeon_config 模块函数(副本 / 周本声明,src.config) + └─▶ ChainService ─▶ chain_gen / schedule / utils_runner + └─▶ src.utils_weekly(周常参数读写) ``` -调用方不感知 weekly_timeouts 同步、链合法性校验、runner 命令构造等细节,全部内聚在 service/。 +调用方不感知 weekly 同步、链合法性校验、runner 命令构造等细节,全部内聚在 service/。 + +`utils_shutdown.py` 不得模块级依赖 GUI 层:否则 `schedule → utils_shutdown → +gui.dialogs → app_service → chain_service → schedule` 成环,确认窗实现于 `src/gui/shutdown_dialog.py`,`utils_shutdown` 仅延迟 import 它。 diff --git a/src/service/app_service.py b/src/service/app_service.py new file mode 100644 index 0000000..935de8f --- /dev/null +++ b/src/service/app_service.py @@ -0,0 +1,185 @@ +"""AppService:组合根(composition root),GUI/CLI 唯一服务入口。 + +持有平级 peer 并薄委托,使各 peer 互不越界——ChainService 只是被本类组合的一个链领域 peer,自身只负责链的生成/运行/校验。 + +peer: +- 单脚本配置(config.yml 读写含脚本条目增删改):归 :mod:`src.utils_config` 模块函数 +- 副本与周常声明读取(dungeon_list.yml / weekly_list.yml):归 :mod:`src.config.dungeon_config` 模块函数 +- ChainService:链编排领域服务(生成/运行/调度/校验) +- schedule.yml 读写:归 :mod:`src.service.schedule` 的模块函数(与调度编排同处一模一样) +- 周常运行期参数(weekly_start.yml / weekly_timeouts.yml):归 :mod:`src.utils_weekly` 模块函数 + +GUI(MainWindow)与 CLI(各子命令)都只实例化本类,控制器经构造注入持有它; +未来 GUI 同类操作优先经 CLI 完成,本类即两者的共同装配点。 +""" + +import logging + +from src.config.dungeon_config import get_dungeon_map, get_weekly_map +from src.service.chain_service import ChainService +from src.service.schedule import load_schedule, save_schedule +from src.utils_config import ( + add_script, + build_script_entry, + config_file_path, + get_script, + load_config, + remove_script, + save_config, + update_script, +) +from src.utils_weekly import ( + check_weekly, + get_weekly_start, + get_weekly_start_map, + set_weekly_start, + weekly_inputs, +) + +logger = logging.getLogger(__name__) + + +class AppService: + """组合根:装配平级 service peer 并向外暴露统一接口(GUI/CLI 唯一门面)。""" + + def __init__( + self, + chain_service=None, + ): + """装配各 peer。 + + Args: + chain_service: 可注入的 ChainService;None 时自建。 + """ + self._chain_service = chain_service or ChainService() + + # ── 副本 / 周常声明(src.config.dungeon_config 模块函数)──────────── + def get_weekly_map(self, script_name: str) -> list: + """读取 weekly_list.yml 的周常声明清单。""" + return get_weekly_map(script_name) + + def get_dungeon_map(self) -> dict: + """读取 dungeon_list.yml 的副本/序列配置。""" + return get_dungeon_map() + + # ── 单脚本配置(src.utils_config 模块函数)───────────────────────── + def get_script(self, script_name: str): + """按脚本唯一标识读取单个脚本条目。""" + return get_script(script_name) + + def build_script_entry(self, file_path: str, existing_script_names: set) -> dict: + """按文件路径构造脚本条目(去重命名 + 类型推断 + 默认字段补全)。""" + return build_script_entry(file_path, existing_script_names) + + def config_file_path(self, script_name: str): + """返回该脚本「配置文件」的本地路径(用于外部打开)与失败原因。""" + return config_file_path(script_name) + + # ── 周常运行期参数(src.utils_weekly 模块函数)── + # weekly_start.yml(周几起)与 weekly_timeouts.yml(每周超时)由 src.utils_weekly + # 拥有;读写直接调模块函数,不经 ChainService 转发。 + def get_weekly_start(self, script_name: str): + """返回某脚本的周常起始日(1~7),未设置返回 None。""" + return get_weekly_start(script_name) + + def weekly_inputs(self, script_name: str) -> list: + """返回配置弹窗 7 个超时输入框的初始值。""" + return weekly_inputs(script_name) + + def set_weekly_start(self, script_name: str, start_day) -> None: + """持久化某脚本的周常起始日(周几起)到 weekly_start.yml。""" + return set_weekly_start(script_name, start_day) + + def get_weekly_start_map(self) -> dict: + """读取 weekly_start.yml 全量映射({脚本标识: 1~7})。""" + return get_weekly_start_map() + + def check_weekly(self) -> dict: + """校验 weekly_timeouts.yml 与 config.yml 脚本条目的一致性。 + + Returns: + 一致性结果字典(含 status / missing_or_short / orphans)。 + """ + return check_weekly(load_config()) + + # ── 配置读写(src.utils_config 模块函数)── + # config.yml 读写(含脚本条目增删改)归 :mod:`src.utils_config`;此处仅作薄委托。 + def load_config(self) -> dict: + return load_config() + + def save_config(self, data: dict) -> None: + return save_config(data) + + def add_script(self, script_data: dict) -> None: + return add_script(script_data) + + def remove_script(self, script_name: str) -> None: + return remove_script(script_name) + + def update_script( + self, + old_script_name: str, + new_display_name: str, + config_patch: dict, + weekly_timeouts: list, + ): + return update_script( + old_script_name, new_display_name, config_patch, weekly_timeouts + ) + + # ── schedule.yml(src.service.schedule 模块函数)── + # schedule.yml 的读写与调度编排同处 src.service.schedule,不挂在任何 peer 实例上; + # 此处作薄委托,对外接口保持稳定、避免 GUI/CLI 直接依赖该模块。 + def load_schedule(self) -> dict: + return load_schedule() + + def save_schedule(self, data: dict) -> None: + return save_schedule(data) + + def collect_invalid_scripts(self, script_list: list) -> list: + return self._chain_service.collect_invalid_scripts(script_list) + + def generate_chain( + self, + all_config_data: dict, + enabled_keys: set, + chain_name: str = "today", + out_path: str | None = None, + ) -> str: + return self._chain_service.generate_chain( + all_config_data, enabled_keys, chain_name, out_path + ) + + def build_chain_command(self, chain_config_path: str, extra_args=None): + return self._chain_service.build_chain_command(chain_config_path, extra_args) + + def run_chain_command( + self, chain_config_path: str, block: bool = True, extra_args=None + ): + return self._chain_service.run_chain_command( + chain_config_path, block, extra_args + ) + + def run_chain_once( + self, enabled_keys: set | None = None, *, chain_name: str = "today" + ): + return self._chain_service.run_chain_once(enabled_keys, chain_name=chain_name) + + def schedule_run( + self, + enabled_keys, + target_time: str, + *, + chain_name: str = "today", + mute: bool = False, + shutdown_delay=None, + close_running: bool = True, + ): + return self._chain_service.schedule_run( + enabled_keys, + target_time, + chain_name=chain_name, + mute=mute, + shutdown_delay=shutdown_delay, + close_running=close_running, + ) diff --git a/src/service/chain_gen.py b/src/service/chain_gen.py index ace4787..a98598b 100644 --- a/src/service/chain_gen.py +++ b/src/service/chain_gen.py @@ -2,8 +2,8 @@ 复刻 ``MainWindow._generate_config`` 的核心,但去掉 QWidget 依赖: - 启用脚本集合由调用方以 ``enabled_names`` 传入; -- 副本/序列选择从 ``gui_state.json``(UI 状态)读取,并按 dungeon_list 选项校验, - 与 ``ScriptItem.__init__`` 构造时的取数逻辑一致。 +- 副本/序列选择来自子脚本 config(GUI/CLI 编辑期经 set_config 实时落盘), + 按 dungeon_list 选项校验。 脚本配置合法性校验(对齐 runner invalid_message)见 ``src.utils_runner``。 自 ``src.gui.chain`` 迁出:不依赖 Qt,收编到 service 层便于无头测试与 GUI/CLI 共用。 @@ -12,11 +12,11 @@ import copy import logging -from src.config.subscript import DEFAULT_RUN_TIMEOUT, get_script_name from src.utils import ( get_path_under_root, safe_path_join, ) +from src.utils_sub_config import DEFAULT_RUN_TIMEOUT, get_script_name from src.utils_weekly import get_week_num from src.utils_yaml import dump_yaml @@ -63,8 +63,8 @@ def resolve_weekly_start(weekly_start_map: dict, script_name: str) -> int | None 「今天周几 >= 起始日」由 set_config 判断启用/停用写入脚本配置 (与日常副本选择落盘不受日常开关影响的模型一致)。 - 起始日来源为 weekly_start.yml(运行时由 ScriptService 持久化),经 - weekly_start_map 传入,不再来自 gui_state.json。 + 起始日来源为 weekly_start.yml(运行时由 src.utils_weekly 持久化),经 + weekly_start_map 传入。 Args: weekly_start_map: weekly_start.yml 的全量映射({脚本标识: 1~7})。 @@ -99,7 +99,7 @@ def generate_chain_config( 计算的周本开关写盘,已抽出为 ``ScheduledRun`` 的 pre_run 步骤(由 ``build_pre_run_pipeline`` 在运行前统一写回), 故本函数只负责按星期过滤脚本并生成链 yml,不再写任何子脚本 config。 - weekly_timeouts 由调用方(ChainService)通过 ScriptService 加载后传入,不再直接读取磁盘文件。 + weekly_timeouts 由调用方(ChainService)通过 src.utils_config 加载后传入,不再直接读取磁盘文件。 Args: all_config_data: config.yml 完整数据(含 script_list)。 diff --git a/src/service/chain_service.py b/src/service/chain_service.py index 63e7479..6210aab 100644 --- a/src/service/chain_service.py +++ b/src/service/chain_service.py @@ -1,33 +1,28 @@ -"""ChainService:脚本链核心服务(GUI / CLI 唯一 facade)。 +"""ChainService:链编排领域服务(平级 peer,非协调器)。 -承载「真实实现」:config.yml 完整读写(含单脚本字段更新)、UI 状态持久化 -(gui_state.json)、脚本链生成、合法性校验、runner 命令构造。 +承载链领域实现:脚本链生成、合法性校验、runner 命令构造、调度运行的编排。 +config.yml 的读写由 :mod:`src.utils_config` 拥有,本服务仅保留 +``load_config`` 委托供运行时取配置;schedule.yml 读写归 +:mod:`src.service.schedule` 所有——与调度编排同处本模块。本服务不读取 +UI 状态文件;日常副本真源为子脚本 config,set_dungeon 为 +no-op 的脚本取 dungeon_list.yml 声明项。本服务 +不充当 GUI/CLI 的顶层门面/协调器—— +该角色由 :class:`AppService`(组合根)承担,本类只是被其组合的一个 peer。 -weekly_timeouts 同步由内部 ScriptService 处理,调用方不感知。GUI(MainWindow) -与 CLI(launcher.py)都作为薄适配器依赖本服务。 +weekly 运行期参数(weekly_start.yml / weekly_timeouts.yml)由 :mod:`src.utils_weekly` +模块函数提供,调用方不感知。 本模块不承载 UI 渲染/弹窗逻辑,无 Qt 依赖。 """ -import json import logging import os import subprocess -from src.config.subscript import ( - check_script_name_uniqueness, - get_script_name, -) +from src import utils_config from src.log.monitor import parse_logs from src.service.chain_gen import generate_chain_config as _generate_chain_config -from src.service.script_service import ScriptService -from src.utils import ( - get_config_yml_path_under_root, - get_root_dir, - get_schedule_yml_path_under_root, - require_config_yml_path, - safe_path_join, -) +from src.service.schedule import ScheduledRun from src.utils_runner import ( build_chain_command as _build_chain_command, ) @@ -40,229 +35,38 @@ from src.utils_runner import ( run_chain_command as _run_chain_command, ) -from src.utils_yaml import dump_yaml, load_yaml +from src.utils_sub_config import ( + get_script_name, +) +from src.utils_weekly import get_weekly_start_map, load_all_weekly, set_weekly_start logger = logging.getLogger(__name__) -_STATE_FILE = safe_path_join(get_root_dir(), "config", "gui_state.json") - -def resolve_mail_config(all_config: dict) -> dict | None: - """从 config 解析有效邮件配置:notify.enabled 非 true 或 email/password 缺失返回 None。 +class ChainService: + """链编排领域服务(平级 peer):链生成、校验、运行命令构造、调度运行编排。 - ``schedule_run`` 在链路点火后调用,将结果透传 ``build_post_run_pipeline``;返回 None - 表示不发邮件(默认关闭),与旧 notify_mail.yml「缺字段即跳过」语义一致。 + config.yml 读写由 :mod:`src.utils_config` 拥有,本服务仅委托 ``load_config`` + 供运行时取配置;weekly_timeouts / weekly_start 由 :mod:`src.utils_weekly` 提供, + 本服务直接调用模块函数(链生成取超时、调度运行取周几起)。 """ - notify = all_config.get("notify") - if not isinstance(notify, dict) or not notify.get("enabled", False): - return None - email = (notify.get("email") or "").strip() - password = (notify.get("password") or "").strip() - if not email or not password: - logger.warning("[chain] 邮件未启用或 email/password 缺失,跳过: %s", notify) - return None - return notify - - -class ChainService: - """脚本链核心服务:config.yml 读写、链生成、校验、运行命令构造, - 内部集成 ScriptService 处理 weekly_timeouts 同步。""" def __init__(self, script_service=None): """初始化 ChainService。 Args: - script_service: 可注入的 ScriptService;None 时自建默认实例。 + script_service: 可注入的 config 加载器(须提供 ``load_config``);None 时 + 默认用 :mod:`src.utils_config` 模块。 """ - self._script_service = script_service or ScriptService() - # UI 状态(gui_state.json)单一实例:懒加载,load/save 均围绕它, - # 避免各处独立 load 出不同内存副本、在 save 时互相覆盖。 - self._ui_state: dict | None = None + self._script_service = script_service or utils_config - # ---------- 配置读写 ---------- + # ---------- 配置读取(委托 config 加载器)---------- + # config.yml 的读写实现已迁至 :mod:`src.utils_config`;此处仅保留 + # load_config 委托,供本服务运行时(run_chain_once / ScheduledRun)取配置。 def load_config(self) -> dict: - """读取 config.yml(断言存在),返回完整 script_list 配置。 - - 结果从外部 YAML 载入——入口处一次性校验每个条目含 display_name/script_path - 且脚本唯一标识唯一,``script_list`` 内部数据此后可安全用直接访问。 - """ - config_path = require_config_yml_path() - data = load_yaml(config_path) - assert isinstance(data, dict) and "script_list" in data, ( - "[service] config.yml 缺少 script_list 字段" - ) - for s in data["script_list"]: - assert "display_name" in s, ( - f"[service] script_list 条目缺少 display_name: {s}" - ) - assert "script_path" in s, ( - f"[service] script_list 条目缺少 script_path: {s}" - ) - check_script_name_uniqueness(data) - return data - - def save_config(self, data: dict) -> None: - """写回 config.yml(生成目标,不要求已存在)。 - - Args: - data: 完整 script_list 配置字典。 - """ - assert isinstance(data, dict) and "script_list" in data, ( - "[service] 待保存的 config 缺少 script_list 字段" - ) - config_path = get_config_yml_path_under_root() - dump_yaml(config_path, data) - - def load_schedule(self) -> dict: - """读取 schedule.yml(缺失时从 schedule.example.yml 生成),返回调度运行参数。 - - 调度参数(shutdown / timed_run / mute / rerun / notify)已从 config.yml 迁出, - 独立存放于此,避免与脚本链声明(script_list)耦合。 - """ - return load_yaml(get_schedule_yml_path_under_root()) - - def save_schedule(self, data: dict) -> None: - """写回 schedule.yml(生成目标,不要求已存在)。 - - Args: - data: 完整调度运行参数字典(由 apply_* 原地修改后传入)。 - """ - assert isinstance(data, dict), "[service] 待保存的 schedule 非 dict" - schedule_path = get_schedule_yml_path_under_root() - dump_yaml(schedule_path, data) - - def add_script(self, script_data: dict) -> None: - """向 config.yml 的 script_list 追加一个脚本条目,并自动创建 weekly 默认条目。 - - 脚本唯一标识(get_script_name)不得与已有条目重复(数据完整性约束)。 - - Args: - script_data: 完整脚本条目 dict(含 display_name / script_path 等)。 - """ - assert "display_name" in script_data, "[service] script_data 缺少 display_name" - assert "script_path" in script_data, "[service] script_data 缺少 script_path" - config = self.load_config() - scripts = config.setdefault("script_list", []) - new_script_name = get_script_name(script_data) - assert all(get_script_name(s) != new_script_name for s in scripts), ( - f"[service] 脚本标识已存在: {new_script_name}" - ) - scripts.append(script_data) - self.save_config(config) - self._script_service.ensure_weekly_entry(new_script_name) - from src.config.set_config import init_config - - init_config(new_script_name) - - def remove_script(self, script_name: str) -> None: - """从 config.yml 的 script_list 移除指定脚本条目,并自动清理 weekly 孤儿。 - - Args: - script_name: 要移除的脚本唯一标识。 - """ - config = self.load_config() - scripts = config.setdefault("script_list", []) - target = next( - (s for s in scripts if get_script_name(s) == script_name), - None, - ) - assert target is not None, f"[service] 找不到脚本: {script_name}" - scripts.remove(target) - self.save_config(config) - self._script_service.delete_weekly(script_name) - - def update_script( - self, - old_script_name: str, - new_display_name: str, - config_patch: dict, - weekly_timeouts: list[int | None], - ) -> str: - """更新单个脚本条目字段并同步 weekly_timeouts。 - - 以脚本唯一标识定位条目;自动处理标识变更(含 weekly 迁移)与 - kill_game_after_done 自洽(未设置 game_process_name 时强制 False)。 - - Args: - old_script_name: 原脚本唯一标识(用于定位条目)。 - new_display_name: 新 display_name(展示名,可保留原名)。 - config_patch: 要写入条目顶层字段的映射(如 script_path/check_done)。 - weekly_timeouts: 7 格超时输入值,空输入为 None(落盘前转默认超时)。 - - Returns: - 落盘后的脚本唯一标识(标识可能因 script_path/display_name 变更而改变), - 供调用方在落盘后触发依赖新路径的后续动作(如游戏侧周几起同步)。 - """ - assert new_display_name, "[service] 脚本名称不能为空" - config = self.load_config() - target = None - for script in config.setdefault("script_list", []): - if get_script_name(script) == old_script_name: - target = script - break - assert target is not None, f"[service] 找不到脚本: {old_script_name}" - - for key, value in config_patch.items(): - target[key] = value - target["display_name"] = new_display_name - - new_script_name = get_script_name(target) - if new_script_name != old_script_name: - assert all( - get_script_name(s) != new_script_name - for s in config["script_list"] - if s is not target - ), f"[service] 脚本标识已存在: {new_script_name}" - - # 配置自洽:未设置游戏进程名时「运行后关闭游戏」强制 False - if not target.get("game_process_name", ""): - target["kill_game_after_done"] = False - - self.save_config(config) - - if new_script_name != old_script_name: - self._script_service.rename_weekly_in_timeouts( - old_script_name, new_script_name - ) - self._script_service.save_weekly(new_script_name, weekly_timeouts) - from src.config.set_config import init_config - - init_config(new_script_name) - return new_script_name - - def load_ui_state(self) -> dict: - """返回 UI 状态单一实例(懒加载自 gui_state.json)。 - - 多次调用返回同一对象:消除各处独立 load 出的不同内存副本在 save 时 - 互相覆盖的风险(如一处在 save 前改了内存态、另一处 load 出旧盘内容)。 - 文件不存在时返回空 dict 并缓存。 - - Returns: - 状态字典;文件不存在时返回空 dict。 - """ - if self._ui_state is None: - if os.path.exists(_STATE_FILE): - with open(_STATE_FILE, encoding="utf-8") as f: - self._ui_state = json.load(f) - else: - self._ui_state = {} - return self._ui_state - - def save_ui_state(self, state: dict | None = None) -> None: - """将 UI 状态写回 gui_state.json。 - - state 省略时写当前单一实例(self._ui_state);显式传入时先替换实例再写。 - 写前会同步 self._ui_state,保证后续 load_ui_state 返回已保存内容。 - - Args: - state: 要写入 gui_state.json 的状态字典;None 时写当前实例。 - """ - if state is not None: - self._ui_state = state - assert self._ui_state is not None, "save_ui_state 调用前需先 load_ui_state" - with open(_STATE_FILE, "w", encoding="utf-8") as f: - json.dump(self._ui_state, f, ensure_ascii=False, indent=2) + """委托 config 加载器读取 config.yml(默认 :mod:`src.utils_config`)。""" + return self._script_service.load_config() # ---------- 链生成与校验 ---------- @@ -275,7 +79,7 @@ def generate_chain( ) -> str: """生成 ScriptChainer 配置文件(仅含启用脚本)。 - weekly_timeouts 通过 ScriptService 加载后传入 chain_gen,不再由 + weekly_timeouts 通过 utils_config 加载后传入 chain_gen,不再由 chain_gen 直接读取磁盘文件。 Args: @@ -287,7 +91,7 @@ def generate_chain( Returns: 输出文件路径。 """ - weekly_timeouts = self._script_service.load_all_weekly() + weekly_timeouts = load_all_weekly() return _generate_chain_config( all_config_data, enabled_keys, @@ -301,13 +105,13 @@ def generate_chain( def set_weekly_start(self, script_name: str, start_day: int | None) -> None: """持久化某脚本的周常起始日到 weekly_start.yml(None 表示「不设置」)。 - 委托内部 ScriptService,调用方(CLI)不感知底层文件。 + 委托 src.utils_weekly,调用方(CLI)不感知底层文件。 """ - self._script_service.set_weekly_start(script_name, start_day) + set_weekly_start(script_name, start_day) def get_weekly_start_map(self) -> dict: """读取 weekly_start.yml 全量映射({脚本标识: 1~7}),供运行前 pre_run 写回子脚本 config。""" - return self._script_service.get_weekly_start_map() + return get_weekly_start_map() def collect_invalid_scripts(self, script_list: list[dict]) -> list[tuple[str, str]]: """收集脚本列表中配置不合法的条目。 @@ -357,7 +161,7 @@ def run_chain_once( 始终返回 None(纯跑链,运行后动作交由调用方)。 """ all_config = self.load_config() - weekly_timeouts = self._script_service.load_all_weekly() + weekly_timeouts = load_all_weekly() _run_chain_once_impl( all_config, enabled_keys, @@ -396,7 +200,7 @@ def _rerun_round( keys = set(rerun_list) # 复用 _run_chain_once_impl(生成+运行原子),阻塞等重跑结束, # 使后续邮件/关机基于重跑后的最终态。 - weekly_timeouts = self._script_service.load_all_weekly() + weekly_timeouts = load_all_weekly() _run_chain_once_impl( all_config, keys, @@ -417,7 +221,7 @@ def schedule_run( """调度运行:组装 ``ScheduledRun`` 并执行的薄工厂。 完整编排(等待到点 → 生成并运行 → 可选重跑 → post_run)由 - ``src.service.scheduled_run.ScheduledRun`` 拥有;本方法仅作 facade 入口, + ``src.service.schedule.ScheduledRun`` 拥有;本方法仅作 facade 入口, 设计为在独立控制台进程(``utils_runner.spawn_schedule_run`` 以 ``CREATE_NEW_CONSOLE`` 起)中运行。 @@ -430,8 +234,6 @@ def schedule_run( shutdown_delay: 关机延迟秒数;None 表示不关机(含 0/未启用)。 close_running: 是否运行前关闭残留进程(由 ScheduledRun 的 pre_run 执行)。 """ - from src.service.scheduled_run import ScheduledRun - ScheduledRun( self, enabled_keys, diff --git a/src/service/run_actions.py b/src/service/run_actions.py index 488047d..54b63c3 100644 --- a/src/service/run_actions.py +++ b/src/service/run_actions.py @@ -1,6 +1,6 @@ """运行前/后各 step 的具体动作(pre_run / post_run 的「做什么」)。 -每个函数做一件事、参数全部显式传入(不依赖闭包捕获),由 ``scheduled_run`` 的 +每个函数做一件事、参数全部显式传入(不依赖闭包捕获),由 ``schedule`` 的 ``build_pre_run_pipeline`` / ``build_post_run_pipeline`` 组装成 step 序列并决定顺序。 步骤间的数据流(如日志分析结果 → 邮件)属组装关注点,留在 pipeline 内;本模块只提供 diff --git a/src/service/scheduled_run.py b/src/service/schedule.py similarity index 72% rename from src/service/scheduled_run.py rename to src/service/schedule.py index 8088e77..8793813 100644 --- a/src/service/scheduled_run.py +++ b/src/service/schedule.py @@ -1,4 +1,9 @@ -"""定时/即时运行编排:ScheduledRun 持有 pre_run / 核心编排 / post_run。 +"""调度运行编排 + schedule.yml 读写:ScheduledRun 持有 pre_run / 核心编排 / post_run。 + +schedule.yml(调度运行参数:shutdown / timed_run / mute / rerun / notify)的读写归 +本模块——``load_schedule`` / ``save_schedule``,其 notify 块经 ``resolve_mail_config`` +解析为 SMTP 配置;消费方几乎全在调度链路(重跑轮读 rerun、post_run 读 notify), +故读写与编排同处本模块。 ``ScheduledRun`` 是一个带生命周期的对象,而非纯函数:它在独立控制台进程 (由 ``utils_runner.spawn_schedule_run`` 以 ``CREATE_NEW_CONSOLE`` 起)中运行, @@ -14,7 +19,6 @@ import logging from collections.abc import Callable, Sequence -from src.service.chain_service import resolve_mail_config from src.service.run_actions import ( analyze_logs, apply_subscript_config, @@ -22,31 +26,70 @@ send_summary_mail, wait_until_target, ) +from src.utils import get_schedule_yml_path_under_root from src.utils_mute import mute_off, mute_on from src.utils_shutdown import shutdown_sys +from src.utils_yaml import dump_yaml, load_yaml logger = logging.getLogger(__name__) +def load_schedule() -> dict: + """读取 schedule.yml(缺失时从 schedule.example.yml 生成),返回调度运行参数。 + + 调度参数(shutdown / timed_run / mute / rerun / notify)独立于 config.yml 存放, + 避免与脚本链声明(script_list)耦合。 + """ + return load_yaml(get_schedule_yml_path_under_root()) + + +def save_schedule(data: dict) -> None: + """写回 schedule.yml(生成目标,不要求已存在)。 + + Args: + data: 完整调度运行参数字典(由调用方原地修改后传入)。 + """ + assert isinstance(data, dict), "[schedule] 待保存的 schedule 非 dict" + dump_yaml(get_schedule_yml_path_under_root(), data) + + +def resolve_mail_config(schedule: dict) -> dict | None: + """从 schedule.yml 数据解析有效邮件配置;未启用或字段缺失返回 None。 + + ``notify.enabled`` 非 true、或 email/password 缺失时返回 None,表示不发邮件 + (默认关闭),字段缺失即跳过发送。 + + Args: + schedule: schedule.yml 全量数据(含 notify 块)。 + + Returns: + 有效的 notify 配置字典;不发邮件时返回 None。 + """ + notify = schedule.get("notify") + if not isinstance(notify, dict) or not notify.get("enabled", False): + return None + email = (notify.get("email") or "").strip() + password = (notify.get("password") or "").strip() + if not email or not password: + logger.warning("[schedule] 邮件未启用或 email/password 缺失,跳过: %s", notify) + return None + return notify + + def build_pre_run_pipeline( *, target_time: str, scripts: list[dict] | None = None, enabled_keys: set[str] | None = None, weekly_start_map: dict | None = None, - close_running: bool = False, + close_running: bool = True, mute: bool = False, ) -> list[Callable[[], None]]: - """运行前 step 列表(单一工厂,与 build_post_run_pipeline 同形)。 + """组装运行前 step 列表(单一工厂,与 build_post_run_pipeline 同形)。 - 固定顺序:等待到点(+可选静音) → 关闭残留进程 → 写回子脚本 config。各 step 均为 - 无参 Callable,由 ``ScheduledRun._run_steps`` 统一顺序执行。 - - 等待+静音置顶:定时运行整段含等待期全程静音,避免等待期噪音; - - 关闭残留紧贴运行前(等待之后):等待期内用户可能手动开了脚本/游戏, - 若在最开头就关闭会漏掉等待期新起的进程,须等真正运行前再清场; - 受 ``close_running`` 开关控制(默认关闭)。 - - 写回子脚本 config:关闭之后写,避开残留进程可能持有的文件锁; - 须早于核心运行(游戏/脚本启动时读 config)。 + 固定顺序:等待到点(+可选静音) → 关闭残留进程 → 写回子脚本 config;各 step 均为 + 无参 Callable,由 ``ScheduledRun._run_steps`` 统一顺序执行。每步的取舍理由见 + 对应内联注释。 Args: target_time: 目标时刻 ``"HH:MM"``;``"now"`` 表示即时运行(跳过等待)。 @@ -90,11 +133,8 @@ def build_post_run_pipeline( ) -> list[Callable[[], None]]: """按序构建运行后动作:日志分析(最终态) → 邮件 → 关机(末位)。 - 重跑已移出本 pipeline,作为运行主环节由 ``ChainService._rerun_round`` 在链运行 - 结束后、本 pipeline 触发前完成;此处只需对最终态做日志分析供邮件汇总,并在末位关机。 - - 日志分析结果经共享闭包 ``shared`` 从分析步骤流向邮件步骤——数据流属组装关注点, - 故留在工厂内,动作函数本身(``analyze_logs`` / ``send_summary_mail``)保持无状态。 + 重跑不在此处,由 ``ChainService._rerun_round`` 在链运行结束后、本 pipeline 前完成; + 此处对最终态做日志分析供邮件汇总,并在末位关机。 Args: shutdown_delay: 关机延迟秒数;None/0 表示不关机。 @@ -164,15 +204,9 @@ def __init__( # 语义处理,由调用方显式传入全量集合表达「全部」。 self.candidate_keys = enabled_keys - # pre_run / post_run:均为 step 列表(同形),分别经单一工厂组装、由 _run_steps 执行。 - # 仅所处位置不同(run 前 / 后),机制完全一致。 - # pre_run 顺序(由 build_pre_run_pipeline 内部固定):等待+静音 → 关闭残留 → 写子脚本 config。 - # - 等待+静音置顶:定时运行整段含等待期全程静音,避免等待期噪音; - # - 关闭残留紧贴运行前(即等待之后):等待期内用户可能手动开了脚本/游戏, - # 若在最开头就关闭会漏掉等待期新起的进程,须等真正运行前再清场,受 close_running 开关控制; - # - 写子脚本 config 在关闭之后:避开残留进程可能持有的文件锁,须早于核心运行。 - # close 步骤关的是 config 全量脚本(不按启用集合过滤):残留多为「昨天跑、今天不跑」 - # 的脚本遗留,按启用集合过滤恰好抓不住这类,故全量传入工厂。 + # pre_run / post_run 均为 step 列表(同形),分别经单一工厂组装、由 _run_steps 执行, + # 仅所处位置不同(run 前 / 后)。pre_run 顺序与每步取舍见 build_pre_run_pipeline 内联注释。 + # 关残留传全量脚本(非启用集合):残留多为「昨天跑、今天不跑」的脚本,按启用集过滤抓不到。 all_scripts = self.service.load_config().get("script_list", []) self.pre_run: list[Callable[[], None]] = build_pre_run_pipeline( target_time=target_time, @@ -184,8 +218,7 @@ def __init__( ) # post_run:日志分析最终态 → 邮件 → 恢复声音 → 关机(末位),由 build_post_run_pipeline 产出。 - # 邮件配置来自 schedule.yml 的 notify 块(已从 config.yml 迁出)。 - schedule = service.load_schedule() + schedule = load_schedule() mail_config = resolve_mail_config(schedule) self.post_run: list[Callable[[], None]] = build_post_run_pipeline( shutdown_delay=shutdown_delay, @@ -203,13 +236,11 @@ def run(self) -> None: def _run_core(self) -> None: """生成脚本链并运行,随后按需重跑失败脚本(先于 post_run)。""" all_config = self.service.load_config() - # 第一次跑:复用 run_chain_once 原子(生成+运行),与 ``_rerun_round`` 内的 - # 重跑路径完全一致(均阻塞),仅脚本集合(全部启用 vs 失败子集)与链名不同。 - # candidate_keys 为 None/空集合时 run_chain_once 按「跳过」语义不运行任何脚本。 + # 首次运行复用 run_chain_once(生成+运行原子);candidate_keys 为 None/空集合时按「跳过」语义不运行任何脚本。 self.service.run_chain_once(self.candidate_keys, chain_name=self.chain_name) # 重跑轮:链跑完后解析日志、对失败脚本二次运行(先于 post_run)。 # 受 schedule.yml 的 rerun.enabled 控制(契约键,缺失即 assert 崩,不降级)。 - schedule = self.service.load_schedule() + schedule = load_schedule() rerun_cfg = schedule.get("rerun") assert isinstance(rerun_cfg, dict) and "enabled" in rerun_cfg, ( "[chain] schedule 缺 rerun.enabled" diff --git a/src/service/script_service.py b/src/service/script_service.py deleted file mode 100644 index 7a200d5..0000000 --- a/src/service/script_service.py +++ /dev/null @@ -1,420 +0,0 @@ -"""ScriptService:单脚本配置服务(无 Qt 依赖)。 - -承载「单脚本」视角的实现:从 config.yml 读取单个脚本条目,管理 -weekly_timeouts.yml 的读写与改名迁移。 - -内部标识统一用**脚本唯一标识 script_name**(exe 脚本为进程名、脚本文件为 -display_name),display_name 仅用于展示。config.yml 的写入权统一归 -ChainService;本 Service 仅做只读查询与 weekly_timeouts 管理。对应 GUI 的 -ScriptItem 卡片与配置弹窗(SingleScriptConfigDialog)。 - -链编排(脚本列表/生成/运行)见 :mod:`src.service.chain_service`。 -""" - -import logging -import os - -from src.config.dungeon_config import load_dungeon_map -from src.config.set_config import get_config_path, get_dungeon_lists -from src.config.subscript import ( - DEFAULT_RUN_TIMEOUT, - default_script_entry, - get_script_name, - is_exe_script, - resolve_script_path, -) -from src.utils import ( - get_weekly_list_yml_path_under_root, - get_weekly_start_yml_path_under_root, - get_weekly_timeouts_yml_path_under_root, - require_config_yml_path, -) -from src.utils_yaml import dump_yaml, load_yaml - -logger = logging.getLogger(__name__) - - -def _load_config() -> dict: - """读取 config.yml(断言存在),校验每个条目含 display_name 与 script_path。""" - config_path = require_config_yml_path() - data = load_yaml(config_path) - for s in data.get("script_list", []): - assert "display_name" in s, f"[service] script_list 条目缺少 display_name: {s}" - assert "script_path" in s, f"[service] script_list 条目缺少 script_path: {s}" - return data - - -def _load_weekly() -> dict: - """读取 weekly_timeouts.yml(随包发布、必存在)。 - - 与 _load_weekly_defs 同款:assert 存在且为 dict,损坏直接暴露而非静默兜底。 - """ - weekly_path = get_weekly_timeouts_yml_path_under_root() - assert os.path.exists(weekly_path), f"[service] 周常超时配置缺失: {weekly_path}" - data = load_yaml(weekly_path) - assert isinstance(data, dict), ( - f"[service] 周常超时配置应为 dict(空文件或格式错误): {weekly_path}" - ) - return data - - -def _dump_weekly(weekly_map: dict) -> None: - """写回 weekly_timeouts.yml。""" - weekly_path = get_weekly_timeouts_yml_path_under_root() - dump_yaml(weekly_path, weekly_map) - - -def _load_weekly_start() -> dict: - """读取 weekly_start.yml(周常起始日持久化配置,进 git,必存在)。 - - 结构:{script_name: 1~7}。与 _load_weekly / _load_weekly_defs 同款: - assert 存在且为 dict,损坏直接暴露而非静默兜底。 - """ - weekly_start_path = get_weekly_start_yml_path_under_root() - assert os.path.exists(weekly_start_path), ( - f"[service] 周常起始日配置缺失: {weekly_start_path}" - ) - data = load_yaml(weekly_start_path) - assert isinstance(data, dict), ( - f"[service] 周常起始日配置应为 dict(空文件或格式错误): {weekly_start_path}" - ) - return data - - -def _dump_weekly_start(data: dict) -> None: - """写回 weekly_start.yml(覆盖式,与 _dump_weekly 同款)。""" - weekly_start_path = get_weekly_start_yml_path_under_root() - dump_yaml(weekly_start_path, data) - - -def _load_weekly_defs() -> dict: - """读取 weekly_list.yml(周常声明配置,进 git,必存在)。 - - 结构:{script_name: [{"name", "dungeons"?}, ...]}。dungeons 存在且有内容即 - 表示该周常需选副本(不再用 needs_instance 布尔字段)。周常起始日(周几起) - 另存于 weekly_start.yml,不在本文件。 - """ - weekly_list_path = get_weekly_list_yml_path_under_root() - assert os.path.exists(weekly_list_path), ( - f"[service] 周常声明配置缺失: {weekly_list_path}" - ) - data = load_yaml(weekly_list_path) - # 空文件或内容非 dict 都是声明配置损坏,直接暴露而非静默当成「无声明」。 - assert isinstance(data, dict), ( - f"[service] 周常声明配置应为 dict(空文件或格式错误): {weekly_list_path}" - ) - return data - - -def _resolve_weekly_timeouts(timeouts: list[int | None]) -> list[int]: - """把弹窗输入的超时列表规范化:None(空输入)转默认超时,低值(<10)原样保留。 - - 低值不再 clamp,由 chain_gen 在生成链时按「当天 <10 秒不运行」语义跳过脚本。 - - Args: - timeouts: 7 格输入值,空输入为 None。 - - Returns: - 规范化后的 7 格超时值列表。 - """ - return [DEFAULT_RUN_TIMEOUT if v is None else v for v in timeouts] - - -class ScriptService: - """单脚本配置服务:config.yml 只读查询 + weekly_timeouts 管理。 - - 脚本内部标识为**脚本唯一标识**(get_script_name):exe 脚本用进程名, - python/bat 等脚本文件用 display_name。所有方法入参均为此标识。 - """ - - def load_all_weekly(self) -> dict: - """返回 weekly_timeouts.yml 的完整字典(文件随包发布,必存在)。 - - key 为脚本唯一标识。 - """ - return _load_weekly() - - def get_weekly_defs(self, script_name: str) -> list: - """返回某脚本支持的周常声明清单(weekly_list.yml)。 - - 每项:{"name", "dungeons"?}。dungeons 存在且有内容即有可选副本。文件缺失或该 - 脚本无声明时返回空列表。 - - 声明项若带 ``dungeons_source`` 标记,则副本清单取自游戏脚本自身配置(运行期 - 读取,见 ``set_config.get_dungeon_lists``),不再手写维护;读不到时降级 - 为 ``dungeons: []``(该周常无需/无法选副本)。 - - Args: - script_name: 脚本唯一标识。 - - Returns: - 周常声明列表;无声明时为空列表。 - """ - defs_map = _load_weekly_defs() - if script_name not in defs_map: - return [] - defs = list(defs_map[script_name]) - for d in defs: - source = d.get("dungeons_source") - if source: - # 副本清单来自外部(如 M7A 的 instance_names.json),运行时读取, - # 不再手动维护;读不到则降级为无可选副本(has_dungeon=False)。 - names = get_dungeon_lists(script_name, d["name"], source) - d["dungeons"] = names if names is not None else [] - return defs - - def get_dungeon_map(self) -> dict: - """返回日常副本/序列配置映射(dungeon_list.yml)。 - - 声明项若带 ``dungeons_source`` 标记,其二级序列(副本名清单)取自游戏脚本 - 自身配置(运行期读取,见 ``get_dungeon_lists``),不再手写维护;读不到时 - 降级为 ``sequences: []``(该副本无需/无法选二级)。 - - Returns: - 脚本唯一标识 → 副本配置的映射(文件缺失时返回空 dict)。 - """ - data = load_dungeon_map() - for script_name, cfg in data.items(): - if not isinstance(cfg, dict): - continue - for d in cfg.get("dungeons", []): - if not isinstance(d, dict): - continue - source = d.get("dungeons_source") - if source: - # 二级序列来自外部(如 ok-ef 的 world_map.json),运行期读取, - # 不手动维护;读不到则降级为无可选序列(show_seq=False)。 - names = get_dungeon_lists(script_name, d["name"], source) - d["sequences"] = ( - [{"display": n, "value": n} for n in names] if names else [] - ) - return data - - def get_weekly_start(self, script_name: str) -> int | None: - """返回某脚本的周常起始日(1~7),未设置返回 None。 - - Args: - script_name: 脚本唯一标识。 - - Returns: - 周常起始日(1~7),未设置返回 None。 - """ - start_map = _load_weekly_start() - if script_name not in start_map: - return None - start_day = start_map[script_name] - if start_day is None: - return None - assert isinstance(start_day, int), ( - f"[service] {script_name} 非法 weekly_start: {start_day!r}(应为整数 1~7)" - ) - assert 1 <= start_day <= 7, ( - f"[service] {script_name} 非法 weekly_start: {start_day}(应为 1~7)" - ) - return start_day - - def get_weekly_start_map(self) -> dict: - """返回 weekly_start.yml 全量({脚本标识: 1~7})。""" - return _load_weekly_start() - - def set_weekly_start(self, script_name: str, start_day: int | None) -> None: - """持久化某脚本的周常起始日(周几起)到 weekly_start.yml。 - - start_day 为 1~7 时写入;为 None 时移除该脚本条目(对应弹窗「不设置」)。 - - Args: - script_name: 脚本唯一标识。 - start_day: 周常起始日(1~7);None 表示清除。 - """ - if start_day is not None: - assert 1 <= start_day <= 7, ( - f"[service] 非法 weekly_start: {start_day}(应为 1~7)" - ) - data = _load_weekly_start() - if start_day is None: - if script_name not in data: - return - data.pop(script_name, None) - else: - data[script_name] = start_day - _dump_weekly_start(data) - - def get_script(self, script_name: str) -> dict | None: - """按脚本唯一标识读取单个脚本条目。 - - Args: - script_name: 脚本唯一标识(exe 用进程名,脚本文件用 display_name)。 - - Returns: - 脚本条目 dict;不存在时返回 None。 - """ - config = _load_config() - for script in config.get("script_list", []): - if get_script_name(script) == script_name: - return script - return None - - def save_weekly(self, script_name: str, timeouts: list[int | None]) -> None: - """保存单个脚本的每周超时(空输入转默认超时;低值原样保留表示当天不运行)。 - - Args: - script_name: 脚本唯一标识。 - timeouts: 7 格超时输入值(必须恰好 7 格),空输入为 None。 - """ - assert len(timeouts) == 7, ( - f"[service] weekly 超时必须为 7 格,实际 {len(timeouts)}" - ) - weekly = _load_weekly() - weekly[script_name] = _resolve_weekly_timeouts(timeouts) - _dump_weekly(weekly) - - def rename_weekly_in_timeouts( - self, old_script_name: str, new_script_name: str - ) -> None: - """脚本标识变更时迁移 weekly_timeouts.yml 中的条目。 - - 旧条目存在则迁移到新名;不存在则无操作。 - - Args: - old_script_name: 原脚本唯一标识。 - new_script_name: 新脚本唯一标识。 - """ - if old_script_name == new_script_name: - return - weekly = _load_weekly() - old_val = weekly.pop(old_script_name, None) - if old_val is not None: - weekly[new_script_name] = old_val - _dump_weekly(weekly) - - def ensure_weekly_entry(self, script_name: str) -> None: - """为该脚本在 weekly_timeouts.yml 创建 7 格默认条目(已存在则跳过)。 - - Args: - script_name: 脚本唯一标识。 - """ - weekly = _load_weekly() - if script_name in weekly: - return - weekly[script_name] = [DEFAULT_RUN_TIMEOUT] * 7 - _dump_weekly(weekly) - - def weekly_inputs(self, script_name: str) -> list[int]: - """返回配置弹窗 7 个超时输入框的初始值。 - - Args: - script_name: 脚本唯一标识。 - - Returns: - 长度为 7 的超时值列表(无条目/不足 7 格时用默认超时补齐)。 - """ - weekly_map = _load_weekly() - entry = weekly_map.get(script_name) - timeouts = list(entry) if entry else [DEFAULT_RUN_TIMEOUT] * 7 - if len(timeouts) < 7: - timeouts.extend([DEFAULT_RUN_TIMEOUT] * (7 - len(timeouts))) - return timeouts[:7] - - def check_weekly(self) -> dict: - """校验 weekly_timeouts.yml 与 config 脚本条目的一致性。 - - Returns: - {"status": "ok"|"inconsistent", "missing_or_short": [...], "orphans": [...]}。 - weekly_timeouts 中不是 7 格条目的脚本标识进 missing_or_short; - config 已删除的孤儿 key 进 orphans(均为脚本唯一标识)。 - """ - config = _load_config() - config_keys = [get_script_name(s) for s in config.get("script_list", [])] - weekly = _load_weekly() - - missing = [name for name in config_keys if len(weekly.get(name) or []) != 7] - orphans = [name for name in weekly if name not in config_keys] - - return { - "status": "ok" if not missing and not orphans else "inconsistent", - "missing_or_short": missing, - "orphans": orphans, - } - - def build_script_entry( - self, file_path: str, existing_script_names: set[str] - ) -> dict: - """按文件路径构造脚本条目:去重命名 + 类型推断 + 默认字段补全。 - - 新脚本的 display_name 与唯一标识一致(exe 为进程名,脚本文件为 display_name), - 去重基于唯一标识。 - - Args: - file_path: 选中的脚本文件路径(已规范化)。 - existing_script_names: 已有脚本唯一标识集合,用于去重命名。 - - Returns: - 完整的 script_list 条目 dict(display_name 不与 existing 重复)。 - """ - base_name = os.path.splitext(os.path.basename(file_path))[0] - display_name = base_name - suffix = 1 - while display_name in existing_script_names: - display_name = f"{base_name}_{suffix}" - suffix += 1 - - script_type = "python" if file_path.lower().endswith(".py") else "external" - return default_script_entry( - display_name=display_name, - script_type=script_type, - script_path=file_path, - ) - - def delete_weekly(self, script_name: str) -> None: - """删除脚本时清理 weekly_timeouts.yml 中该脚本的孤儿条目。 - - config.yml 的总配置移除由 ChainService.remove_script 负责;此处仅清理 - 脚本级配置(weekly 超时条目),使删除行为完整、无残留。 - - Args: - script_name: 要清理 weekly 条目的脚本唯一标识。 - """ - weekly = _load_weekly() - if script_name in weekly: - weekly.pop(script_name) - _dump_weekly(weekly) - - def config_file_path(self, script_name: str) -> tuple[str | None, str | None]: - """返回该脚本「配置文件」的本地路径(用于外部打开)与失败原因。 - - python 脚本返回其 .py 源文件路径;external 脚本返回其内部 config 路径。 - 文件不存在或脚本未适配配置文件时返回 (None, error),error 可直接展示给用户。 - - Args: - script_name: 脚本唯一标识。 - - Returns: - (path, error):path 为可打开的配置文件路径(str);error 为非空字符串时 - 表示未适配或文件缺失(可直接展示),此时 path 为 None。 - """ - script = self.get_script(script_name) - if script is None: - return None, f"找不到脚本: {script_name}" - script_type = script.get("script_type", "external") - script_path = script.get("script_path", "") - if script_type == "python": - resolved = resolve_script_path(script_path) - if not resolved or not os.path.isfile(resolved): - return ( - None, - f"找不到脚本文件:{script_path or '(未设置路径)'}", - ) - return resolved, None - if is_exe_script(script_path): - try: - config_path = get_config_path(get_script_name(script)) - except AssertionError as e: - return None, f"该脚本暂未适配配置文件,无法打开:{e}" - if not os.path.isfile(config_path): - return None, f"配置文件不存在:{config_path}" - return config_path, None - # external 但非 exe(如 bat 等):无 config 适配,打开其自身 - resolved = resolve_script_path(script_path) - if not resolved or not os.path.isfile(resolved): - return None, f"找不到脚本文件:{script_path or '(未设置路径)'}" - return resolved, None diff --git a/src/utils.py b/src/utils.py index bb57d35..2ee96c0 100644 --- a/src/utils.py +++ b/src/utils.py @@ -42,7 +42,7 @@ def require_config_yml_path() -> str: 注意:本函数仅在 config.yml 应当已存在时调用。以下场景应使用 `get_config_yml_path_under_root()`(纯路径,不做存在性断言): - 探测/首次生成(launcher.config_workflow); - - 作为写入/生成目标(subscript.generate_config_from_example 首次生成 config.yml)。 + - 作为写入/生成目标(utils_sub_config.generate_config_from_example 首次生成 config.yml)。 """ path = get_config_yml_path_under_root() assert os.path.exists(path), f"[utils] 未找到 config.yml,无法读取配置: {path}" diff --git a/src/utils_config.py b/src/utils_config.py new file mode 100644 index 0000000..6047444 --- /dev/null +++ b/src/utils_config.py @@ -0,0 +1,253 @@ +"""单脚本配置读写(原 src/service/script_service.py,已退化为模块函数)。 + +承载「单脚本」视角的实现:config.yml 的读写(含脚本条目增删改)、单脚本条目查询 +与路径解析。周常运行期参数(weekly_start.yml / weekly_timeouts.yml)的读写由 +:mod:`src.utils_weekly` 负责,本模块协作调用(如新增脚本时建默认 weekly 条目)。 + +内部标识统一用**脚本唯一标识 script_name**(exe 脚本为进程名、脚本文件为 +display_name),display_name 仅用于展示。config.yml 的读写权统一归本模块; +ChainService 仅作运行时委托(其内部 ScheduledRun 经 ``load_config`` 取配置)。 +对应 GUI 的 ScriptItem 卡片与配置弹窗(SingleScriptConfigDialog)。 + +链编排(脚本列表/生成/运行)见 :mod:`src.service.chain_service`。 +""" + +import logging +import os + +from src.config.set_config import get_config_path, init_config +from src.utils import ( + get_config_yml_path_under_root, + require_config_yml_path, +) +from src.utils_sub_config import ( + check_script_name_uniqueness, + default_script_entry, + get_script_name, + is_exe_script, + resolve_script_path, +) +from src.utils_weekly import ( + delete_weekly, + ensure_weekly_entry, + rename_weekly_in_timeouts, + save_weekly, +) +from src.utils_yaml import dump_yaml, load_yaml + +logger = logging.getLogger(__name__) + + +def load_config() -> dict: + """读取 config.yml(断言存在),返回完整 script_list 配置。 + + 入口处一次性校验每个条目含 display_name/script_path 且脚本唯一标识唯一, + script_list 内部数据此后可安全用直接访问。 + """ + config_path = require_config_yml_path() + data = load_yaml(config_path) + assert isinstance(data, dict) and "script_list" in data, ( + "[utils_config] config.yml 缺少 script_list 字段" + ) + for s in data["script_list"]: + assert "display_name" in s, ( + f"[utils_config] script_list 条目缺少 display_name: {s}" + ) + assert "script_path" in s, ( + f"[utils_config] script_list 条目缺少 script_path: {s}" + ) + check_script_name_uniqueness(data) + return data + + +def save_config(data: dict) -> None: + """写回 config.yml(生成目标,不要求已存在)。 + + Args: + data: 完整 script_list 配置字典。 + """ + assert isinstance(data, dict) and "script_list" in data, ( + "[utils_config] 待保存的 config 缺少 script_list 字段" + ) + config_path = get_config_yml_path_under_root() + dump_yaml(config_path, data) + + +def add_script(script_data: dict) -> None: + """向 config.yml 的 script_list 追加一个脚本条目,并自动创建 weekly 默认条目。 + + 脚本唯一标识(get_script_name)不得与已有条目重复(数据完整性约束)。 + + Args: + script_data: 完整脚本条目 dict(含 display_name / script_path 等)。 + """ + assert "display_name" in script_data, "[utils_config] script_data 缺少 display_name" + assert "script_path" in script_data, "[utils_config] script_data 缺少 script_path" + config = load_config() + scripts = config.setdefault("script_list", []) + new_script_name = get_script_name(script_data) + assert all(get_script_name(s) != new_script_name for s in scripts), ( + f"[utils_config] 脚本标识已存在: {new_script_name}" + ) + scripts.append(script_data) + save_config(config) + ensure_weekly_entry(new_script_name) + init_config(new_script_name) + + +def remove_script(script_name: str) -> None: + """从 config.yml 的 script_list 移除指定脚本条目,并自动清理 weekly 孤儿。 + + Args: + script_name: 要移除的脚本唯一标识。 + """ + config = load_config() + scripts = config.setdefault("script_list", []) + target = next( + (s for s in scripts if get_script_name(s) == script_name), + None, + ) + assert target is not None, f"[utils_config] 找不到脚本: {script_name}" + scripts.remove(target) + save_config(config) + delete_weekly(script_name) + + +def update_script( + old_script_name: str, + new_display_name: str, + config_patch: dict, + weekly_timeouts: list[int | None], +) -> str: + """更新单个脚本条目字段并同步 weekly_timeouts。 + + 以脚本唯一标识定位条目;自动处理标识变更(含 weekly 迁移)与 + kill_game_after_done 自洽(未设置 game_process_name 时强制 False)。 + + Args: + old_script_name: 原脚本唯一标识(用于定位条目)。 + new_display_name: 新 display_name(展示名,可保留原名)。 + config_patch: 要写入条目顶层字段的映射(如 script_path/check_done)。 + weekly_timeouts: 7 格超时输入值,空输入为 None(落盘前转默认超时)。 + + Returns: + 落盘后的脚本唯一标识(标识可能因 script_path/display_name 变更而改变), + 供调用方在落盘后触发依赖新路径的后续动作(如游戏侧周几起同步)。 + """ + assert new_display_name, "[utils_config] 脚本名称不能为空" + config = load_config() + target = None + for script in config.setdefault("script_list", []): + if get_script_name(script) == old_script_name: + target = script + break + assert target is not None, f"[utils_config] 找不到脚本: {old_script_name}" + + for key, value in config_patch.items(): + target[key] = value + target["display_name"] = new_display_name + + new_script_name = get_script_name(target) + if new_script_name != old_script_name: + assert all( + get_script_name(s) != new_script_name + for s in config["script_list"] + if s is not target + ), f"[utils_config] 脚本标识已存在: {new_script_name}" + + # 配置自洽:未设置游戏进程名时「运行后关闭游戏」强制 False + if not target.get("game_process_name", ""): + target["kill_game_after_done"] = False + + save_config(config) + + if new_script_name != old_script_name: + rename_weekly_in_timeouts(old_script_name, new_script_name) + save_weekly(new_script_name, weekly_timeouts) + init_config(new_script_name) + return new_script_name + + +def get_script(script_name: str) -> dict | None: + """按脚本唯一标识读取单个脚本条目。 + + Args: + script_name: 脚本唯一标识(exe 用进程名,脚本文件用 display_name)。 + + Returns: + 脚本条目 dict;不存在时返回 None。 + """ + config = load_config() + for script in config.get("script_list", []): + if get_script_name(script) == script_name: + return script + return None + + +def build_script_entry(file_path: str, existing_script_names: set[str]) -> dict: + """按文件路径构造脚本条目:去重命名 + 类型推断 + 默认字段补全。 + + 新脚本的 display_name 与唯一标识一致(exe 为进程名,脚本文件为 display_name), + 去重基于唯一标识。 + + Args: + file_path: 选中的脚本文件路径(已规范化)。 + existing_script_names: 已有脚本唯一标识集合,用于去重命名。 + + Returns: + 完整的 script_list 条目 dict(display_name 不与 existing 重复)。 + """ + base_name = os.path.splitext(os.path.basename(file_path))[0] + display_name = base_name + suffix = 1 + while display_name in existing_script_names: + display_name = f"{base_name}_{suffix}" + suffix += 1 + + script_type = "python" if file_path.lower().endswith(".py") else "external" + return default_script_entry( + display_name=display_name, + script_type=script_type, + script_path=file_path, + ) + + +def config_file_path(script_name: str) -> tuple[str | None, str | None]: + """返回该脚本「配置文件」的本地路径(用于外部打开)与失败原因。 + + python 脚本返回其 .py 源文件路径;external 脚本返回其内部 config 路径。 + 文件不存在或脚本未适配配置文件时返回 (None, error),error 可直接展示给用户。 + + Args: + script_name: 脚本唯一标识。 + + Returns: + (path, error):path 为可打开的配置文件路径(str);error 为非空字符串时 + 表示未适配或文件缺失(可直接展示),此时 path 为 None。 + """ + script = get_script(script_name) + if script is None: + return None, f"找不到脚本: {script_name}" + script_type = script.get("script_type", "external") + script_path = script.get("script_path", "") + if script_type == "python": + resolved = resolve_script_path(script_path) + if not resolved or not os.path.isfile(resolved): + return ( + None, + f"找不到脚本文件:{script_path or '(未设置路径)'}", + ) + return resolved, None + if is_exe_script(script_path): + try: + config_path = get_config_path(get_script_name(script)) + except AssertionError as e: + return None, f"该脚本暂未适配配置文件,无法打开:{e}" + if not os.path.isfile(config_path): + return None, f"配置文件不存在:{config_path}" + return config_path, None + # external 但非 exe(如 bat 等):无 config 适配,打开其自身 + resolved = resolve_script_path(script_path) + if not resolved or not os.path.isfile(resolved): + return None, f"找不到脚本文件:{script_path or '(未设置路径)'}" + return resolved, None diff --git a/src/utils_runner.py b/src/utils_runner.py index 6e3384a..d0a7343 100644 --- a/src/utils_runner.py +++ b/src/utils_runner.py @@ -21,8 +21,8 @@ import psutil -from src.config.subscript import resolve_script_path from src.utils import get_root_dir +from src.utils_sub_config import resolve_script_path logger = logging.getLogger(__name__) diff --git a/src/utils_shutdown.py b/src/utils_shutdown.py index 1dd6f49..cbce911 100644 --- a/src/utils_shutdown.py +++ b/src/utils_shutdown.py @@ -1,13 +1,12 @@ """自动关机:运行全部结束后由 service 作为 post_run 最后一项触发。 -迁自 runner 的 ``script_chainer.utils.cmd_utils.shutdown_sys``:关机必须由主仓库 -编排(在所有运行含重跑结束之后),不能再交给 runner 子进程的 ``--shutdown``,否则 -首次运行结束即拉起关机倒计时,会抢在重跑前关掉机器。 +关机必须由主仓库编排(在所有运行含重跑结束之后),不能再交给 runner 子进程的 +``--shutdown``,否则首次运行结束即拉起关机倒计时,会抢在重跑前关掉机器。 -确认窗改进程内 PySide6 弹窗(取代原 ``win_exe/shutdown_confirm.py`` 的 tkinter -子进程):调用方只有 CLI 进程与 ``spawn_schedule_run`` 起的独立控制台进程,二者都 -运行在主线程且本无 QApplication,进程内弹窗安全;同时去掉子进程退出码约定与 -tkinter 依赖。样式复用 ``src.gui.dialogs`` 的主题常量与弹窗基类(单一来源)。 +确认窗是 PySide6 实现,位于 ``src.gui.shutdown_dialog``;本模块只保留「确认后执行 +shutdown 命令」的纯逻辑,弹窗经**延迟 import** 引入,避免底层反向依赖上层(否则 +``schedule → utils_shutdown → gui.dialogs → app_service → chain_service → +schedule`` 成环)。 仅 Windows 下真正关机;非 Windows(CI/Linux/macOS)仅记日志跳过关机。 """ @@ -16,28 +15,12 @@ import subprocess import sys -from PySide6.QtCore import Qt, QTimer -from PySide6.QtWidgets import ( - QApplication, - QDialog, - QLabel, - QVBoxLayout, -) - -from src.gui.dialogs import ( - BG_CARD, - FONT_SIZE_BODY, - TEXT, - FormDialogBase, - make_font, -) +logger = logging.getLogger(__name__) # CREATE_NO_WINDOW 仅在 Windows 平台存在;非 Windows 用 0 表示无特殊创建标志, # 保证同一份代码在 Linux/macOS CI 上也能正常执行(不创建隐藏窗口)。 _CREATE_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0) -logger = logging.getLogger(__name__) - def shutdown_sys(seconds: int) -> None: """关机:先弹倒计时确认窗,确认才关机;关窗/取消则不关。 @@ -56,7 +39,10 @@ def shutdown_sys(seconds: int) -> None: def _confirm_shutdown(countdown: int) -> bool: - """进程内弹倒计时确认窗并等待用户选择。 + """弹关机确认窗并等待用户选择(GUI 实现见 :mod:`src.gui.shutdown_dialog`)。 + + 延迟 import:GUI 层会经 ``gui.dialogs`` 反向依赖 service 层,模块级 import 成环; + 且只有真要弹窗时才需要 GUI(非 Windows 平台根本走不到)。 Args: countdown: 倒计时秒数。 @@ -64,13 +50,9 @@ def _confirm_shutdown(countdown: int) -> bool: Returns: 确认返回 True;取消/关窗/弹窗失败返回 False。 """ - try: - if QApplication.instance() is None: - QApplication(sys.argv) - return ShutdownConfirmDialog(countdown).exec() == QDialog.DialogCode.Accepted - except Exception as e: # 无桌面等环境下 Qt 初始化会失败,属可预见 - logger.error("关机确认窗初始化失败 %s(%s),按取消处理", type(e).__name__, e) - return False + from src.gui.shutdown_dialog import confirm_shutdown + + return confirm_shutdown(countdown) def _run_shutdown_command(args: list[str]) -> None: @@ -93,64 +75,3 @@ def _run_shutdown_command(args: list[str]) -> None: proc.returncode, (proc.stderr or "").strip(), ) - - -class ShutdownConfirmDialog(FormDialogBase): - """关机倒计时确认窗:倒计时归零或点「立即关机」→ accept;取消/关窗 → reject。 - - 复用 ``FormDialogBase`` 的样式与底部按钮行(取消 / 立即关机);倒计时由 - ``QTimer`` 每秒递减,归零即 accept。是否真关机由调用方据 ``exec()`` 结果决定。 - - Args: - countdown: 倒计时秒数,须大于 0。 - parent: 父窗口;关机窗由独立控制台进程弹出,通常无父窗口。 - """ - - def __init__(self, countdown: int, parent=None): - super().__init__(parent) - # 调用方(build_post_run_pipeline)仅在 delay>0 时挂关机步骤,0/负数是编程错误。 - assert countdown > 0 - self._remain = countdown - - self.setWindowTitle("即将关机") - self.setWindowFlag(Qt.WindowStaysOnTopHint) - self.setStyleSheet(f"background-color: {BG_CARD};") - self.setMinimumWidth(360) - - layout = QVBoxLayout(self) - layout.setContentsMargins(18, 18, 18, 18) - layout.setSpacing(14) - - self._label = QLabel(self) - self._label.setFont(make_font(size=FONT_SIZE_BODY)) - self._label.setStyleSheet(f"color: {TEXT}; background: transparent;") - layout.addWidget(self._label) - - layout.addLayout(self._make_footer("立即关机", self.accept)) - - self._timer = QTimer(self) - self._timer.setInterval(1000) - self._timer.timeout.connect(self._tick) - self._show_remain() - - def _show_remain(self) -> None: - """按剩余秒数刷新文案。""" - self._label.setText(f"系统将在 {self._remain} 秒后关机") - - def _tick(self) -> None: - """每秒递减一秒;归零停表并接受(关机)。""" - self._remain -= 1 - self._show_remain() - if self._remain <= 0: - self._timer.stop() - self.accept() - - def showEvent(self, event) -> None: - """显示即起倒计时(``exec()`` 为模态,显示后才计时才不会提前流逝)。""" - super().showEvent(event) - self._timer.start() - - def hideEvent(self, event) -> None: - """关闭即停表(accept/reject 都会走到),避免挂窗后定时器空转。""" - self._timer.stop() - super().hideEvent(event) diff --git a/src/config/subscript.py b/src/utils_sub_config.py similarity index 95% rename from src/config/subscript.py rename to src/utils_sub_config.py index 0b08a61..9a5ac9b 100644 --- a/src/config/subscript.py +++ b/src/utils_sub_config.py @@ -1,5 +1,6 @@ """ -脚本配置读写模块 +脚本配置读写模块(原 src/config/subscript.py,已迁移至 src/utils_sub_config.py)。 + 提供脚本根目录解析、config 路径推导、配置文件读写等功能。 """ @@ -96,7 +97,7 @@ def check_script_name_uniqueness(config_data: dict) -> None: display_name = script.get("display_name", script_name) if script_name in seen: raise AssertionError( - f"[subscript] 脚本标识重复: {script_name}({seen[script_name]} 与 {display_name})" + f"[sub_config] 脚本标识重复: {script_name}({seen[script_name]} 与 {display_name})" ) seen[script_name] = display_name @@ -142,7 +143,7 @@ def get_script_root_dir_soft(script_name: str) -> str | None: return None -def get_config_path(script_name: str, rel_path: str) -> str: +def get_sub_config_path(script_name: str, rel_path: str) -> str: """ 获取指定脚本的 config 文件绝对路径。 @@ -193,7 +194,7 @@ def load_config(script_name: str, rel_path: str) -> dict | list: 支持 .json 和 .yaml/.yml 格式。 assert 文件存在。 """ - path = get_config_path(script_name, rel_path) + path = get_sub_config_path(script_name, rel_path) assert os.path.exists(path), f"[set_config] config 文件不存在: {path}" ext = os.path.splitext(path)[1].lower() with open(path, encoding="utf-8") as f: @@ -235,7 +236,7 @@ def save_config(script_name: str, rel_path: str, data: dict | list) -> None: 保持原始格式(json / yaml)。 并确保 config 文件已存在且能被写入。 """ - path = get_config_path(script_name, rel_path) + path = get_sub_config_path(script_name, rel_path) ext = os.path.splitext(path)[1].lower() if ext == ".json": with open(path, "w", encoding="utf-8") as f: @@ -303,7 +304,7 @@ def generate_config_from_example() -> None: """ example_path = safe_path_join(get_root_dir(), "config", "config.example.yml") config_path = get_config_yml_path_under_root() - assert os.path.exists(example_path), f"[subscript] 模板不存在: {example_path}" + assert os.path.exists(example_path), f"[sub_config] 模板不存在: {example_path}" data = load_yaml(example_path) dump_yaml(config_path, data) @@ -317,6 +318,6 @@ def generate_schedule_from_example() -> None: """ example_path = safe_path_join(get_root_dir(), "config", "schedule.example.yml") schedule_path = get_schedule_yml_path_under_root() - assert os.path.exists(example_path), f"[subscript] 模板不存在: {example_path}" + assert os.path.exists(example_path), f"[sub_config] 模板不存在: {example_path}" data = load_yaml(example_path) dump_yaml(schedule_path, data) diff --git a/src/utils_weekly.py b/src/utils_weekly.py index ee76689..8e0e65d 100644 --- a/src/utils_weekly.py +++ b/src/utils_weekly.py @@ -1,7 +1,31 @@ -"""周常(周几以后开始执行)相关工具:周几计算与起始日判断。""" +"""周常相关工具:日期数学 + 运行期参数读写(无 Qt 依赖)。 +两类职责: +- 日期数学:``next_target_datetime`` / ``get_week_num`` / ``is_weekly_start_reached``(周几判定); +- 运行期参数读写:``weekly_start.yml``(周几起)+ ``weekly_timeouts.yml``(每周超时)。 + +不含周本声明——各游戏「有哪些周常、可选哪些副本」由 src.config.dungeon_config 模块函数读 +weekly_list.yml 提供;本模块只管「周几起 / 每天超时多久」这类运行期参数。 + +脚本标识统一用脚本唯一标识 script_name(exe 为进程名、脚本文件为 display_name)。 +""" + +import logging +import os from datetime import datetime, timedelta +from src.utils import ( + get_weekly_start_yml_path_under_root, + get_weekly_timeouts_yml_path_under_root, +) +from src.utils_sub_config import DEFAULT_RUN_TIMEOUT, get_script_name +from src.utils_yaml import dump_yaml, load_yaml + +logger = logging.getLogger(__name__) + + +# ---- 日期数学 ---- + def next_target_datetime(target_time: str, now: datetime | None = None) -> datetime: """返回下一个等于 target_time 的时刻:今天未到取今天,已过取明天(跨午夜)。 @@ -41,3 +65,224 @@ def is_weekly_start_reached(start_day: int) -> bool: """ assert 1 <= start_day <= 7, f"[utils_weekly] 非法周常起始日: {start_day}" return get_week_num() + 1 >= start_day + + +# ---- weekly_start.yml / weekly_timeouts.yml 读写 ---- + + +def _load_weekly() -> dict: + """读取 weekly_timeouts.yml(随包发布、必存在)。 + + 与 _load_weekly_start 同款:assert 存在且为 dict,损坏直接暴露而非静默兜底。 + """ + weekly_path = get_weekly_timeouts_yml_path_under_root() + assert os.path.exists(weekly_path), ( + f"[utils_weekly] 周常超时配置缺失: {weekly_path}" + ) + data = load_yaml(weekly_path) + assert isinstance(data, dict), ( + f"[utils_weekly] 周常超时配置应为 dict(空文件或格式错误): {weekly_path}" + ) + return data + + +def _dump_weekly(weekly_map: dict) -> None: + """写回 weekly_timeouts.yml。""" + weekly_path = get_weekly_timeouts_yml_path_under_root() + dump_yaml(weekly_path, weekly_map) + + +def _load_weekly_start() -> dict: + """读取 weekly_start.yml(周常起始日持久化配置,进 git,必存在)。 + + 结构:{script_name: 1~7}。与 _load_weekly 同款:assert 存在且为 dict, + 损坏直接暴露而非静默兜底。 + """ + weekly_start_path = get_weekly_start_yml_path_under_root() + assert os.path.exists(weekly_start_path), ( + f"[utils_weekly] 周常起始日配置缺失: {weekly_start_path}" + ) + data = load_yaml(weekly_start_path) + assert isinstance(data, dict), ( + f"[utils_weekly] 周常起始日配置应为 dict(空文件或格式错误): {weekly_start_path}" + ) + return data + + +def _dump_weekly_start(data: dict) -> None: + """写回 weekly_start.yml(覆盖式,与 _dump_weekly 同款)。""" + weekly_start_path = get_weekly_start_yml_path_under_root() + dump_yaml(weekly_start_path, data) + + +def _resolve_weekly_timeouts(timeouts: list[int | None]) -> list[int]: + """把弹窗输入的超时列表规范化:None(空输入)转默认超时,低值(<10)原样保留。 + + 低值不再 clamp,由 chain_gen 在生成链时按「当天 <10 秒不运行」语义跳过脚本。 + + Args: + timeouts: 7 格输入值,空输入为 None。 + + Returns: + 规范化后的 7 格超时值列表。 + """ + return [DEFAULT_RUN_TIMEOUT if v is None else v for v in timeouts] + + +def load_all_weekly() -> dict: + """返回 weekly_timeouts.yml 的完整字典(文件随包发布,必存在)。 + + key 为脚本唯一标识。 + """ + return _load_weekly() + + +def get_weekly_start(script_name: str) -> int | None: + """返回某脚本的周常起始日(1~7),未设置返回 None。 + + Args: + script_name: 脚本唯一标识。 + + Returns: + 周常起始日(1~7),未设置返回 None。 + """ + start_map = _load_weekly_start() + if script_name not in start_map: + return None + start_day = start_map[script_name] + if start_day is None: + return None + assert isinstance(start_day, int), ( + f"[utils_weekly] {script_name} 非法 weekly_start: {start_day!r}(应为整数 1~7)" + ) + assert 1 <= start_day <= 7, ( + f"[utils_weekly] {script_name} 非法 weekly_start: {start_day}(应为 1~7)" + ) + return start_day + + +def get_weekly_start_map() -> dict: + """返回 weekly_start.yml 全量({脚本标识: 1~7})。""" + return _load_weekly_start() + + +def set_weekly_start(script_name: str, start_day: int | None) -> None: + """持久化某脚本的周常起始日(周几起)到 weekly_start.yml。 + + start_day 为 1~7 时写入;为 None 时移除该脚本条目(对应弹窗「不设置」)。 + + Args: + script_name: 脚本唯一标识。 + start_day: 周常起始日(1~7);None 表示清除。 + """ + if start_day is not None: + assert 1 <= start_day <= 7, ( + f"[utils_weekly] 非法 weekly_start: {start_day}(应为 1~7)" + ) + data = _load_weekly_start() + if start_day is None: + if script_name not in data: + return + data.pop(script_name, None) + else: + data[script_name] = start_day + _dump_weekly_start(data) + + +def save_weekly(script_name: str, timeouts: list[int | None]) -> None: + """保存单个脚本的每周超时(空输入转默认超时;低值原样保留表示当天不运行)。 + + Args: + script_name: 脚本唯一标识。 + timeouts: 7 格超时输入值(必须恰好 7 格),空输入为 None。 + """ + assert len(timeouts) == 7, ( + f"[utils_weekly] weekly 超时必须为 7 格,实际 {len(timeouts)}" + ) + weekly = _load_weekly() + weekly[script_name] = _resolve_weekly_timeouts(timeouts) + _dump_weekly(weekly) + + +def rename_weekly_in_timeouts(old_script_name: str, new_script_name: str) -> None: + """脚本标识变更时迁移 weekly_timeouts.yml 中的条目。 + + 旧条目存在则迁移到新名;不存在则无操作。 + + Args: + old_script_name: 原脚本唯一标识。 + new_script_name: 新脚本唯一标识。 + """ + if old_script_name == new_script_name: + return + weekly = _load_weekly() + old_val = weekly.pop(old_script_name, None) + if old_val is not None: + weekly[new_script_name] = old_val + _dump_weekly(weekly) + + +def ensure_weekly_entry(script_name: str) -> None: + """为该脚本在 weekly_timeouts.yml 创建 7 格默认条目(已存在则跳过)。 + + Args: + script_name: 脚本唯一标识。 + """ + weekly = _load_weekly() + if script_name in weekly: + return + weekly[script_name] = [DEFAULT_RUN_TIMEOUT] * 7 + _dump_weekly(weekly) + + +def weekly_inputs(script_name: str) -> list[int]: + """返回配置弹窗 7 个超时输入框的初始值。 + + Args: + script_name: 脚本唯一标识。 + + Returns: + 长度为 7 的超时值列表(无条目/不足 7 格时用默认超时补齐)。 + """ + weekly_map = _load_weekly() + entry = weekly_map.get(script_name) + timeouts = list(entry) if entry else [DEFAULT_RUN_TIMEOUT] * 7 + if len(timeouts) < 7: + timeouts.extend([DEFAULT_RUN_TIMEOUT] * (7 - len(timeouts))) + return timeouts[:7] + + +def check_weekly(config: dict) -> dict: + """校验 weekly_timeouts.yml 与 config.yml 脚本条目的一致性。 + + Args: + config: config.yml 完整数据(含 script_list)。 + + Returns: + {"status": "ok"|"inconsistent", "missing_or_short": [...], "orphans": [...]}。 + weekly_timeouts 中不是 7 格条目的脚本标识进 missing_or_short; + config 已删除的孤儿 key 进 orphans(均为脚本唯一标识)。 + """ + config_keys = [get_script_name(s) for s in config.get("script_list", [])] + weekly = _load_weekly() + + missing = [name for name in config_keys if len(weekly.get(name) or []) != 7] + orphans = [name for name in weekly if name not in config_keys] + + return { + "status": "ok" if not missing and not orphans else "inconsistent", + "missing_or_short": missing, + "orphans": orphans, + } + + +def delete_weekly(script_name: str) -> None: + """删除脚本时清理 weekly_timeouts.yml 中该脚本的孤儿条目。 + + Args: + script_name: 要清理 weekly 条目的脚本唯一标识。 + """ + weekly = _load_weekly() + if script_name in weekly: + weekly.pop(script_name) + _dump_weekly(weekly) diff --git a/tests/test_chain_service.py b/tests/test_chain_service.py index bdb0896..050e497 100644 --- a/tests/test_chain_service.py +++ b/tests/test_chain_service.py @@ -1,61 +1,11 @@ """测试 src/service/chain_service.py:无头测试,全部 mock 被包装函数。""" -import os -import tempfile import unittest from datetime import datetime from unittest.mock import MagicMock, patch from src.service.chain_service import ChainService -from src.service.scheduled_run import ScheduledRun, build_post_run_pipeline -from src.utils_yaml import dump_yaml_file, load_yaml - - -class TestLoadSaveConfig(unittest.TestCase): - """config.yml 读写:转发 + 结构断言(用临时文件,不碰真实 config)""" - - def setUp(self): - self.tmp_dir = tempfile.TemporaryDirectory() - self.addCleanup(self.tmp_dir.cleanup) - self.config_path = os.path.join(self.tmp_dir.name, "config.yml") - - def test_load_config_reads_yaml(self): - fake_data = { - "script_list": [ - {"display_name": "测试", "script_path": "C:/x.exe"}, - ] - } - dump_yaml_file(self.config_path, fake_data) - with patch( - "src.service.chain_service.require_config_yml_path", - return_value=self.config_path, - ): - data = ChainService().load_config() - self.assertEqual(data, fake_data) - - def test_load_config_asserts_script_list(self): - dump_yaml_file(self.config_path, {"a": 1}) - with ( - patch( - "src.service.chain_service.require_config_yml_path", - return_value=self.config_path, - ), - self.assertRaises(AssertionError), - ): - ChainService().load_config() - - def test_save_config_writes_yaml(self): - with patch( - "src.service.chain_service.get_config_yml_path_under_root", - return_value=self.config_path, - ): - ChainService().save_config({"script_list": [{"display_name": "测试"}]}) - saved = load_yaml(self.config_path) - self.assertEqual(saved["script_list"][0]["display_name"], "测试") - - def test_save_config_asserts_script_list(self): - with self.assertRaises(AssertionError): - ChainService().save_config({"a": 1}) +from src.service.schedule import ScheduledRun, build_post_run_pipeline class TestChainGeneration(unittest.TestCase): @@ -64,11 +14,14 @@ class TestChainGeneration(unittest.TestCase): def test_generate_chain_delegates(self): data = {"script_list": []} mock_script = MagicMock() - mock_script.load_all_weekly.return_value = {} - mock_script.get_weekly_start_map.return_value = {} - with patch( - "src.service.chain_service._generate_chain_config", return_value="out.yml" - ) as m: + with ( + patch( + "src.service.chain_service._generate_chain_config", + return_value="out.yml", + ) as m, + patch("src.service.chain_service.load_all_weekly", return_value={}), + patch("src.service.chain_service.get_weekly_start_map", return_value={}), + ): out = ChainService(script_service=mock_script).generate_chain( data, {"A"}, "88", out_path="out.yml" ) @@ -121,10 +74,14 @@ class TestRunChainOnce(unittest.TestCase): def _make_service(self, script_list): svc = ChainService() svc.load_config = MagicMock(return_value={"script_list": script_list}) - svc.load_ui_state = MagicMock(return_value={}) svc._script_service = MagicMock() - svc._script_service.load_all_weekly.return_value = {} - svc._script_service.get_weekly_start_map.return_value = {} + self._weekly_load = patch( + "src.service.chain_service.load_all_weekly", return_value={} + ).start() + self._weekly_start = patch( + "src.service.chain_service.get_weekly_start_map", return_value={} + ).start() + self.addCleanup(patch.stopall) return svc def test_defaults_all_scripts_and_runs(self): @@ -155,8 +112,8 @@ def test_run_chain_once_does_not_forward_weekly_start_map(self): """回归:weekly_start→子脚本 config 的写盘已移到 ScheduledRun.pre_run, run_chain_once 不再把 weekly_start_map 透传给链生成。""" svc = self._make_service([{"display_name": "A", "script_path": "A.exe"}]) - svc._script_service.get_weekly_start_map.return_value = {"A": 3} - svc._script_service.load_all_weekly.return_value = {"A": 100} + self._weekly_start.return_value = {"A": 3} + self._weekly_load.return_value = {"A": 100} with ( patch( "src.service.chain_service._generate_chain_config", @@ -223,7 +180,7 @@ def test_run_steps_isolates_step_failures(self): def boom() -> None: raise RuntimeError("step failed") - with patch("src.service.scheduled_run.logger") as mock_logger: + with patch("src.service.schedule.logger") as mock_logger: ScheduledRun._run_steps( [lambda: order.append("a"), boom, lambda: order.append("b")] ) @@ -234,14 +191,20 @@ def boom() -> None: class TestScheduleRun(unittest.TestCase): """schedule_run:server 侧真实实现(等待→生成→运行→关机 post_run)。""" + def setUp(self): + # schedule.yml 现由 src.service.schedule.load_schedule 读取(模块函数,非 + # service 方法),故 patch 模块函数;用例改 self.schedule_data 即可切换配置。 + self.schedule_data = {"rerun": {"enabled": True}, "notify": {"enabled": False}} + patcher = patch( + "src.service.schedule.load_schedule", + side_effect=lambda: self.schedule_data, + ) + patcher.start() + self.addCleanup(patcher.stop) + def _make_service(self, script_list): svc = ChainService() svc.load_config = MagicMock(return_value={"script_list": script_list}) - # rerun 已迁入 schedule.yml,经 load_schedule 读取。 - svc.load_schedule = MagicMock( - return_value={"rerun": {"enabled": True}, "notify": {"enabled": False}} - ) - svc.load_ui_state = MagicMock(return_value={}) svc.run_chain_once = MagicMock(return_value=None) return svc @@ -257,8 +220,8 @@ def _run(self, svc, target_time="08:00", **kwargs): return_value={"rerun": [], "notify": [], "report": "", "entries": []}, ), patch("src.service.chain_service._run_chain_once_impl"), - patch("src.service.scheduled_run.build_post_run_pipeline", return_value=[]), - patch("src.service.scheduled_run.shutdown_sys") as mock_shutdown, + patch("src.service.schedule.build_post_run_pipeline", return_value=[]), + patch("src.service.schedule.shutdown_sys") as mock_shutdown, ): svc.schedule_run({"demo"}, target_time, **kwargs) return mock_sleep, mock_shutdown @@ -282,7 +245,7 @@ def test_shutdown_triggers_post_run(self): ), patch("src.service.chain_service.parse_logs", return_value={"rerun": []}), patch("src.service.chain_service._run_chain_once_impl"), - patch("src.service.scheduled_run.build_post_run_pipeline") as mock_pipeline, + patch("src.service.schedule.build_post_run_pipeline") as mock_pipeline, ): svc.schedule_run({"demo"}, "08:00", shutdown_delay=60) mock_pipeline.assert_called_once_with( @@ -300,8 +263,8 @@ def test_mute_passed_to_pipelines(self): ), patch("src.service.chain_service.parse_logs", return_value={"rerun": []}), patch("src.service.chain_service._run_chain_once_impl"), - patch("src.service.scheduled_run.build_pre_run_pipeline") as mock_pre, - patch("src.service.scheduled_run.build_post_run_pipeline") as mock_post, + patch("src.service.schedule.build_pre_run_pipeline") as mock_pre, + patch("src.service.schedule.build_post_run_pipeline") as mock_post, ): svc.schedule_run({"demo"}, "08:00", mute=True) # mute 经 pre_run 工厂透传(由其挂静音 step),不再经 run_chain_once @@ -330,7 +293,7 @@ def test_now_skips_wait(self): return_value={"rerun": [], "notify": [], "report": "", "entries": []}, ), patch("src.service.chain_service._run_chain_once_impl"), - patch("src.service.scheduled_run.shutdown_sys"), + patch("src.service.schedule.shutdown_sys"), ): svc.schedule_run({"demo"}, "now") mock_sleep.assert_not_called() # 即时:不等待 @@ -365,7 +328,7 @@ def test_rerun_round_before_post_run(self): side_effect=lambda *a, **k: order.append("rerun"), ), patch( - "src.service.scheduled_run.build_post_run_pipeline", + "src.service.schedule.build_post_run_pipeline", return_value=[lambda: order.append("mail")], ), ): @@ -375,9 +338,10 @@ def test_rerun_round_before_post_run(self): def test_rerun_skipped_when_disabled(self): """schedule.rerun.enabled=false:链跑完后不进入重跑轮。""" svc = self._make_service([{"display_name": "demo"}]) - svc.load_schedule = MagicMock( - return_value={"rerun": {"enabled": False}, "notify": {"enabled": False}} - ) + self.schedule_data = { + "rerun": {"enabled": False}, + "notify": {"enabled": False}, + } with ( patch("src.service.run_actions.time.sleep"), patch( @@ -385,7 +349,7 @@ def test_rerun_skipped_when_disabled(self): return_value=datetime(2030, 1, 1, 8, 0), ), patch("src.service.chain_service._run_chain_once_impl") as rerun, - patch("src.service.scheduled_run.build_post_run_pipeline", return_value=[]), + patch("src.service.schedule.build_post_run_pipeline", return_value=[]), ): svc.schedule_run({"demo"}, "08:00") rerun.assert_not_called() @@ -393,12 +357,10 @@ def test_rerun_skipped_when_disabled(self): def test_mail_skipped_when_disabled(self): """notify.enabled=false(即便配了 email/password):smtp_config 为 None(不发信)。""" svc = self._make_service([{"display_name": "demo"}]) - svc.load_schedule = MagicMock( - return_value={ - "rerun": {"enabled": True}, - "notify": {"enabled": False, "email": "a@qq.com", "password": "pw"}, - } - ) + self.schedule_data = { + "rerun": {"enabled": True}, + "notify": {"enabled": False, "email": "a@qq.com", "password": "pw"}, + } captured = {} def _fake_pipeline( @@ -418,7 +380,7 @@ def _fake_pipeline( return_value={"rerun": [], "notify": [], "report": "", "entries": []}, ), patch( - "src.service.scheduled_run.build_post_run_pipeline", + "src.service.schedule.build_post_run_pipeline", side_effect=_fake_pipeline, ) as pipeline, ): @@ -446,7 +408,7 @@ def _run(self, *, rerun=("demo",), notify=("demo",), **kwargs): return_value=self._result(rerun=rerun, notify=notify), ) as parse, patch("src.service.run_actions.send_mail") as mail, - patch("src.service.scheduled_run.shutdown_sys") as shutdown, + patch("src.service.schedule.shutdown_sys") as shutdown, ): steps = build_post_run_pipeline(**kwargs) for step in steps: @@ -508,8 +470,13 @@ def _svc_with_config(self, script_list): svc = ChainService() svc.load_config = MagicMock(return_value={"script_list": script_list}) svc._script_service = MagicMock() - svc._script_service.load_all_weekly.return_value = {} - svc._script_service.get_weekly_start_map.return_value = {} + self._weekly_load = patch( + "src.service.chain_service.load_all_weekly", return_value={} + ).start() + self._weekly_start = patch( + "src.service.chain_service.get_weekly_start_map", return_value={} + ).start() + self.addCleanup(patch.stopall) return svc def test_reruns_when_rerun_list_nonempty(self): @@ -585,72 +552,5 @@ def test_passes_enabled_keys_to_parse_logs(self): ) -class TestAddRemoveScript(unittest.TestCase): - """add_script / remove_script / update_script:操作 config.yml 并同步 weekly。""" - - def setUp(self): - self.tmp_dir = tempfile.TemporaryDirectory() - self.addCleanup(self.tmp_dir.cleanup) - self.config_path = os.path.join(self.tmp_dir.name, "config.yml") - dump_yaml_file( - self.config_path, - {"script_list": [{"display_name": "原神", "script_path": "C:/a.exe"}]}, - ) - self.mock_script = MagicMock() - - def _read(self): - return load_yaml(self.config_path) - - def test_add_script_appends(self): - """add_script 在 script_list 末尾追加条目、落盘,并内部调 ensure_weekly_entry。""" - with ( - patch( - "src.service.chain_service.require_config_yml_path", - return_value=self.config_path, - ), - patch( - "src.service.chain_service.get_config_yml_path_under_root", - return_value=self.config_path, - ), - ): - ChainService(script_service=self.mock_script).add_script( - {"display_name": "鸣潮", "script_path": "C:/b.exe"} - ) - names = [s["display_name"] for s in self._read()["script_list"]] - self.assertEqual(names, ["原神", "鸣潮"]) - self.mock_script.ensure_weekly_entry.assert_called_once_with("b") - - def test_remove_script_removes(self): - """remove_script 从 script_list 移除指定进程条目、落盘,并内部清 weekly 孤儿。""" - with ( - patch( - "src.service.chain_service.require_config_yml_path", - return_value=self.config_path, - ), - patch( - "src.service.chain_service.get_config_yml_path_under_root", - return_value=self.config_path, - ), - ): - ChainService(script_service=self.mock_script).remove_script("a") - self.assertEqual(self._read()["script_list"], []) - self.mock_script.delete_weekly.assert_called_once_with("a") - - def test_remove_script_missing_raises(self): - """remove_script 移除不存在的脚本属非法调用:assert 表达不该发生""" - with ( - patch( - "src.service.chain_service.require_config_yml_path", - return_value=self.config_path, - ), - patch( - "src.service.chain_service.get_config_yml_path_under_root", - return_value=self.config_path, - ), - self.assertRaises(AssertionError), - ): - ChainService(script_service=self.mock_script).remove_script("不存在") - - if __name__ == "__main__": unittest.main() diff --git a/tests/test_cli.py b/tests/test_cli.py index ca7f892..f3cd2a2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -23,8 +23,8 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") from src import cli, launcher -from src.config.subscript import get_script_name from src.service import chain_gen as service_chain_gen +from src.utils_sub_config import get_script_name from src.utils_yaml import load_yaml PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -311,7 +311,7 @@ class TestCliGenerateChainOverrides(unittest.TestCase): """--weekly-start 命令行覆盖的落盘语义。 - --weekly-start:经 service.set_weekly_start 持久化到 weekly_start.yml - (周几跑是长期配置),不实时写子脚本 config、也不并入 generate_chain 内存 ui_state。 + (周几跑是长期配置),不实时写子脚本 config、不并入任何 UI 状态。 """ def setUp(self): @@ -322,13 +322,13 @@ def setUp(self): def test_weekly_start_persists_via_set_weekly_start(self): """--weekly-start 调用 service.set_weekly_start 持久化(周几跑是长期配置), - 不实时写子脚本 config、不并入 generate_chain 内存 ui_state。""" + 不实时写子脚本 config、不并入任何 UI 状态。""" with tempfile.NamedTemporaryFile("w", suffix=".yml", delete=False) as fh: out = fh.name try: with ( - patch.object(cli.ChainService, "generate_chain", return_value=out), - patch.object(cli.ChainService, "set_weekly_start") as mock_set, + patch.object(cli.AppService, "generate_chain", return_value=out), + patch.object(cli.AppService, "set_weekly_start") as mock_set, ): _run_main( [ @@ -435,11 +435,11 @@ class TestCliScheduledRun(unittest.TestCase): def _run(self, argv): with ( patch.object( - cli.ChainService, + cli.AppService, "load_config", return_value={"script_list": [{"display_name": "demo"}]}, ), - patch.object(cli.ChainService, "schedule_run") as mock_sched, + patch.object(cli.AppService, "schedule_run") as mock_sched, ): code = _run_main(argv, expect_exit=0) return code, mock_sched @@ -486,11 +486,11 @@ def test_schedule_run_enable_all_is_explicit_all(self): def test_schedule_run_unknown_enable_exits_one(self): with ( patch.object( - cli.ChainService, + cli.AppService, "load_config", return_value={"script_list": [{"display_name": "demo"}]}, ), - patch.object(cli.ChainService, "schedule_run") as mock_sched, + patch.object(cli.AppService, "schedule_run") as mock_sched, ): code = _run_main( ["--schedule-run", "08:00", "--enable", "ghost"], expect_exit=1 diff --git a/tests/test_dungeon_config.py b/tests/test_dungeon_config.py index 0e960b1..7a14ba5 100644 --- a/tests/test_dungeon_config.py +++ b/tests/test_dungeon_config.py @@ -1,151 +1,170 @@ -"""测试 dungeon_config 模块""" +"""测试 src/config/dungeon_config.py:副本与周常声明读取(get_dungeon_map / get_weekly_map)。""" +import os +import tempfile import unittest +from unittest.mock import patch -from src.config.dungeon_config import ( - get_display_name, - parse_dungeon_config, -) - - -class TestParseDungeonConfig(unittest.TestCase): - """测试 parse_dungeon_config""" - - def test_empty_dungeons(self): - """空 dungeons 列表返回空结果""" - cfg = {"dungeons": []} - options, seq_map, show_seq = parse_dungeon_config(cfg) - self.assertEqual(options, []) - self.assertEqual(seq_map, {}) - self.assertFalse(show_seq) - - def test_none_input(self): - """None 输入返回空结果""" - options, seq_map, show_seq = parse_dungeon_config(None) - self.assertEqual(options, []) - self.assertEqual(seq_map, {}) - self.assertFalse(show_seq) - - def test_flat_list(self): - """只有一级选项(无 sequences)""" - cfg = { - "dungeons": [ - {"name": "未选择"}, - {"name": "副本A"}, - {"name": "副本B"}, - ] - } - options, seq_map, show_seq = parse_dungeon_config(cfg) - self.assertEqual(options, ["未选择", "副本A", "副本B"]) - self.assertEqual(seq_map, {}) - self.assertFalse(show_seq) - - def test_with_sequences(self): - """有二级选项""" - cfg = { - "dungeons": [ - {"name": "未选择"}, - { - "name": "凝素领域", - "sequences": [ - {"display": "第1层", "value": 1}, - {"display": "第2层", "value": 2}, - ], - }, - ] - } - options, seq_map, show_seq = parse_dungeon_config(cfg) - self.assertEqual(options, ["未选择", "凝素领域"]) - self.assertEqual(seq_map["凝素领域"], [("第1层", 1), ("第2层", 2)]) - self.assertTrue(show_seq) - - def test_mixed_formats(self): - """混合格式:有二级选项和无二级选项""" - cfg = { - "dungeons": [ - {"name": "未选择"}, - { - "name": "凝素领域", - "sequences": [ - {"display": "第1层", "value": 1}, - {"display": "第2层", "value": 2}, - ], - }, - { - "name": "模拟领域", - "sequences": [ - {"display": "共鸣者经验", "value": "共鸣者经验"}, - {"display": "武器经验", "value": "武器经验"}, - ], - }, - ] +from src.config.dungeon_config import get_dungeon_map, get_weekly_map +from src.utils_yaml import dump_yaml_file + + +class TestGetWeeklyDefs(unittest.TestCase): + """get_weekly_map:静态 dungeons 保持,dungeons_source 运行期从外部读取/降级。""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.weekly_list_path = os.path.join(self.tmp.name, "weekly_list.yml") + patcher = patch( + "src.config.dungeon_config.get_weekly_list_yml_path_under_root", + return_value=self.weekly_list_path, + ) + patcher.start() + self.addCleanup(patcher.stop) + + def _write(self, data): + dump_yaml_file(self.weekly_list_path, data) + + def test_static_dungeons_untouched(self): + """带 dungeons(无 dungeons_source)的项保持原样,不触发外部读取。""" + self._write( + { + "March7th-Assistant": [ + {"name": "历战余响", "dungeons": ["无", "铁骸的锈冢"]} + ] + } + ) + with patch("src.config.dungeon_config.get_dungeon_lists") as mock_ext: + defs = get_weekly_map("March7th-Assistant") + self.assertEqual(defs[0]["dungeons"], ["无", "铁骸的锈冢"]) + mock_ext.assert_not_called() # 无 dungeons_source 不读外部 + + def test_external_source_filled_when_reachable(self): + """dungeons_source=assets/config/instance_names.json 且外部可读 → 用外部副本清单填充。""" + self._write( + { + "March7th-Assistant": [ + { + "name": "历战余响", + "dungeons_source": "assets/config/instance_names.json", + } + ] + } + ) + names = ["无", "铁骸的锈冢", "晨昏的回眸"] + with patch( + "src.config.dungeon_config.get_dungeon_lists", return_value=names + ) as mock_ext: + defs = get_weekly_map("March7th-Assistant") + mock_ext.assert_called_once_with( + "March7th-Assistant", "历战余响", "assets/config/instance_names.json" + ) + self.assertEqual(defs[0]["dungeons"], names) + self.assertTrue(defs[0]["dungeons"]) # 供 GUI 推导 has_dungeon + + def test_external_source_empty_when_unreachable(self): + """外部读不到(返回 None)→ 降级 dungeons=[],该周常无需选副本。""" + self._write( + { + "March7th-Assistant": [ + { + "name": "历战余响", + "dungeons_source": "assets/config/instance_names.json", + } + ] + } + ) + with patch("src.config.dungeon_config.get_dungeon_lists", return_value=None): + defs = get_weekly_map("March7th-Assistant") + self.assertEqual(defs[0]["dungeons"], []) + + def test_unknown_script_returns_empty(self): + """未知脚本 → get_weekly_map 返回空列表(不抛错、不读外部)。""" + self._write({"March7th-Assistant": [{"name": "货币战争"}]}) + self.assertEqual(get_weekly_map("不存在"), []) + + +class TestGetDungeonMap(unittest.TestCase): + """get_dungeon_map:静态 sequences 保持,dungeons_source 运行期从外部读取/降级。""" + + def test_static_sequences_untouched(self): + """带 dungeons(无 dungeons_source)的项保持原样,不触发外部读取。""" + raw = { + "ok-ef": { + "dungeons": [ + { + "name": "干员养成", + "sequences": [{"display": "干员经验", "value": "干员经验"}], + } + ] + } } - options, seq_map, show_seq = parse_dungeon_config(cfg) - self.assertEqual(options, ["未选择", "凝素领域", "模拟领域"]) - self.assertEqual(seq_map["凝素领域"], [("第1层", 1), ("第2层", 2)]) + with ( + patch("src.config.dungeon_config.load_dungeon_map", return_value=raw), + patch("src.config.dungeon_config.get_dungeon_lists") as mock_ext, + ): + result = get_dungeon_map() self.assertEqual( - seq_map["模拟领域"], - [("共鸣者经验", "共鸣者经验"), ("武器经验", "武器经验")], + result["ok-ef"]["dungeons"][0]["sequences"], + [{"display": "干员经验", "value": "干员经验"}], ) - self.assertTrue(show_seq) - - def test_invalid_format_no_dungeons_key(self): - """缺少 dungeons 键返回空结果""" - cfg = {"not_dungeons": []} - options, seq_map, show_seq = parse_dungeon_config(cfg) - self.assertEqual(options, []) - self.assertEqual(seq_map, {}) - self.assertFalse(show_seq) - - def test_directory_structure(self): - """一级为目录、二级为具体副本的目录结构(原神 BetterGI)""" - cfg = { - "dungeons": [ - {"name": "未选择"}, - { - "name": "1", - "sequences": [ - {"display": "山风的荆冕", "value": "山风的荆冕"}, - {"display": "霜凝的机枢", "value": "霜凝的机枢"}, - ], - }, - ] + mock_ext.assert_not_called() # 无 dungeons_source 不读外部 + + def test_fills_sequences_from_dungeons_source(self): + """带 dungeons_source 的声明项,其二级序列由 get_dungeon_lists 运行期填充。""" + raw = { + "ok-ef": { + "dungeons": [ + {"name": "培养目标"}, + { + "name": "能量淤积点", + "dungeons_source": "data/apps/ok-ef/working/assets/data/world_map.json", + }, + ] + } } - options, seq_map, show_seq = parse_dungeon_config(cfg) - self.assertEqual(options, ["未选择", "1"]) + with ( + patch("src.config.dungeon_config.load_dungeon_map", return_value=raw), + patch( + "src.config.dungeon_config.get_dungeon_lists", + return_value=["枢纽区", "武陵城"], + ) as mock_ext, + ): + result = get_dungeon_map() + # 培养目标(无 dungeons_source)保持无序列 + self.assertEqual(result["ok-ef"]["dungeons"][0].get("sequences"), None) + # 带 dungeons_source 的项被填充为 {display,value} 序列 + seqs = result["ok-ef"]["dungeons"][1]["sequences"] self.assertEqual( - seq_map["1"], - [("山风的荆冕", "山风的荆冕"), ("霜凝的机枢", "霜凝的机枢")], + seqs, + [ + {"display": "枢纽区", "value": "枢纽区"}, + {"display": "武陵城", "value": "武陵城"}, + ], ) - self.assertTrue(show_seq) - - -class TestGetDisplayName(unittest.TestCase): - """测试 get_display_name""" - - def test_found_integer_value(self): - """找到整数类型的实际值""" - seq_map = {"凝素领域": [("第1层", 1), ("第17层", 17)]} - result = get_display_name(seq_map, "凝素领域", 17) - self.assertEqual(result, "第17层") - - def test_found_string_value(self): - """找到字符串类型的实际值""" - seq_map = {"模拟领域": [("共鸣者经验", "共鸣者经验"), ("武器经验", "武器经验")]} - result = get_display_name(seq_map, "模拟领域", "武器经验") - self.assertEqual(result, "武器经验") - - def test_not_found_returns_string(self): - """找不到时返回实际值的字符串表示""" - seq_map = {"凝素领域": [("第1层", 1)]} - result = get_display_name(seq_map, "凝素领域", 99) - self.assertEqual(result, "99") - - def test_dungeon_not_in_map_raises(self): - """副本不在映射中时抛出 AssertionError""" - seq_map = {"凝素领域": [("第1层", 1)]} - with self.assertRaises(AssertionError): - get_display_name(seq_map, "不存在", 1) + mock_ext.assert_called_once_with( + "ok-ef", "能量淤积点", "data/apps/ok-ef/working/assets/data/world_map.json" + ) + + def test_dungeons_source_unreachable_degrades_to_empty(self): + """dungeons_source 读不到(get_dungeon_lists 返回 [])→ 降级为空序列。""" + raw = { + "ok-ef": { + "dungeons": [ + { + "name": "能量淤积点", + "dungeons_source": "data/apps/ok-ef/working/assets/data/world_map.json", + } + ] + } + } + with ( + patch("src.config.dungeon_config.load_dungeon_map", return_value=raw), + patch("src.config.dungeon_config.get_dungeon_lists", return_value=[]), + ): + result = get_dungeon_map() + self.assertEqual(result["ok-ef"]["dungeons"][0]["sequences"], []) if __name__ == "__main__": diff --git a/tests/test_game_config_roundtrip.py b/tests/test_game_config_roundtrip.py index 1ba4fa0..962685e 100644 --- a/tests/test_game_config_roundtrip.py +++ b/tests/test_game_config_roundtrip.py @@ -1,6 +1,6 @@ """游戏 config 往返保真测试(以「类似崩铁 M7A」的夹具驱动真实读写路径)。 -验证 src.config.subscript 的 load_config / save_config: +验证 src.utils_sub_config 的 load_config / save_config: - 读后写回,解析结果与原 config 数据等价(reloaded == original); - 注释(含行内注释)保留; - 04:00 / 4:00 这类时间保持字符串,绝不变 240.0 浮点污染; @@ -15,7 +15,7 @@ import unittest from unittest.mock import patch -from src.config import subscript +from src import utils_sub_config FIXTURE = os.path.join(os.path.dirname(__file__), "fixtures", "starrail_config.yaml") @@ -23,16 +23,16 @@ class TestGameConfigRoundTrip(unittest.TestCase): def _load_fixture(self): """经由真实 load_config 读夹具(mock 路径解析,避免依赖游戏目录)。""" - with patch.object(subscript, "get_config_path", return_value=FIXTURE): - return subscript.load_config("starrail-test", "config.yaml") + with patch.object(utils_sub_config, "get_sub_config_path", return_value=FIXTURE): + return utils_sub_config.load_config("starrail-test", "config.yaml") def _save_to_temp(self, data): with tempfile.NamedTemporaryFile( "w", suffix=".yaml", encoding="utf-8", delete=False ) as tmp: tmp_path = tmp.name - with patch.object(subscript, "get_config_path", return_value=tmp_path): - subscript.save_config("starrail-test", "config.yaml", data) + with patch.object(utils_sub_config, "get_sub_config_path", return_value=tmp_path): + utils_sub_config.save_config("starrail-test", "config.yaml", data) return tmp_path def test_loaded_values_match_original(self): @@ -53,8 +53,8 @@ def test_noop_round_trip_equal_and_comments_kept(self): cfg = self._load_fixture() tmp_path = self._save_to_temp(cfg) - with patch.object(subscript, "get_config_path", return_value=tmp_path): - reloaded = subscript.load_config("starrail-test", "config.yaml") + with patch.object(utils_sub_config, "get_sub_config_path", return_value=tmp_path): + reloaded = utils_sub_config.load_config("starrail-test", "config.yaml") self.assertEqual(reloaded, cfg) # 数据等价(reloaded == original) @@ -75,8 +75,8 @@ def test_starrail_weekly_write_round_trip(self): tmp_path = self._save_to_temp(cfg) - with patch.object(subscript, "get_config_path", return_value=tmp_path): - reloaded = subscript.load_config("starrail-test", "config.yaml") + with patch.object(utils_sub_config, "get_sub_config_path", return_value=tmp_path): + reloaded = utils_sub_config.load_config("starrail-test", "config.yaml") self.assertEqual(reloaded, cfg) self.assertTrue(reloaded["currencywars_enable"]) diff --git a/tests/test_game_list_controller.py b/tests/test_game_list_controller.py index 94eb506..7635949 100644 --- a/tests/test_game_list_controller.py +++ b/tests/test_game_list_controller.py @@ -88,7 +88,7 @@ def test_cancel_keeps_data(self, mock_box): instance.exec.return_value = 2 # Cancel ctrl = self._make_ctrl() ctrl.deleteScript(0) - ctrl._service.remove_script.assert_not_called() + ctrl._app_service.remove_script.assert_not_called() ctrl._on_reload.assert_not_called() @patch("src.gui.controllers.game_list.QMessageBox") @@ -100,7 +100,7 @@ def test_ok_removes_and_reloads(self, mock_box): instance.exec.return_value = 1 # Ok ctrl = self._make_ctrl() ctrl.deleteScript(0) - ctrl._service.remove_script.assert_called_once_with("wu") + ctrl._app_service.remove_script.assert_called_once_with("wu") ctrl._on_reload.assert_called_once() @@ -113,7 +113,7 @@ class TestReloadGamesIconOrder(unittest.TestCase): def test_refresh_before_set_games(self): ctrl = GameListController(MagicMock(), MagicMock(), MagicMock()) - ctrl._service.load_config.return_value = { + ctrl._app_service.load_config.return_value = { "script_list": [ { "display_name": "鸣潮", diff --git a/tests/test_gui_dialogs.py b/tests/test_gui_dialogs.py index 273a50b..b3e842a 100644 --- a/tests/test_gui_dialogs.py +++ b/tests/test_gui_dialogs.py @@ -42,11 +42,11 @@ def test_load_seeds_from_default_when_no_weekly_entry(self): cfg = self._make_config_file() with ( patch( - "src.service.script_service.require_config_yml_path", + "src.utils_config.require_config_yml_path", return_value=cfg, ), patch( - "src.service.script_service.get_weekly_timeouts_yml_path_under_root", + "src.utils_weekly.get_weekly_timeouts_yml_path_under_root", return_value=wt, ), ): @@ -60,11 +60,11 @@ def test_load_uses_existing_weekly_entry(self): cfg = self._make_config_file() with ( patch( - "src.service.script_service.require_config_yml_path", + "src.utils_config.require_config_yml_path", return_value=cfg, ), patch( - "src.service.script_service.get_weekly_timeouts_yml_path_under_root", + "src.utils_weekly.get_weekly_timeouts_yml_path_under_root", return_value=wt, ), ): @@ -76,7 +76,7 @@ def test_init_asserts_when_config_yml_missing(self): """config.yml 缺失属内部错误:构造对话框必须 assert,而非静默返回空数据""" with ( patch( - "src.service.script_service.require_config_yml_path", + "src.utils_config.require_config_yml_path", side_effect=AssertionError("config.yml 缺失"), ), self.assertRaises(AssertionError), @@ -106,7 +106,7 @@ def test_load_sets_block_from_config(self): ] ) with patch( - "src.service.script_service.require_config_yml_path", + "src.utils_config.require_config_yml_path", return_value=cfg, ): dlg = SingleScriptConfigDialog("collect_log", "日志分析", "C:/x.py") @@ -124,7 +124,7 @@ def test_load_defaults_block_true_when_missing(self): ] ) with patch( - "src.service.script_service.require_config_yml_path", + "src.utils_config.require_config_yml_path", return_value=cfg, ): dlg = SingleScriptConfigDialog("collect_log", "日志分析", "C:/x.py") @@ -143,7 +143,7 @@ def test_save_stores_block_in_pending_changes(self): ) with ( patch( - "src.service.script_service.require_config_yml_path", + "src.utils_config.require_config_yml_path", return_value=cfg, ), patch("src.gui.dialogs.QMessageBox.warning"), @@ -157,7 +157,7 @@ def test_save_stores_block_in_pending_changes(self): class _FakeService: - """极简 ScriptService 替身:供弹窗构造时读取脚本数据,避免依赖真实 config。""" + """极简 AppService 替身:供弹窗构造时读取脚本数据,避免依赖真实 config。""" def __init__( self, script_type, script_path, display_name="日志分析", weekly_start=None @@ -192,7 +192,7 @@ def _make_dialog(self, script_name, display_name, weekly_start, supported): script_name, display_name, "C:/games/run.exe", - script_service=_FakeService( + app_service=_FakeService( "external", "C:/games/run.exe", display_name, weekly_start ), ) @@ -218,7 +218,7 @@ def test_visible_and_loaded_when_weekly_supported(self): self.assertEqual(dlg.weekly_start_combo.currentIndex(), 3) def test_save_writes_weekly_start(self): - """保存时把周几起(周三起)经 ScriptService 持久化,并暂存到 pending_changes + """保存时把周几起(周三起)经 AppService 持久化,并暂存到 pending_changes 游戏侧原生 config 的同步不在 save_data 内进行(那时 config.yml 尚未落盘新路径, 目录解析会指向旧目录);由调用方落盘后触发,见 game_list.configCurrent。 @@ -230,11 +230,11 @@ def test_save_writes_weekly_start(self): patch.object(SingleScriptConfigDialog, "accept"), ): dlg.save_data() - self.assertEqual(dlg._script_service.saved_weekly_start, 3) + self.assertEqual(dlg._app_service.saved_weekly_start, 3) self.assertEqual(dlg.pending_changes["weekly_start_day"], 3) def test_save_clears_weekly_start_when_unset(self): - """选择「不设置」时经 ScriptService 清除(传 None),pending_changes 记为 None""" + """选择「不设置」时经 AppService 清除(传 None),pending_changes 记为 None""" dlg = self._make_dialog("run", "鸣潮", 5, supported=True) dlg.weekly_start_combo.setCurrentIndex(0) with ( @@ -242,7 +242,7 @@ def test_save_clears_weekly_start_when_unset(self): patch.object(SingleScriptConfigDialog, "accept"), ): dlg.save_data() - self.assertIsNone(dlg._script_service.saved_weekly_start) + self.assertIsNone(dlg._app_service.saved_weekly_start) self.assertIsNone(dlg.pending_changes["weekly_start_day"]) diff --git a/tests/test_gui_exe.py b/tests/test_gui_exe.py index 23a56a6..6faa91a 100644 --- a/tests/test_gui_exe.py +++ b/tests/test_gui_exe.py @@ -21,7 +21,7 @@ import tempfile import unittest -from src.config.subscript import get_script_name +from src.utils_sub_config import get_script_name from src.utils_yaml import load_yaml PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) diff --git a/tests/test_gui_links.py b/tests/test_gui_links.py index cbf1b9e..a24a0de 100644 --- a/tests/test_gui_links.py +++ b/tests/test_gui_links.py @@ -14,7 +14,7 @@ def __init__(self, game): class TestLinksOpenScriptConfig(unittest.TestCase): - """openScriptConfig:委托 ScriptService.config_file_path 打开当前脚本配置文件。""" + """openScriptConfig:委托 AppService.config_file_path 打开当前脚本配置文件。""" def _make_controller(self, config_return): game = { @@ -26,7 +26,7 @@ def _make_controller(self, config_return): svc.config_file_path.return_value = config_return toast = MagicMock() ctrl = LinksController( - game_list=_FakeGameList(game), toast=toast, script_service=svc + game_list=_FakeGameList(game), toast=toast, app_service=svc ) return ctrl, svc, toast diff --git a/tests/test_gui_shutdown_dialog.py b/tests/test_gui_shutdown_dialog.py new file mode 100644 index 0000000..079a4c7 --- /dev/null +++ b/tests/test_gui_shutdown_dialog.py @@ -0,0 +1,109 @@ +"""测试 src/gui/shutdown_dialog.py:关机确认窗与 Qt 失败降级。 + +确认窗为进程内 PySide6 弹窗,UI 测试在 offscreen 平台下运行(CI 无显示器)。 +关机确认窗实现于 ``src.gui.shutdown_dialog``,纯逻辑测试见 ``test_utils_shutdown.py``。 +""" + +import os +import unittest +from unittest import mock + +# 在导入 PySide6 之前设置 offscreen 平台插件(CI 无显示器环境) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PySide6.QtWidgets import QApplication, QDialog, QPushButton + +from src.gui.shutdown_dialog import ShutdownConfirmDialog, confirm_shutdown + +# 模块级 QApplication 单例:widget 需要 GUI 应用,进程退出时随解释器销毁。 +if QApplication.instance() is None: + _APP = QApplication([]) + + +def _button(dialog: QDialog, text: str) -> QPushButton: + """按文字取弹窗按钮(按钮由 FormDialogBase._make_footer 构造,无公开引用)。""" + return next(b for b in dialog.findChildren(QPushButton) if b.text() == text) + + +class TestConfirmShutdown(unittest.TestCase): + """confirm_shutdown:弹窗结果映射与 Qt 初始化失败的降级。""" + + def test_accepted_returns_true(self): + with mock.patch.object( + ShutdownConfirmDialog, + "exec", + return_value=QDialog.DialogCode.Accepted, + ): + self.assertTrue(confirm_shutdown(30)) + + def test_rejected_returns_false(self): + with mock.patch.object( + ShutdownConfirmDialog, + "exec", + return_value=QDialog.DialogCode.Rejected, + ): + self.assertFalse(confirm_shutdown(30)) + + def test_qt_init_failure_returns_false(self): + """Qt 初始化失败(无桌面):记诊断并按取消处理,不静默吞掉。 + + ``QApplication.instance()`` 返回 None 才会走到创建分支,故 Mock 需让 + ``instance`` 返回 None、构造调用抛 RuntimeError,与真实无桌面环境一致。 + """ + fake_app = mock.Mock() + fake_app.instance.return_value = None + fake_app.side_effect = RuntimeError("no display") + with ( + mock.patch("src.gui.shutdown_dialog.QApplication", fake_app), + self.assertLogs("src.gui.shutdown_dialog", level="ERROR") as logs, + ): + self.assertFalse(confirm_shutdown(30)) + self.assertIn("RuntimeError", "\n".join(logs.output)) + + +class TestShutdownConfirmDialog(unittest.TestCase): + """ShutdownConfirmDialog:倒计时文案 / 归零接受 / 按钮与定时器生命周期。""" + + def test_initial_label_shows_countdown(self): + dlg = ShutdownConfirmDialog(45) + self.assertEqual(dlg._label.text(), "系统将在 45 秒后关机") + + def test_tick_decrements(self): + dlg = ShutdownConfirmDialog(45) + dlg._tick() + self.assertEqual(dlg._remain, 44) + self.assertEqual(dlg._label.text(), "系统将在 44 秒后关机") + + def test_tick_to_zero_accepts(self): + """倒计时归零即接受(关机),并停表。""" + dlg = ShutdownConfirmDialog(2) + dlg.show() + dlg._tick() + self.assertEqual(dlg.result(), QDialog.DialogCode.Rejected) # 未归零:尚未接受 + dlg._tick() + self.assertEqual(dlg.result(), QDialog.DialogCode.Accepted) + self.assertFalse(dlg._timer.isActive()) + dlg.close() + + def test_confirm_button_accepts(self): + dlg = ShutdownConfirmDialog(45) + _button(dlg, "立即关机").click() + self.assertEqual(dlg.result(), QDialog.DialogCode.Accepted) + + def test_cancel_button_rejects(self): + dlg = ShutdownConfirmDialog(45) + _button(dlg, "取消").click() + self.assertEqual(dlg.result(), QDialog.DialogCode.Rejected) + + def test_timer_starts_on_show_and_stops_on_hide(self): + """显示才起倒计时(模态 exec 前不流逝),关闭即停表。""" + dlg = ShutdownConfirmDialog(45) + self.assertFalse(dlg._timer.isActive()) + dlg.show() + self.assertTrue(dlg._timer.isActive()) + dlg.close() + self.assertFalse(dlg._timer.isActive()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_gui_task_card.py b/tests/test_gui_task_card.py index ea4480c..642fa38 100644 --- a/tests/test_gui_task_card.py +++ b/tests/test_gui_task_card.py @@ -5,6 +5,7 @@ import unittest from unittest.mock import MagicMock, patch +from src.config.dungeon_config import get_dungeon_map, get_weekly_map from src.gui.controllers import task_card as task_card_mod from src.gui.controllers.task_card import TaskCardController from src.utils_yaml import dump_yaml_file @@ -23,16 +24,16 @@ def _write_defs(tmp, data): return path -def _make_controller( - script_name="March7th-Assistant", display_name="崩铁", ui_state=None -): +def _make_controller(script_name="March7th-Assistant", display_name="崩铁"): games = [{"script_name": script_name, "display_name": display_name}] game_list = _FakeGameList(games) service = MagicMock() - service.load_ui_state.return_value = {} if ui_state is None else ui_state + # 副本/周常声明经真实 dungeon_config 模块函数读取(weekly_list.yml 路径已由用例 patch)。 + service.get_weekly_map.side_effect = get_weekly_map + service.get_dungeon_map.side_effect = get_dungeon_map + service.get_weekly_start.return_value = None toast = MagicMock() - ctrl = TaskCardController(game_list, service, toast) - return ctrl + return TaskCardController(game_list, service, toast) class TestWeeklyItems(unittest.TestCase): @@ -53,7 +54,7 @@ def test_weekly_items_for_star_rail(self): ) with ( patch( - "src.service.script_service.get_weekly_list_yml_path_under_root", + "src.config.dungeon_config.get_weekly_list_yml_path_under_root", return_value=defs_path, ), patch.object(task_card_mod, "get_weekly_dungeon", return_value=None), @@ -67,7 +68,7 @@ def test_weekly_items_for_star_rail(self): self.assertEqual(items[0]["dungeon_label"], "") self.assertEqual(items[1]["name"], "历战余响") self.assertTrue(items[1]["has_dungeon"]) - # 无配置/未选:反读 None → 回退 gui_state(此处无 gui_state)→ 占位提示 + # 无配置/未选:反读 None → 占位提示(周常侧不设回退) self.assertEqual(items[1]["dungeon_label"], "选择副本") def test_weekly_dungeon_options_reads_from_config(self): @@ -85,7 +86,7 @@ def test_weekly_dungeon_options_reads_from_config(self): }, ) with patch( - "src.service.script_service.get_weekly_list_yml_path_under_root", + "src.config.dungeon_config.get_weekly_list_yml_path_under_root", return_value=defs_path, ): ctrl = _make_controller() @@ -105,7 +106,7 @@ def test_weekly_dungeon_options_unknown_weekly_returns_empty(self): }, ) with patch( - "src.service.script_service.get_weekly_list_yml_path_under_root", + "src.config.dungeon_config.get_weekly_list_yml_path_under_root", return_value=defs_path, ): ctrl = _make_controller() @@ -124,7 +125,7 @@ def test_weekly_dungeon_options_without_dungeons_key(self): }, ) with patch( - "src.service.script_service.get_weekly_list_yml_path_under_root", + "src.config.dungeon_config.get_weekly_list_yml_path_under_root", return_value=defs_path, ): ctrl = _make_controller() @@ -144,7 +145,7 @@ def test_weekly_supported_follows_config(self): }, ) with patch( - "src.service.script_service.get_weekly_list_yml_path_under_root", + "src.config.dungeon_config.get_weekly_list_yml_path_under_root", return_value=defs_path, ): ctrl_star = _make_controller("March7th-Assistant", "崩铁") @@ -158,57 +159,21 @@ def test_weekly_items_empty_for_non_weekly_script(self): # ok-ww 不在 weekly_list.yml 声明 → 空列表 defs_path = _write_defs(tmp, {}) with patch( - "src.service.script_service.get_weekly_list_yml_path_under_root", + "src.config.dungeon_config.get_weekly_list_yml_path_under_root", return_value=defs_path, ): ctrl = _make_controller("ok-ww", "鸣潮") self.assertEqual(ctrl.weekly_items, []) tmp.cleanup() - def test_weekly_items_reflects_saved_dungeon(self): - """已选副本(gui_state.json 的 weekly_dungeons)应反映在 dungeon_label。""" - tmp = tempfile.TemporaryDirectory() - defs_path = _write_defs( - tmp, - { - "March7th-Assistant": [ - {"name": "货币战争"}, - {"name": "历战余响", "dungeons": ["无", "铁骸的锈冢"]}, - ] - }, - ) - ui_state = { - "March7th-Assistant": {"weekly_dungeons": {"历战余响": "铁骸的锈冢"}} - } - with ( - patch( - "src.service.script_service.get_weekly_list_yml_path_under_root", - return_value=defs_path, - ), - # 反读 None → 回退 gui_state 的 weekly_dungeons(保留既有语义) - patch.object(task_card_mod, "get_weekly_dungeon", return_value=None), - ): - ctrl = _make_controller(ui_state=ui_state) - items = ctrl.weekly_items - tmp.cleanup() - echo = [i for i in items if i["name"] == "历战余响"][0] - self.assertEqual(echo["dungeon_label"], "铁骸的锈冢") - class TestSelectWeeklyDungeon(unittest.TestCase): - def test_select_weekly_dungeon_persists_and_writes_config(self): - """选副本:写 gui_state.json 的 weekly_dungeons + 调 set_config 适配器接口。""" + def test_select_weekly_dungeon_writes_config(self): + """选副本:调 set_weekly_dungeon 写脚本自身 config(周常侧无 no-op 脚本)。""" ctrl = _make_controller() with patch.object(task_card_mod, "set_weekly_dungeon") as mock_set: ctrl.selectWeeklyDungeon("历战余响", "铁骸的锈冢") - - # 1) 持久化到 gui_state.json 的 weekly_dungeons - self.assertEqual( - ctrl.ui_state["March7th-Assistant"]["weekly_dungeons"]["历战余响"], - "铁骸的锈冢", - ) - ctrl._service.save_ui_state.assert_called_once_with(ctrl.ui_state) - # 2) 写脚本自身 config 的 instance_names(M7A 约定键名) + # 写脚本自身 config 的 instance_names(M7A 约定键名) mock_set.assert_called_once_with("March7th-Assistant", "历战余响", "铁骸的锈冢") @@ -222,22 +187,16 @@ def test_select_dungeon_writes_subscript_config(self): ctrl.selectDungeon("凝素领域", "5") # 实时落盘:dungeon_name + sequence(鸣潮要求 sequence 非空) mock_set.assert_called_once_with("ok-ww", dungeon_name="凝素领域", sequence="5") - # 同时持久化到 gui_state.json 的副本/序列字段 - self.assertEqual(ctrl.ui_state["ok-ww"]["dungeon"], "凝素领域") - self.assertEqual(ctrl.ui_state["ok-ww"]["sequence"], "5") - ctrl._service.save_ui_state.assert_called_once_with(ctrl.ui_state) class TestDailyDungeonTextReadback(unittest.TestCase): - """daily_dungeon_text 优先反读子脚本 config,无真相回退 gui_state.json。""" + """daily_dungeon_text 优先反读子脚本 config,无真相回退声明的唯一选项。""" - def test_prefers_subscript_config_over_gui_state(self): - ctrl = _make_controller( - "ok-ww", - "鸣潮", - ui_state={"ok-ww": {"dungeon": "旧副本", "sequence": "旧序列"}}, - ) + def test_prefers_subscript_config_over_declared(self): + """config 有真相时以 config 为准,不走声明项回退。""" + ctrl = _make_controller("ok-ww", "鸣潮") ctrl._dungeon_map_cache = {} + ctrl._dungeon_options_cache = {"ok-ww": [{"name": "声明项"}]} with ( patch.object(task_card_mod, "get_dungeon", return_value="凝素领域"), patch.object(task_card_mod, "get_sequence", return_value="5"), @@ -257,11 +216,7 @@ def test_nte_daily_shows_dungeon_and_sequence(self): } ] } - ctrl = _make_controller( - "ok-nte", - "异环", - ui_state={"ok-nte": {"dungeon": "旧副本", "sequence": "旧序列"}}, - ) + ctrl = _make_controller("ok-nte", "异环") ctrl._dungeon_map_cache = {"ok-nte": dungeon_cfg} with ( patch.object(task_card_mod, "get_dungeon", return_value="空幕"), @@ -269,18 +224,29 @@ def test_nte_daily_shows_dungeon_and_sequence(self): ): self.assertEqual(ctrl.daily_dungeon_text, "空幕 · 轨道之夜") - def test_falls_back_to_gui_state_when_config_none(self): - ctrl = _make_controller( - "OneDragon-Launcher", - "绝区零", - ui_state={"OneDragon-Launcher": {"dungeon": "副本A", "sequence": None}}, - ) + def test_falls_back_to_declared_option_when_config_none(self): + """no-op 脚本(绝区零)反读无真相 → 回退声明的唯一选项,呈现为已选。""" + ctrl = _make_controller("OneDragon-Launcher", "绝区零") + ctrl._dungeon_map_cache = {} + ctrl._dungeon_options_cache = { + "OneDragon-Launcher": [{"name": "培养方案", "sequences": []}] + } + with ( + patch.object(task_card_mod, "get_dungeon", return_value=None), + patch.object(task_card_mod, "get_sequence", return_value=None), + ): + self.assertEqual(ctrl.daily_dungeon_text, "培养方案") + + def test_placeholder_when_no_config_and_no_declaration(self): + """config 无真相且 dungeon_list.yml 未声明 → 占位「选择副本」。""" + ctrl = _make_controller("ok-ww", "鸣潮") ctrl._dungeon_map_cache = {} + ctrl._dungeon_options_cache = {} with ( patch.object(task_card_mod, "get_dungeon", return_value=None), patch.object(task_card_mod, "get_sequence", return_value=None), ): - self.assertEqual(ctrl.daily_dungeon_text, "副本A") + self.assertEqual(ctrl.daily_dungeon_text, "选择副本") class TestWeeklyItemsReadback(unittest.TestCase): @@ -298,7 +264,7 @@ def test_weekly_dungeon_label_prefers_config(self): ) with ( patch( - "src.service.script_service.get_weekly_list_yml_path_under_root", + "src.config.dungeon_config.get_weekly_list_yml_path_under_root", return_value=defs_path, ), patch.object( diff --git a/tests/test_launch_controller.py b/tests/test_launch_controller.py index 18574cd..3dba54e 100644 --- a/tests/test_launch_controller.py +++ b/tests/test_launch_controller.py @@ -28,7 +28,6 @@ def _make_controller(enabled: bool, target_time: str | None): game_list.games = [{"script_name": "demo"}] game_list.enabled = [True] task_card = mock.MagicMock() - task_card.ui_state = {} service = mock.MagicMock() service.load_config.return_value = {"script_list": []} service.load_schedule.return_value = { @@ -115,7 +114,6 @@ def _make_ctrl(self, config_data): game_list.games = [{"script_name": "demo"}] game_list.enabled = [True] task_card = mock.MagicMock() - task_card.ui_state = {} service = mock.MagicMock() # script_list 留在 config;其余调度块归 schedule。 schedule_keys = { diff --git a/tests/test_log_monitor.py b/tests/test_log_monitor.py index 066c2f4..3243e19 100644 --- a/tests/test_log_monitor.py +++ b/tests/test_log_monitor.py @@ -10,7 +10,6 @@ import src.log.monitor as collect_log import src.utils_logger -from src.config.subscript import get_script_name from src.log import ( BGILogParser, M7ALogParser, @@ -21,6 +20,7 @@ ZZZLogParser, parse_log, ) +from src.utils_sub_config import get_script_name from src.utils_yaml import dump_yaml_file, load_yaml diff --git a/tests/test_qml_bridge_taskcard.py b/tests/test_qml_bridge_taskcard.py index 43a2413..af369db 100644 --- a/tests/test_qml_bridge_taskcard.py +++ b/tests/test_qml_bridge_taskcard.py @@ -1,15 +1,15 @@ """测试 QmlBridge 任务卡后端(日常副本 / 周常周几)。 -复用 test_qml_launcher 的 _make_bridge:用 mock 隔离 ChainService 的 config / -ui_state / 壁纸 I/O;is_adapted / ScriptService.get_weekly_defs / get_dungeon_map / -parse_dungeon_config 按用例 patch(这些名字实际由 task_card 子模块引用, -patch 目标指向 task_card),验证 QML 任务卡所需的数据与写回行为。 +复用 test_qml_launcher 的 _make_bridge:用 mock 隔离 config / 壁纸 I/O;is_adapted / dungeon_config.get_weekly_map / get_dungeon_map / +parse_dungeon_config 按用例 patch(task_card 经 AppService 取数,patch 目标指向 +真实模块函数),验证 QML 任务卡所需的数据与写回行为。 """ import unittest from unittest.mock import patch from src.gui.controllers import task_card +from src.service import app_service from tests.test_qml_launcher import _make_bridge @@ -19,18 +19,16 @@ class TestTaskCard(unittest.TestCase): @patch.object(task_card, "get_dungeon", return_value=None) @patch.object(task_card, "get_sequence", return_value=None) @patch.object(task_card, "is_adapted", return_value=True) - @patch.object( - task_card.ScriptService, "get_weekly_defs", return_value=[{"name": "周常"}] - ) - @patch.object(task_card.ScriptService, "get_weekly_start", return_value=None) - @patch.object(task_card.ScriptService, "get_dungeon_map", return_value={}) + @patch("src.service.app_service.get_weekly_map", return_value=[{"name": "周常"}]) + @patch.object(app_service, "get_weekly_start", return_value=None) + @patch("src.service.app_service.get_dungeon_map", return_value={}) def test_daily_text_default_is_placeholder(self, *_): b = _make_bridge() self.assertEqual(b.dailyDungeonText, "选择副本") self.assertEqual(b.weeklyStartLabel, "选择周几") @patch.object(task_card, "is_adapted", return_value=False) - @patch.object(task_card.ScriptService, "get_dungeon_map", return_value={}) + @patch("src.service.app_service.get_dungeon_map", return_value={}) def test_task_adapted_reflects_is_adapted(self, *_): b = _make_bridge() self.assertFalse(b.taskAdapted) @@ -42,13 +40,13 @@ def get(self, key, default=None): return 1 @patch.object(task_card, "is_adapted", return_value=True) - @patch.object(task_card.ScriptService, "get_dungeon_map", return_value=_AnyMap()) + @patch("src.service.app_service.get_dungeon_map", return_value=_AnyMap()) def test_daily_supported_true_when_dungeon_cfg_present(self, *_): b = _make_bridge() self.assertTrue(b.dailySupported) @patch.object(task_card, "is_adapted", return_value=True) - @patch.object(task_card.ScriptService, "get_dungeon_map", return_value={}) + @patch("src.service.app_service.get_dungeon_map", return_value={}) def test_daily_supported_false_when_no_dungeon_cfg(self, *_): b = _make_bridge() self.assertFalse(b.dailySupported) @@ -57,23 +55,19 @@ def test_daily_supported_false_when_no_dungeon_cfg(self, *_): @patch.object(task_card, "get_dungeon", return_value=None) @patch.object(task_card, "get_sequence", return_value=None) @patch.object(task_card, "is_adapted", return_value=True) - @patch.object(task_card.ScriptService, "get_weekly_defs", return_value=[]) - @patch.object(task_card.ScriptService, "get_dungeon_map", return_value={}) - def test_select_dungeon_persists(self, *_): + @patch("src.service.app_service.get_weekly_map", return_value=[]) + @patch("src.service.app_service.get_dungeon_map", return_value={}) + def test_select_dungeon_writes_config(self, *_): b = _make_bridge() name = b.games[0]["script_name"] - with patch.object(b.service, "save_ui_state") as m_save: - b.selectDungeon("副本A", "seq1") - self.assertEqual(b.task_card._ui_state[name]["dungeon"], "副本A") - self.assertEqual(b.task_card._ui_state[name]["sequence"], "seq1") - # 无配置真相(get_dungeon/get_sequence 回退为 None)→ chip 文字取 gui_state 副本名 + # 无配置真相(get_dungeon/get_sequence 回退为 None)→ chip 回退声明的唯一选项 + b.task_card._dungeon_options_cache = {name: [{"name": "副本A"}]} + b.selectDungeon("副本A", "seq1") self.assertEqual(b.dailyDungeonText, "副本A") # 实时落盘子脚本 config(日常副本编辑期即生效,不再依赖运行全体) task_card.set_config.assert_called_once_with( name, dungeon_name="副本A", sequence="seq1" ) - # 对齐旧 GUI:日常副本选择持久化到 gui_state.json - m_save.assert_called_once() @patch.object(task_card, "is_adapted", return_value=True) @patch.object( @@ -87,14 +81,14 @@ class _Map(dict): def get(self, key, default=None): return 1 - with patch.object(task_card.ScriptService, "get_dungeon_map") as m_dm: + with patch("src.service.app_service.get_dungeon_map") as m_dm: m_dm.return_value = _Map() b = _make_bridge() opts = b.dungeonOptions self.assertEqual(opts[0]["name"], "副本A") self.assertEqual(opts[0]["sequences"], [{"label": "难1", "value": "s1"}]) - @patch.object(task_card.ScriptService, "get_dungeon_map", return_value={}) + @patch("src.service.app_service.get_dungeon_map", return_value={}) def test_dungeon_options_empty_when_no_cfg(self, *_): b = _make_bridge() self.assertEqual(b.dungeonOptions, []) @@ -116,7 +110,7 @@ def test_config_current_accept_saves_and_reloads( "weekly_timeouts": {"1": [1]}, } with ( - patch.object(b.service, "update_script") as mock_update, + patch.object(b.app_service, "update_script") as mock_update, patch.object(b, "_reload_games") as mock_reload, ): b.configCurrent() @@ -131,7 +125,7 @@ def test_config_current_cancel_does_not_save(self, mock_qdialog, mock_dialog_cls dlg = mock_dialog_cls.return_value dlg.exec.return_value = mock_qdialog.Rejected # 取消/关闭 with ( - patch.object(b.service, "update_script") as mock_update, + patch.object(b.app_service, "update_script") as mock_update, patch.object(b, "_reload_games") as mock_reload, ): b.configCurrent() diff --git a/tests/test_qml_launcher.py b/tests/test_qml_launcher.py index 9295565..0a6ba9b 100644 --- a/tests/test_qml_launcher.py +++ b/tests/test_qml_launcher.py @@ -5,9 +5,10 @@ 渲染时才调用),避免 offscreen 依赖 exe 图标。 各职责已拆到 src/gui/controllers/ 下 mixin;monkeypatch 目标需指向实际引用 -该名字的子模块(os/subprocess/webbrowser 指向标准库模块;ChainService 为类方法 -patch 指向重导出的 main_window.ChainService;build_script_command 在 launch, -链接相关函数在 links,周常/适配相关函数在 task_card)。 +该名字的子模块(os/subprocess/webbrowser 指向标准库模块;config 读取走 AppService—— +其 load_config 已委托 src.utils_config,故 load_config 类方法 patch 指向 +AppService;build_script_command 在 launch,链接相关函数在 links, +周常/适配相关函数在 task_card)。 """ import os @@ -31,6 +32,7 @@ from src.gui.controllers.game_list import ScriptIconProvider # noqa: E402 from src.gui.icons import UiIconProvider # noqa: E402 from src.gui.main_window import QmlBridge # noqa: E402 +from src.service.app_service import AppService # noqa: E402 # 清理损坏的 QML 磁盘缓存(需在 QQmlApplicationEngine 创建前,保证干净编译) _local_appdata = os.environ.get("LOCALAPPDATA", "") @@ -61,19 +63,15 @@ def _make_bridge(): # 构造期用 with 屏蔽读盘(QmlBridge 初始化即读 config.yml); # with 退出后失效,故构造后再持久 mock load_config,覆盖 reorderGames/ # addScript 等构造后真实读盘路径(CI 环境无 config.yml,必须持续屏蔽)。 - with ( - patch.object( - main_window.ChainService, - "load_config", - return_value={"script_list": list(_SCRIPTS)}, - ), - patch.object(main_window.ChainService, "load_ui_state", return_value={}), + with patch.object( + AppService, + "load_config", + return_value={"script_list": list(_SCRIPTS)}, ): b = QmlBridge() - b.service.load_config = MagicMock(return_value={"script_list": list(_SCRIPTS)}) - # 隔离写盘:避免测试污染真实 config/gui_state.json / config.yml - b.service.save_config = MagicMock() - b.service.save_ui_state = MagicMock() + b.app_service.load_config = MagicMock(return_value={"script_list": list(_SCRIPTS)}) + # 隔离写盘:避免测试污染真实 config.yml + b.app_service.save_config = MagicMock() return b @@ -181,12 +179,12 @@ def test_select_all_and_deselect_all(self): def test_reorder_games_syncs_config_and_enabled(self): b = _make_bridge() - b.service.save_config = MagicMock() + b.app_service.save_config = MagicMock() b.deselectAll() b.selectAll() b.reorderGames(0, 1) # 鸣潮 → 测试脚本之后 self.assertEqual([g["display_name"] for g in b.games], ["测试脚本", "鸣潮"]) - b.service.save_config.assert_called_once() + b.app_service.save_config.assert_called_once() def test_launch_all_no_enabled_toasts(self): b = _make_bridge() @@ -331,14 +329,14 @@ def test_add_script_emits_game_added(self): return_value=("C:/scripts/new.py", ""), ), patch.object( - b.service._script_service, + b.app_service, "build_script_entry", return_value=entry, ), - patch.object(b.service, "add_script"), + patch.object(b.app_service, "add_script"), ): b.addScript() - b.service.add_script.assert_called_once_with(entry) + b.app_service.add_script.assert_called_once_with(entry) spy.assert_called_once() @@ -360,8 +358,9 @@ def test_main_qml_loads(self): from PySide6.QtCore import QUrl, QTimer from PySide6.QtQml import QQmlApplicationEngine, qmlRegisterSingletonInstance from PySide6.QtWidgets import QApplication - from src.config.subscript import resolve_script_path + from src.utils_sub_config import resolve_script_path from src.gui import main_window + from src.service.app_service import AppService from src.gui.controllers.game_list import ScriptIconProvider from src.gui.icons import UiIconProvider from src.gui.main_window import QmlBridge @@ -372,8 +371,7 @@ def test_main_qml_loads(self): {"display_name": "测试脚本", "script_path": "scripts/t.py", "script_type": "python"}, ] with ( - patch.object(main_window.ChainService, "load_config", return_value={"script_list": scripts}), - patch.object(main_window.ChainService, "load_ui_state", return_value={}), + patch.object(AppService, "load_config", return_value={"script_list": scripts}), patch.object(main_window.BackgroundController, "resolve_bg", return_value=None), ): bridge = QmlBridge() @@ -422,11 +420,12 @@ def test_popups_fit_inside_window(self): from PySide6.QtQml import QQmlApplicationEngine, qmlRegisterSingletonInstance from PySide6.QtQuick import QQuickItem from PySide6.QtWidgets import QApplication - from src.config.subscript import resolve_script_path + from src.utils_sub_config import resolve_script_path from src.gui import main_window + from src.service.app_service import AppService from src.gui.icons import UiIconProvider from src.gui.main_window import QmlBridge - import src.service.script_service as script_service + import src.config.dungeon_config as dungeon_config app = QApplication([]) # 崩铁:真实 config/weekly_list.yml 里历战余响声明了 9 个副本。 @@ -440,12 +439,11 @@ def test_popups_fit_inside_window(self): }] fake_dungeons = [f"副本{i}" for i in range(1, 10)] with ( - patch.object(main_window.ChainService, "load_config", + patch.object(AppService, "load_config", return_value={"script_list": scripts}), - patch.object(main_window.ChainService, "load_ui_state", return_value={}), patch.object(main_window.BackgroundController, "resolve_bg", return_value=None), - patch.object(script_service, "get_dungeon_lists", + patch.object(dungeon_config, "get_dungeon_lists", return_value=fake_dungeons), ): bridge = QmlBridge() @@ -517,8 +515,9 @@ def test_weekly_area_hidden_when_not_supported(self): from PySide6.QtQuick import QQuickItem from PySide6.QtWidgets import QApplication from unittest.mock import patch - from src.config.subscript import resolve_script_path + from src.utils_sub_config import resolve_script_path from src.gui import main_window + from src.service.app_service import AppService from src.gui.icons import UiIconProvider from src.gui.main_window import QmlBridge @@ -530,9 +529,8 @@ def test_weekly_area_hidden_when_not_supported(self): "script_type": "external", }] with ( - patch.object(main_window.ChainService, "load_config", + patch.object(AppService, "load_config", return_value={"script_list": scripts}), - patch.object(main_window.ChainService, "load_ui_state", return_value={}), patch.object(main_window.BackgroundController, "resolve_bg", return_value=None), ): @@ -598,8 +596,9 @@ def test_weekly_area_height_matches_item_count(self): from PySide6.QtQuick import QQuickItem from PySide6.QtWidgets import QApplication from unittest.mock import patch - from src.config.subscript import resolve_script_path + from src.utils_sub_config import resolve_script_path from src.gui import main_window + from src.service.app_service import AppService from src.gui.icons import UiIconProvider from src.gui.main_window import QmlBridge @@ -615,12 +614,11 @@ def test_weekly_area_height_matches_item_count(self): "script_type": "external", }] with ( - patch.object(main_window.ChainService, "load_config", + patch.object(AppService, "load_config", return_value={"script_list": scripts}), - patch.object(main_window.ChainService, "load_ui_state", return_value={}), patch.object(main_window.BackgroundController, "resolve_bg", return_value=None), - patch("src.service.script_service.get_dungeon_lists", + patch("src.config.dungeon_config.get_dungeon_lists", return_value=["无", "坏灭的喜剧", "铁骸的锈冢", "晨昏的回眸", "心兽的战场", "尘梦的赞礼", "蛀星的旧靥", "不死的神实", "寒潮的落幕", "毁灭的开端"]), diff --git a/tests/test_scheduled_run.py b/tests/test_schedule.py similarity index 88% rename from tests/test_scheduled_run.py rename to tests/test_schedule.py index 8421431..f97552d 100644 --- a/tests/test_scheduled_run.py +++ b/tests/test_schedule.py @@ -1,4 +1,4 @@ -"""测试 src/service/scheduled_run.py:定时运行的 pre_run / core / post_run 流水线。 +"""测试 src/service/schedule.py:定时运行的 pre_run / core / post_run 流水线。 覆盖: - 工厂装配契约(build_pre_run_pipeline 合并成一次 kill、写配置按启用集、 @@ -14,7 +14,7 @@ import unittest from unittest import mock -from src.service.scheduled_run import ( +from src.service.schedule import ( ScheduledRun, build_pre_run_pipeline, ) @@ -27,7 +27,9 @@ def _make_service(script_list=None, *, schedule=None): svc = mock.MagicMock() svc.load_config.return_value = {"script_list": script_list or []} default = {"rerun": {"enabled": False}, "notify": {"enabled": False}} - svc.load_schedule.return_value = default if schedule is None else schedule + # schedule 数据不再经 service 桩注入(ScheduledRun 直接调本模块 load_schedule), + # 挂到 svc 上供用例 patch src.service.schedule.load_schedule 时取用。 + svc.schedule_data = default if schedule is None else schedule svc.get_weekly_start_map.return_value = {} return svc @@ -75,7 +77,7 @@ class TestBuildPreRunWriteConfig(unittest.TestCase): def test_applies_weekly_start_per_enabled_script(self): weekly_start_map = {"A": 3, "B": 4} with mock.patch("src.service.run_actions.set_config") as mock_set: - # target=now / close_running=False → 仅产生写 config step + # target=now、未传 scripts → 仅产生写 config step(关闭残留需 scripts 非空) steps = build_pre_run_pipeline( target_time="now", enabled_keys={"A", "B"}, @@ -170,7 +172,11 @@ def _run(self, sim: ProcessSim, *, close_running: bool = True): post_done: list[str] = [] with ( mock.patch( - "src.service.scheduled_run.build_post_run_pipeline", + "src.service.schedule.load_schedule", + return_value=svc.schedule_data, + ), + mock.patch( + "src.service.schedule.build_post_run_pipeline", return_value=[lambda: post_done.append("post")], ), sim.install(), @@ -227,17 +233,20 @@ def test_run_kills_shared_game_once(self): sim = ProcessSim() for key in ("ok-ef", "MAS"): sim.add_script(key, game_name="Endfield.exe") + svc = _make_service(sim.scripts) with ( mock.patch( - "src.service.scheduled_run.build_post_run_pipeline", + "src.service.schedule.load_schedule", + return_value=svc.schedule_data, + ), + mock.patch( + "src.service.schedule.build_post_run_pipeline", return_value=[lambda: None], ), self.assertLogs("src.service.run_actions", level="INFO") as cm, sim.install(), ): - ScheduledRun( - _make_service(sim.scripts), None, "now", close_running=True - ).run() + ScheduledRun(svc, None, "now", close_running=True).run() joined = "\n".join(cm.output) self.assertIn("已关闭残留进程 3 个", joined) # 2 真身 + 1 共用游戏 self.assertEqual(joined.count("Endfield.exe"), 1) @@ -261,7 +270,11 @@ def test_run_lifecycle_wiring(self): post_done: list[str] = [] with ( mock.patch( - "src.service.scheduled_run.build_post_run_pipeline", + "src.service.schedule.load_schedule", + return_value=svc.schedule_data, + ), + mock.patch( + "src.service.schedule.build_post_run_pipeline", return_value=[lambda: post_done.append("post")], ), sim.install(), @@ -295,10 +308,12 @@ def _run_and_record(self, *, close_running=True, mute=True, shutdown_delay=60): svc._rerun_round.side_effect = lambda *a, **k: calls.append("rerun") with ( mock.patch( - "src.service.scheduled_run.mute_on", lambda: calls.append("mute_on") + "src.service.schedule.load_schedule", + return_value=svc.schedule_data, ), + mock.patch("src.service.schedule.mute_on", lambda: calls.append("mute_on")), mock.patch( - "src.service.scheduled_run.mute_off", lambda: calls.append("mute_off") + "src.service.schedule.mute_off", lambda: calls.append("mute_off") ), mock.patch( "src.service.run_actions.kill_processes", @@ -314,15 +329,15 @@ def _run_and_record(self, *, close_running=True, mute=True, shutdown_delay=60): ), mock.patch("src.service.run_actions.time.sleep"), mock.patch( - "src.service.scheduled_run.analyze_logs", + "src.service.schedule.analyze_logs", lambda enabled_keys: calls.append("analyze") or {"entries": []}, ), mock.patch( - "src.service.scheduled_run.send_summary_mail", + "src.service.schedule.send_summary_mail", lambda result, smtp_config: calls.append("mail"), ), mock.patch( - "src.service.scheduled_run.shutdown_sys", + "src.service.schedule.shutdown_sys", lambda delay: calls.append("shutdown"), ), ): @@ -376,7 +391,10 @@ def test_runs_chain_then_rerun_when_enabled(self): [{"display_name": "A"}], schedule={"rerun": {"enabled": True}, "notify": {"enabled": False}}, ) - ScheduledRun(svc, {"A"}, "now", chain_name="today")._run_core() + with mock.patch( + "src.service.schedule.load_schedule", return_value=svc.schedule_data + ): + ScheduledRun(svc, {"A"}, "now", chain_name="today")._run_core() svc.run_chain_once.assert_called_once_with({"A"}, chain_name="today") svc._rerun_round.assert_called_once() kwargs = svc._rerun_round.call_args.kwargs @@ -388,7 +406,10 @@ def test_rerun_skipped_when_disabled(self): [{"display_name": "A"}], schedule={"rerun": {"enabled": False}, "notify": {"enabled": False}}, ) - ScheduledRun(svc, {"A"}, "now")._run_core() + with mock.patch( + "src.service.schedule.load_schedule", return_value=svc.schedule_data + ): + ScheduledRun(svc, {"A"}, "now")._run_core() svc.run_chain_once.assert_called_once() svc._rerun_round.assert_not_called() @@ -397,7 +418,12 @@ def test_missing_rerun_block_asserts(self): svc = _make_service( [{"display_name": "A"}], schedule={"notify": {"enabled": False}} ) - with self.assertRaises(AssertionError): + with ( + mock.patch( + "src.service.schedule.load_schedule", return_value=svc.schedule_data + ), + self.assertRaises(AssertionError), + ): ScheduledRun(svc, {"A"}, "now")._run_core() diff --git a/tests/test_script_service.py b/tests/test_script_service.py deleted file mode 100644 index 2f60521..0000000 --- a/tests/test_script_service.py +++ /dev/null @@ -1,519 +0,0 @@ -"""测试 src/service/script_service.py:单脚本配置读写与 weekly_timeouts 同步。""" - -import os -import tempfile -import unittest -from unittest.mock import patch - -from src.service.script_service import ScriptService -from src.utils_yaml import dump_yaml_file, load_yaml - - -class ScriptServiceTestBase(unittest.TestCase): - """用临时 config.yml / weekly_timeouts.yml 隔离真实文件。""" - - def setUp(self): - self.tmp_dir = tempfile.TemporaryDirectory() - self.addCleanup(self.tmp_dir.cleanup) - self.config_path = os.path.join(self.tmp_dir.name, "config.yml") - self.weekly_path = os.path.join(self.tmp_dir.name, "weekly_timeouts.yml") - self.weekly_list_path = os.path.join(self.tmp_dir.name, "weekly_list.yml") - self.weekly_start_path = os.path.join(self.tmp_dir.name, "weekly_start.yml") - self._write_config( - {"script_list": [{"display_name": "原神", "script_path": "C:/a.exe"}]} - ) - # weekly_timeouts.yml 随包发布、必存在,默认建一个空 {} 文件, - # 贴近真实部署;缺失→{} 的兜底已移除(改 assert 暴露)。 - self._write_weekly({}) - # weekly_start.yml 同样随包发布、必存在,默认空 {}。 - self._write_weekly_start({}) - patchers = [ - patch( - "src.service.script_service.require_config_yml_path", - return_value=self.config_path, - ), - patch( - "src.service.script_service.get_weekly_timeouts_yml_path_under_root", - return_value=self.weekly_path, - ), - patch( - "src.service.script_service.get_weekly_list_yml_path_under_root", - return_value=self.weekly_list_path, - ), - patch( - "src.service.script_service.get_weekly_start_yml_path_under_root", - return_value=self.weekly_start_path, - ), - ] - for p in patchers: - p.start() - self.addCleanup(p.stop) - - def _write_config(self, data): - dump_yaml_file(self.config_path, data) - - def _write_weekly(self, data): - dump_yaml_file(self.weekly_path, data) - - def _write_weekly_start(self, data): - dump_yaml_file(self.weekly_start_path, data) - - def _read_config(self): - return load_yaml(self.config_path) - - def _read_weekly(self): - if not os.path.exists(self.weekly_path): - return None - return load_yaml(self.weekly_path) - - def _read_weekly_start(self): - if not os.path.exists(self.weekly_start_path): - return None - return load_yaml(self.weekly_start_path) - - -class TestGetScript(ScriptServiceTestBase): - def test_get_existing_script(self): - s = ScriptService().get_script("a") - self.assertEqual(s, {"display_name": "原神", "script_path": "C:/a.exe"}) - - def test_get_missing_script_returns_none(self): - self.assertIsNone(ScriptService().get_script("none")) - - -class TestSaveWeekly(ScriptServiceTestBase): - """save_weekly:保存 7 格超时到 weekly_timeouts.yml。""" - - def test_save_weekly_writes_entry(self): - ScriptService().save_weekly("a", [60] * 7) - self.assertEqual(self._read_weekly()["a"], [60] * 7) - - def test_none_timeouts_resolved_to_default(self): - """空输入(None)→ 转默认超时。""" - ScriptService().save_weekly("a", [None, 60, None, 60, 60, 60, 60]) - self.assertEqual( - self._read_weekly()["a"], - [3600, 60, 3600, 60, 60, 60, 60], - ) - - def test_low_timeouts_preserved(self): - """低于 10 的输入原样保留(由 chain_gen 按「<10 当天不运行」跳过,不再 clamp)。""" - ScriptService().save_weekly("a", [5, 0, 60, 60, 60, 60, 60]) - self.assertEqual( - self._read_weekly()["a"], - [5, 0, 60, 60, 60, 60, 60], - ) - - -class TestRenameWeeklyInTimeouts(ScriptServiceTestBase): - """rename_weekly_in_timeouts:改名时迁移 weekly_timeouts.yml 条目。""" - - def test_rename_migrates_entry(self): - dump_yaml_file(self.weekly_path, {"a": [1] * 7}) - ScriptService().rename_weekly_in_timeouts("a", "b") - weekly = self._read_weekly() - self.assertNotIn("a", weekly) - self.assertEqual(weekly["b"], [1] * 7) - - def test_same_name_noop(self): - """同名的 rename 为 no-op,不影响已有 weekly 条目。""" - dump_yaml_file(self.weekly_path, {"a": [60] * 7}) - ScriptService().rename_weekly_in_timeouts("a", "a") - self.assertEqual(self._read_weekly()["a"], [60] * 7) - - def test_old_entry_missing_noop(self): - """旧名无 weekly 条目 → no-op(不报错、不改文件,保持空 {})。""" - ScriptService().rename_weekly_in_timeouts("none", "b") - self.assertEqual(self._read_weekly(), {}) - - -class TestEnsureWeeklyEntry(ScriptServiceTestBase): - def test_creates_default_entry(self): - ScriptService().ensure_weekly_entry("a") - self.assertEqual(self._read_weekly()["a"], [3600] * 7) - - def test_existing_entry_untouched(self): - dump_yaml_file(self.weekly_path, {"a": [60] * 7}) - ScriptService().ensure_weekly_entry("a") - self.assertEqual(self._read_weekly()["a"], [60] * 7) - - -class TestWeeklyInputs(ScriptServiceTestBase): - def test_missing_entry_uses_default(self): - self.assertEqual(ScriptService().weekly_inputs("a"), [3600] * 7) - - def test_existing_entry_kept(self): - dump_yaml_file(self.weekly_path, {"a": [1, 2, 3, 4, 5, 6, 7]}) - self.assertEqual(ScriptService().weekly_inputs("a"), [1, 2, 3, 4, 5, 6, 7]) - - def test_short_entry_padded_with_default(self): - """不足 7 格 → 用默认超时补齐。""" - dump_yaml_file(self.weekly_path, {"a": [10, 20]}) - self.assertEqual( - ScriptService().weekly_inputs("a"), - [10, 20, 3600, 3600, 3600, 3600, 3600], - ) - - -class TestBuildScriptEntry(unittest.TestCase): - """build_script_entry:文件名去重命名 + 类型推断 + 默认字段。""" - - def test_python_type_inferred(self): - entry = ScriptService().build_script_entry("C:/foo/bar.py", set()) - self.assertEqual(entry["script_type"], "python") - self.assertEqual(entry["display_name"], "bar") - - def test_external_type_inferred(self): - entry = ScriptService().build_script_entry("C:/foo/bar.exe", set()) - self.assertEqual(entry["script_type"], "external") - self.assertEqual(entry["display_name"], "bar") - - def test_name_deduplicated_with_suffix(self): - entry = ScriptService().build_script_entry("C:/foo/bar.exe", {"bar"}) - self.assertEqual(entry["display_name"], "bar_1") - - def test_name_dedup_keeps_incrementing(self): - entry = ScriptService().build_script_entry("C:/foo/bar.exe", {"bar", "bar_1"}) - self.assertEqual(entry["display_name"], "bar_2") - - -class TestCheckWeekly(ScriptServiceTestBase): - """check_weekly:weekly_timeouts.yml 与 config 脚本条目的一致性。""" - - def test_ok_when_aligned(self): - """weekly 有 7 格条目且无孤儿 → status=ok。""" - dump_yaml_file(self.weekly_path, {"a": [3600] * 7}) - result = ScriptService().check_weekly() - self.assertEqual(result["status"], "ok") - self.assertEqual(result["missing_or_short"], []) - self.assertEqual(result["orphans"], []) - - def test_missing_entry_reported(self): - """config 有脚本但 weekly 无条目 → 进 missing_or_short。""" - result = ScriptService().check_weekly() - self.assertEqual(result["status"], "inconsistent") - self.assertEqual(result["missing_or_short"], ["a"]) - - def test_orphan_key_reported(self): - """weekly 有 config 已删除的 key → 进 orphans。""" - dump_yaml_file(self.weekly_path, {"a": [3600] * 7, "gone": [3600] * 7}) - result = ScriptService().check_weekly() - self.assertEqual(result["status"], "inconsistent") - self.assertEqual(result["orphans"], ["gone"]) - - def test_missing_display_name_raises_assertion(self): - """条目缺 display_name 属数据损坏:_load_config 入口抛 AssertionError。""" - self._write_config({"script_list": [{"script_path": "C:/x.py"}]}) - with self.assertRaises(AssertionError): - ScriptService().check_weekly() - - -class TestConfigFilePath(ScriptServiceTestBase): - """测试 ScriptService.config_file_path:python/external 分支与缺失处理。""" - - def _setup_script(self, display_name, script_type, script_path): - self._write_config( - { - "script_list": [ - { - "display_name": display_name, - "script_type": script_type, - "script_path": script_path, - } - ] - } - ) - - def test_missing_script_returns_error(self): - self._setup_script("原神", "external", "C:/a.exe") - path, error = ScriptService().config_file_path("none") - self.assertIsNone(path) - self.assertIn("找不到脚本", error) - - def test_external_adapted_returns_config_path(self): - self._setup_script("原神", "external", "C:/a.exe") - with ( - patch( - "src.service.script_service.get_config_path", - return_value="C:/config/DailyTask.json", - ), - patch("src.service.script_service.os.path.isfile", return_value=True), - ): - path, error = ScriptService().config_file_path("a") - self.assertEqual(path, "C:/config/DailyTask.json") - self.assertIsNone(error) - - def test_external_unadapted_returns_error(self): - self._setup_script("原神", "external", "C:/a.exe") - with patch( - "src.service.script_service.get_config_path", - side_effect=AssertionError("未适配脚本: 原神"), - ): - path, error = ScriptService().config_file_path("a") - self.assertIsNone(path) - self.assertIn("暂未适配", error) - - def test_python_resolved_returns_py_path(self): - self._setup_script("静音", "python", "C:/proj/mute.py") - with ( - patch( - "src.service.script_service.resolve_script_path", - return_value="C:/proj/mute.py", - ), - patch("src.service.script_service.os.path.isfile", return_value=True), - ): - path, error = ScriptService().config_file_path("静音") - self.assertEqual(path, "C:/proj/mute.py") - self.assertIsNone(error) - - def test_python_missing_file_returns_error(self): - self._setup_script("静音", "python", "C:/nope/mute.py") - with ( - patch( - "src.service.script_service.resolve_script_path", - return_value="C:/nope/mute.py", - ), - patch("src.service.script_service.os.path.isfile", return_value=False), - ): - path, error = ScriptService().config_file_path("静音") - self.assertIsNone(path) - self.assertIn("找不到脚本文件", error) - - -class TestDeleteWeekly(ScriptServiceTestBase): - """测试 ScriptService.delete_weekly:仅清理 weekly_timeouts.yml 孤儿(总 config 移除归 ChainService)。""" - - def test_delete_weekly_cleans_orphan(self): - """删除后 weekly_timeouts.yml 中该脚本的孤儿条目被移除""" - dump_yaml_file(self.weekly_path, {"a": [100] * 7}) - ScriptService().delete_weekly("a") - weekly = self._read_weekly() - self.assertNotIn("a", weekly) - self.assertEqual(weekly, {}) - - def test_delete_weekly_keeps_others(self): - """删除单个脚本不影响 weekly_timeouts.yml 中其它条目""" - dump_yaml_file(self.weekly_path, {"a": [100] * 7, "mute": [120] * 7}) - ScriptService().delete_weekly("a") - weekly = self._read_weekly() - self.assertNotIn("a", weekly) - self.assertEqual(weekly, {"mute": [120] * 7}) - - def test_delete_weekly_noop_when_absent(self): - """脚本无 weekly 条目时清理为 no-op(不报错,文件保持空 {})""" - ScriptService().delete_weekly("不存在") - self.assertEqual(self._read_weekly(), {}) - - -class TestSetWeeklyStart(unittest.TestCase): - """set_weekly_start / get_weekly_start:读写独立文件 weekly_start.yml(不污染 weekly_list.yml)。""" - - def setUp(self): - self.tmp_dir = tempfile.TemporaryDirectory() - self.addCleanup(self.tmp_dir.cleanup) - self.weekly_start_path = os.path.join(self.tmp_dir.name, "weekly_start.yml") - self.weekly_list_path = os.path.join(self.tmp_dir.name, "weekly_list.yml") - # weekly_list.yml 必存在(_load_weekly_defs 断言),但本测试不依赖其内容。 - dump_yaml_file(self.weekly_list_path, {}) - dump_yaml_file(self.weekly_start_path, {}) - patchers = [ - patch( - "src.service.script_service.get_weekly_start_yml_path_under_root", - return_value=self.weekly_start_path, - ), - patch( - "src.service.script_service.get_weekly_list_yml_path_under_root", - return_value=self.weekly_list_path, - ), - ] - for p in patchers: - p.start() - self.addCleanup(p.stop) - - def _read_start(self): - return load_yaml(self.weekly_start_path) - - def test_set_writes_to_weekly_start_file_only(self): - """set_weekly_start 写入 weekly_start.yml,不污染 weekly_list.yml。""" - ScriptService().set_weekly_start("a", 4) - self.assertEqual(self._read_start(), {"a": 4}) - self.assertEqual(load_yaml(self.weekly_list_path), {}) - - def test_get_returns_set_value(self): - ScriptService().set_weekly_start("a", 3) - self.assertEqual(ScriptService().get_weekly_start("a"), 3) - self.assertIsNone(ScriptService().get_weekly_start("缺失")) - - def test_set_none_clears_entry(self): - """start_day=None → 移除该脚本条目。""" - ScriptService().set_weekly_start("a", 2) - ScriptService().set_weekly_start("a", None) - self.assertIsNone(ScriptService().get_weekly_start("a")) - self.assertEqual(self._read_start(), {}) - - def test_invalid_day_raises(self): - for bad in (0, 8): - with self.subTest(bad=bad), self.assertRaises(AssertionError): - ScriptService().set_weekly_start("a", bad) - - -class TestGetWeeklyDefs(unittest.TestCase): - """get_weekly_defs:静态 dungeons 保持,dungeons_source 运行期从外部读取/降级。""" - - def setUp(self): - self.tmp = tempfile.TemporaryDirectory() - self.addCleanup(self.tmp.cleanup) - self.weekly_list_path = os.path.join(self.tmp.name, "weekly_list.yml") - patcher = patch( - "src.service.script_service.get_weekly_list_yml_path_under_root", - return_value=self.weekly_list_path, - ) - patcher.start() - self.addCleanup(patcher.stop) - - def _write(self, data): - dump_yaml_file(self.weekly_list_path, data) - - def test_static_dungeons_untouched(self): - """带 dungeons(无 dungeons_source)的项保持原样,不触发外部读取。""" - self._write( - { - "March7th-Assistant": [ - {"name": "历战余响", "dungeons": ["无", "铁骸的锈冢"]} - ] - } - ) - with patch("src.service.script_service.get_dungeon_lists") as mock_ext: - defs = ScriptService().get_weekly_defs("March7th-Assistant") - self.assertEqual(defs[0]["dungeons"], ["无", "铁骸的锈冢"]) - mock_ext.assert_not_called() # 无 dungeons_source 不读外部 - - def test_external_source_filled_when_reachable(self): - """dungeons_source=assets/config/instance_names.json 且外部可读 → 用外部副本清单填充。""" - self._write( - { - "March7th-Assistant": [ - { - "name": "历战余响", - "dungeons_source": "assets/config/instance_names.json", - } - ] - } - ) - names = ["无", "铁骸的锈冢", "晨昏的回眸"] - with patch( - "src.service.script_service.get_dungeon_lists", return_value=names - ) as mock_ext: - defs = ScriptService().get_weekly_defs("March7th-Assistant") - mock_ext.assert_called_once_with( - "March7th-Assistant", "历战余响", "assets/config/instance_names.json" - ) - self.assertEqual(defs[0]["dungeons"], names) - self.assertTrue(defs[0]["dungeons"]) # 供 GUI 推导 has_dungeon - - def test_external_source_empty_when_unreachable(self): - """外部读不到(返回 None)→ 降级 dungeons=[],该周常无需选副本。""" - self._write( - { - "March7th-Assistant": [ - { - "name": "历战余响", - "dungeons_source": "assets/config/instance_names.json", - } - ] - } - ) - with patch("src.service.script_service.get_dungeon_lists", return_value=None): - defs = ScriptService().get_weekly_defs("March7th-Assistant") - self.assertEqual(defs[0]["dungeons"], []) - - def test_unknown_script_returns_empty(self): - """未知脚本 → get_weekly_defs 返回空列表(不抛错、不读外部)。""" - self._write({"March7th-Assistant": [{"name": "货币战争"}]}) - self.assertEqual(ScriptService().get_weekly_defs("不存在"), []) - - -class TestGetDungeonMap(unittest.TestCase): - """get_dungeon_map:静态 sequences 保持,dungeons_source 运行期从外部读取/降级。""" - - def test_static_sequences_untouched(self): - """带 dungeons(无 dungeons_source)的项保持原样,不触发外部读取。""" - raw = { - "ok-ef": { - "dungeons": [ - { - "name": "干员养成", - "sequences": [{"display": "干员经验", "value": "干员经验"}], - } - ] - } - } - with ( - patch("src.service.script_service.load_dungeon_map", return_value=raw), - patch("src.service.script_service.get_dungeon_lists") as mock_ext, - ): - result = ScriptService().get_dungeon_map() - self.assertEqual( - result["ok-ef"]["dungeons"][0]["sequences"], - [{"display": "干员经验", "value": "干员经验"}], - ) - mock_ext.assert_not_called() # 无 dungeons_source 不读外部 - - def test_fills_sequences_from_dungeons_source(self): - """带 dungeons_source 的声明项,其二级序列由 get_dungeon_lists 运行期填充。""" - raw = { - "ok-ef": { - "dungeons": [ - {"name": "培养目标"}, - { - "name": "能量淤积点", - "dungeons_source": "data/apps/ok-ef/working/assets/data/world_map.json", - }, - ] - } - } - with ( - patch("src.service.script_service.load_dungeon_map", return_value=raw), - patch( - "src.service.script_service.get_dungeon_lists", - return_value=["枢纽区", "武陵城"], - ) as mock_ext, - ): - result = ScriptService().get_dungeon_map() - # 培养目标(无 dungeons_source)保持无序列 - self.assertEqual(result["ok-ef"]["dungeons"][0].get("sequences"), None) - # 带 dungeons_source 的项被填充为 {display,value} 序列 - seqs = result["ok-ef"]["dungeons"][1]["sequences"] - self.assertEqual( - seqs, - [ - {"display": "枢纽区", "value": "枢纽区"}, - {"display": "武陵城", "value": "武陵城"}, - ], - ) - mock_ext.assert_called_once_with( - "ok-ef", "能量淤积点", "data/apps/ok-ef/working/assets/data/world_map.json" - ) - - def test_dungeons_source_unreachable_degrades_to_empty(self): - """dungeons_source 读不到(get_dungeon_lists 返回 [])→ 降级为空序列。""" - raw = { - "ok-ef": { - "dungeons": [ - { - "name": "能量淤积点", - "dungeons_source": "data/apps/ok-ef/working/assets/data/world_map.json", - } - ] - } - } - with ( - patch("src.service.script_service.load_dungeon_map", return_value=raw), - patch("src.service.script_service.get_dungeon_lists", return_value=[]), - ): - result = ScriptService().get_dungeon_map() - self.assertEqual(result["ok-ef"]["dungeons"][0]["sequences"], []) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_service_state.py b/tests/test_service_state.py deleted file mode 100644 index 5680f26..0000000 --- a/tests/test_service_state.py +++ /dev/null @@ -1,74 +0,0 @@ -"""测试 ChainService 的 UI 状态持久化(gui_state.json 读写,原 src/service/state.py)。""" - -import json -import unittest -from io import StringIO -from unittest.mock import MagicMock, patch - -from src.service.chain_service import ChainService - - -class TestLoadUiState(unittest.TestCase): - """测试 ChainService.load_ui_state""" - - def test_returns_empty_when_file_not_exists(self): - """文件不存在时返回空 dict""" - with patch("src.service.chain_service.os.path.exists", return_value=False): - result = ChainService().load_ui_state() - self.assertEqual(result, {}) - - def test_loads_valid_json(self): - """正常 JSON 文件正确读取""" - data = {"鸣潮": {"dungeon": "朔雷之鳞", "sequence": 2}} - with ( - patch("src.service.chain_service.os.path.exists", return_value=True), - patch("builtins.open", mock_open_with_data(data)), - ): - result = ChainService().load_ui_state() - self.assertEqual(result, data) - - -class TestSaveUiState(unittest.TestCase): - """测试 ChainService.save_ui_state""" - - def test_writes_json_file(self): - """正常写入 JSON""" - captured = {} - - def fake_open(file, mode, encoding=None): - buf = StringIO() - captured["buf"] = buf - captured["mode"] = mode - m = MagicMock() - m.__enter__ = MagicMock(return_value=buf) - m.__exit__ = MagicMock(return_value=False) - return m - - ui_state = {"鸣潮": {"dungeon": "A", "sequence": 1}} - with patch("builtins.open", side_effect=fake_open): - ChainService().save_ui_state(ui_state) - - written = json.loads(captured["buf"].getvalue()) - self.assertEqual(written, ui_state) - self.assertEqual(captured["mode"], "w") - - -# ---- helpers ---- - - -def mock_open_with_data(data): - """返回一个 mock open,读取时返回 JSON 序列化的 data""" - raw = json.dumps(data, ensure_ascii=False) - - def fake_open(file, mode="r", encoding=None): - buf = StringIO(raw) - m = MagicMock() - m.__enter__ = MagicMock(return_value=buf) - m.__exit__ = MagicMock(return_value=False) - return m - - return fake_open - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_set_config.py b/tests/test_set_config.py index 1a8fb8b..dfdccca 100644 --- a/tests/test_set_config.py +++ b/tests/test_set_config.py @@ -4,7 +4,7 @@ 覆盖函数: - _CONFIGS(子类路径声明完整性) - _get_script_root_dir - - get_config_path + - get_sub_config_path - load_config - save_config(mock 文件写入,不真正写回脚本 config) """ @@ -15,7 +15,8 @@ import unittest from unittest.mock import mock_open, patch -from src.config import set_config, subscript +from src import utils_sub_config +from src.config import set_config from src.utils import safe_path_join from src.utils_yaml import dump_yaml_str, load_yaml_str @@ -116,10 +117,12 @@ def test_returns_dirname_of_script_path(self): # _get_script_root_dir 内部会统一为正斜杠,期望值也要一致 expected_root = os.path.dirname(fake_path.replace("\\", "/")) with ( - patch.object(subscript, "_load_config_yml", return_value=fake_config), + patch.object( + utils_sub_config, "_load_config_yml", return_value=fake_config + ), patch("os.path.exists", return_value=True), ): - root = subscript._get_script_root_dir("ok-ww") + root = utils_sub_config._get_script_root_dir("ok-ww") self.assertEqual(root, expected_root) def test_handles_windows_path_on_any_platform(self): @@ -133,20 +136,24 @@ def test_handles_windows_path_on_any_platform(self): ] } with ( - patch.object(subscript, "_load_config_yml", return_value=fake_config), + patch.object( + utils_sub_config, "_load_config_yml", return_value=fake_config + ), patch("os.path.exists", return_value=True), ): - root = subscript._get_script_root_dir("ok-ww") + root = utils_sub_config._get_script_root_dir("ok-ww") self.assertEqual(root, "C:/Users/test/ok-ww") def test_raises_for_unknown_script(self): """未在 config.yml 中的脚本应触发 AssertionError""" fake_config = {"script_list": []} with ( - patch.object(subscript, "_load_config_yml", return_value=fake_config), + patch.object( + utils_sub_config, "_load_config_yml", return_value=fake_config + ), self.assertRaises(AssertionError), ): - subscript._get_script_root_dir("none") + utils_sub_config._get_script_root_dir("none") def test_raises_for_empty_script_path(self): """script_path 为空时应触发 AssertionError""" @@ -156,14 +163,16 @@ def test_raises_for_empty_script_path(self): ] } with ( - patch.object(subscript, "_load_config_yml", return_value=fake_config), + patch.object( + utils_sub_config, "_load_config_yml", return_value=fake_config + ), self.assertRaises(AssertionError), ): - subscript._get_script_root_dir("empty") + utils_sub_config._get_script_root_dir("empty") class TestGetConfigPath(unittest.TestCase): - """测试 get_config_path""" + """测试 get_sub_config_path""" def test_joins_root_and_rel(self): """应正确拼接脚本根目录和 config 相对路径""" @@ -175,12 +184,14 @@ def test_joins_root_and_rel(self): } rel = "data/apps/ok-ww/working/configs/DailyTask.json" with ( - patch.object(subscript, "_load_config_yml", return_value=fake_config), + patch.object( + utils_sub_config, "_load_config_yml", return_value=fake_config + ), patch("os.path.exists", return_value=True), ): - path = subscript.get_config_path("ok-ww", rel) + path = utils_sub_config.get_sub_config_path("ok-ww", rel) - # get_config_path 内部用 safe_path_join,会归一化为绝对路径(Windows 为反斜杠), + # get_sub_config_path 内部用 safe_path_join,会归一化为绝对路径(Windows 为反斜杠), # 故 expected 需用同一归一化方式,避免分隔符不一致导致断言失败。 expected = safe_path_join("C:/fake/ok-ww", rel) self.assertEqual(path, expected) @@ -189,11 +200,11 @@ def test_raises_for_unknown_script(self): """config.yml 中无此脚本应触发 AssertionError""" with ( patch.object( - subscript, "_load_config_yml", return_value={"script_list": []} + utils_sub_config, "_load_config_yml", return_value={"script_list": []} ), self.assertRaises(AssertionError), ): - subscript.get_config_path("none", "whatever.json") + utils_sub_config.get_sub_config_path("none", "whatever.json") def test_all_registered_scripts_resolve_with_mock_config(self): """对所有已注册脚本,用 mock 的 config.yml 验证路径推导成功 @@ -209,7 +220,7 @@ def test_all_registered_scripts_resolve_with_mock_config(self): ] with ( patch.object( - subscript, + utils_sub_config, "_load_config_yml", return_value={"script_list": fake_script_list}, ), @@ -217,7 +228,7 @@ def test_all_registered_scripts_resolve_with_mock_config(self): ): for name in scripts: rel = set_config._CONFIGS[name]._config_rel_path - path = subscript.get_config_path(name, rel) + path = utils_sub_config.get_sub_config_path(name, rel) self.assertIsNotNone(path, f"{name} 路径推导失败") # 路径中应包含相对路径的各段(不依赖具体分隔符) rel_parts = rel.split("/") @@ -227,7 +238,7 @@ def test_all_registered_scripts_resolve_with_mock_config(self): ) def test_does_not_require_exe_to_exist(self): - """回归:get_config_path 不应校验游戏 exe 是否存在。 + """回归:get_sub_config_path 不应校验游戏 exe 是否存在。 旧实现经 _get_script_root_dir → get_script_path 断言 exe 存在, 当用户正要修正失效的旧路径时,任何保存(含周起始日同步)都会崩溃。 @@ -243,20 +254,22 @@ def test_does_not_require_exe_to_exist(self): } rel = set_config.StarRailConfig._config_rel_path with ( - patch.object(subscript, "_load_config_yml", return_value=fake_config), + patch.object( + utils_sub_config, "_load_config_yml", return_value=fake_config + ), patch("os.path.exists", return_value=False), # 模拟 exe 不存在 ): # 旧实现此处会因 get_script_path 的 assert os.path.exists(exe) 崩溃; # 新实现应正常返回路径(不依赖 exe 是否存在)。 - path = subscript.get_config_path("March7th-Assistant", rel) + path = utils_sub_config.get_sub_config_path("March7th-Assistant", rel) self.assertIn("config.yaml", path) class TestStarRailWeeklyStartDayRobustness(unittest.TestCase): """回归:崩铁 set_weekly_start_day 的读路径不应因 exe 路径失效而崩溃(soft 解析)。 - 旧实现 get_config_path → get_script_path 断言 exe 存在;用户正要修正失效的旧路径时 - 保存即崩。修复后 get_config_path 用 soft 解析(不校验 exe),读路径不再因路径失效 + 旧实现 get_sub_config_path → get_script_path 断言 exe 存在;用户正要修正失效的旧路径时 + 保存即崩。修复后 get_sub_config_path 用 soft 解析(不校验 exe),读路径不再因路径失效 而断言。写游戏侧 config 视为前置条件(游戏已安装、路径有效,由 GUI 保证),不再做 存在性兜底盘;非法周起始日仍由 assert 拦截。 """ @@ -271,7 +284,7 @@ def test_invalid_day_still_asserted(self): class TestArknightsWeeklyStartDayRobustness(unittest.TestCase): """回归:MAA(明日方舟) set_weekly_start_day 的读路径不再因 exe 路径失效而崩溃。 - 与 TestStarRailWeeklyStartDayRobustness 同源修复(get_config_path soft 解析)。 + 与 TestStarRailWeeklyStartDayRobustness 同源修复(get_sub_config_path soft 解析)。 写游戏侧 config 视为前置条件(游戏已安装、路径有效,由 GUI 保证),原生 config 缺失即断言失败,不再 best-effort 跳过;非法周起始日仍由 assert 拦截。 """ @@ -291,11 +304,11 @@ def test_load_json_config(self): fake_path = r"C:\fake\script\config.json" with ( - patch.object(subscript, "get_config_path", return_value=fake_path), + patch.object(utils_sub_config, "get_sub_config_path", return_value=fake_path), patch("os.path.exists", return_value=True), patch("builtins.open", mock_open(read_data=json.dumps(fake_data))), ): - result = subscript.load_config("ok-ww", "DailyTask.json") + result = utils_sub_config.load_config("ok-ww", "DailyTask.json") self.assertEqual(result, fake_data) @@ -306,11 +319,13 @@ def test_load_yaml_config(self): yaml_str = dump_yaml_str(fake_data) with ( - patch.object(subscript, "get_config_path", return_value=fake_path), + patch.object(utils_sub_config, "get_sub_config_path", return_value=fake_path), patch("os.path.exists", return_value=True), patch("builtins.open", mock_open(read_data=yaml_str)), ): - result = subscript.load_config("OneDragon-Launcher", "charge_plan.yml") + result = utils_sub_config.load_config( + "OneDragon-Launcher", "charge_plan.yml" + ) self.assertEqual(result, fake_data) @@ -339,12 +354,12 @@ def test_load_all_registered_configs_with_mock(self): with ( patch.object( - subscript, "_load_config_yml", return_value=fake_config_yml + utils_sub_config, "_load_config_yml", return_value=fake_config_yml ), patch("os.path.exists", return_value=True), patch("builtins.open", mock_open(read_data=file_content)), ): - result = subscript.load_config(name, rel) + result = utils_sub_config.load_config(name, rel) self.assertIsNotNone(result, f"{name} config 读取失败") self.assertEqual(result, fake_data, f"{name} config 读取内容不匹配") @@ -360,10 +375,10 @@ def test_save_json_config_does_not_write_real_file(self): m = mock_open() with ( - patch.object(subscript, "get_config_path", return_value=fake_path), + patch.object(utils_sub_config, "get_sub_config_path", return_value=fake_path), patch("builtins.open", m), ): - result = subscript.save_config("ok-ww", "DailyTask.json", data) + result = utils_sub_config.save_config("ok-ww", "DailyTask.json", data) self.assertIsNone(result) m.assert_called_once_with(fake_path, "w", encoding="utf-8") @@ -379,10 +394,10 @@ def test_save_yaml_config_does_not_write_real_file(self): m = mock_open() with ( - patch.object(subscript, "get_config_path", return_value=fake_path), + patch.object(utils_sub_config, "get_sub_config_path", return_value=fake_path), patch("builtins.open", m), ): - result = subscript.save_config( + result = utils_sub_config.save_config( "OneDragon-Launcher", "charge_plan.yml", data ) @@ -394,12 +409,12 @@ def test_save_yaml_config_does_not_write_real_file(self): self.assertEqual(load_yaml_str(written), data) def test_save_raises_when_path_is_none(self): - """get_config_path 返回 None 时应抛出异常""" + """get_sub_config_path 返回 None 时应抛出异常""" with ( - patch.object(subscript, "get_config_path", return_value=None), + patch.object(utils_sub_config, "get_sub_config_path", return_value=None), self.assertRaises((TypeError, AssertionError)), ): - subscript.save_config("none", "whatever.json", {"key": "val"}) + utils_sub_config.save_config("none", "whatever.json", {"key": "val"}) def test_save_and_reload_roundtrip_json(self): """JSON 数据 save 后 load 回来应一致(用 tempdir 替代真实路径)""" @@ -407,12 +422,14 @@ def test_save_and_reload_roundtrip_json(self): with tempfile.TemporaryDirectory() as tmp: fake_path = os.path.join(tmp, "config.json") - with patch.object(subscript, "get_config_path", return_value=fake_path): + with patch.object( + utils_sub_config, "get_sub_config_path", return_value=fake_path + ): # save - ok = subscript.save_config("ok-ww", "DailyTask.json", data) + ok = utils_sub_config.save_config("ok-ww", "DailyTask.json", data) self.assertIsNone(ok) # load - loaded = subscript.load_config("ok-ww", "DailyTask.json") + loaded = utils_sub_config.load_config("ok-ww", "DailyTask.json") self.assertEqual(loaded, data) def test_save_and_reload_roundtrip_yaml(self): @@ -421,14 +438,18 @@ def test_save_and_reload_roundtrip_yaml(self): with tempfile.TemporaryDirectory() as tmp: fake_path = os.path.join(tmp, "config.yaml") - with patch.object(subscript, "get_config_path", return_value=fake_path): + with patch.object( + utils_sub_config, "get_sub_config_path", return_value=fake_path + ): # save - ok = subscript.save_config( + ok = utils_sub_config.save_config( "OneDragon-Launcher", "charge_plan.yml", data ) self.assertIsNone(ok) # load - loaded = subscript.load_config("OneDragon-Launcher", "charge_plan.yml") + loaded = utils_sub_config.load_config( + "OneDragon-Launcher", "charge_plan.yml" + ) self.assertEqual(loaded, data) diff --git a/tests/test_set_config_readback.py b/tests/test_set_config_readback.py index aa9a945..a478507 100644 --- a/tests/test_set_config_readback.py +++ b/tests/test_set_config_readback.py @@ -105,7 +105,8 @@ def test_anomaly_roundtrip(self): self.assertEqual(cfg._read_dungeon()[0], "异能升级材料") self.assertEqual(cfg._read_dungeon()[1], "3") - def test_hunter_readback_via_routine(self): + def test_hunter_readback_without_boss(self): + """启用追猎目标但尚未选 boss:模式由 routine 判定,boss 为 None(容忍未配置)。""" config = {"daily_anomaly": {}} routine = { "Routine Items": [ @@ -123,7 +124,7 @@ def test_hunter_readback_via_routine(self): patch.object(set_config_mod, "safe_update", _setter), ): cfg = NTEConfig() - self.assertEqual(cfg._read_dungeon()[0], "追猎目标") + self.assertEqual(cfg._read_dungeon(), ("追猎目标", None)) def test_hunter_readback_ignores_stale_anomaly_task_type(self): """从异象界域切到追猎目标后,daily_anomaly.任务类型 仍残留陈旧值, @@ -131,13 +132,14 @@ def test_hunter_readback_ignores_stale_anomaly_task_type(self): """ config = { "daily_anomaly": {"任务类型": "空幕", "空幕序号": 6}, + # boss 随副本/序列写入 **config 文件**(_update_task 落点),非 routine 文件 + "daily_anomaly_hunter": {"追猎目标": "黑之书"}, } routine = { "Routine Items": [ {"id": "daily_anomaly", "enabled": False}, {"id": "daily_anomaly_hunter", "enabled": True}, ], - "daily_anomaly_hunter": {"追猎目标": "黑之书"}, } with ( patch.object( @@ -153,6 +155,35 @@ def test_hunter_readback_ignores_stale_anomaly_task_type(self): self.assertEqual(cfg._read_dungeon()[0], "追猎目标") self.assertEqual(cfg._read_dungeon()[1], "黑之书") + def test_hunter_boss_roundtrip_through_config(self): + """回归:boss 名写入 config 文件的 daily_anomaly_hunter 段,读取须同文件取回。 + + 写入侧 _update_task 经 _daily_section_dict 落点 config 文件,读取须从同文件取回, + 否则追猎目标模式下二级副本名恒为 None(chip 只显示「追猎目标」)。 + """ + config = { + "daily_anomaly": {"任务类型": "", "异能材料序号": ""}, + "daily_anomaly_hunter": {"目标消耗体力": 240}, + } + routine = { + "Routine Items": [ + {"id": "daily_anomaly", "enabled": False}, + {"id": "daily_anomaly_hunter", "enabled": True}, + ], + } + with ( + patch.object( + NTEConfig, + "_load", + side_effect=lambda p=None, **_k: config if p is None else routine, + ), + patch.object(NTEConfig, "_save"), + patch.object(set_config_mod, "safe_update", _setter), + ): + cfg = NTEConfig() + cfg.set_dungeon("追猎目标", "音霸魔王") + self.assertEqual(cfg._read_dungeon(), ("追猎目标", "音霸魔王")) + class TestReadbackMAA(unittest.TestCase): def test_dungeon_roundtrip(self): @@ -293,7 +324,7 @@ def test_facade_noop_scripts_return_none(self): class TestReadbackCorruption(unittest.TestCase): - """损坏数据应 assert 暴露,而非静默返回 None(否则被 gui_state 兜底掩盖)。""" + """损坏数据应 assert 暴露,而非静默返回 None(否则被日常副本的声明项回退掩盖)。""" def test_unknown_task_value_raises(self): config = {"Which to Farm": "未知副本值"} diff --git a/tests/test_state.py b/tests/test_state.py index 2905699..99a2f93 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -3,8 +3,8 @@ import unittest from unittest.mock import patch -from src.config.subscript import DEFAULT_RUN_TIMEOUT from src.service.chain_gen import _resolve_daily_run, resolve_weekly_start +from src.utils_sub_config import DEFAULT_RUN_TIMEOUT def _script(display_name="测试"): diff --git a/tests/test_utils_config.py b/tests/test_utils_config.py new file mode 100644 index 0000000..9858076 --- /dev/null +++ b/tests/test_utils_config.py @@ -0,0 +1,275 @@ +"""测试 src/utils_config.py:config.yml 读写与单脚本条目查询(模块函数)。 + +周常运行期参数(weekly_start.yml / weekly_timeouts.yml)的读写已抽为 src/utils_weekly.py 模块函数, +其测试见 test_utils_weekly.py;本文件只测 config.yml 与单脚本查询/路径解析。 +""" + +import os +import tempfile +import unittest +from unittest.mock import patch + +from src.utils_config import ( + add_script, + build_script_entry, + config_file_path, + get_script, + load_config, + remove_script, + save_config, +) +from src.utils_yaml import dump_yaml_file, load_yaml + + +class UtilsConfigTestBase(unittest.TestCase): + """用临时 config.yml 隔离真实文件(weekly 路径已归 utils_weekly 自管)。""" + + def setUp(self): + self.tmp_dir = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp_dir.cleanup) + self.config_path = os.path.join(self.tmp_dir.name, "config.yml") + self.weekly_list_path = os.path.join(self.tmp_dir.name, "weekly_list.yml") + self._write_config( + {"script_list": [{"display_name": "原神", "script_path": "C:/a.exe"}]} + ) + patchers = [ + patch( + "src.utils_config.require_config_yml_path", + return_value=self.config_path, + ), + patch( + "src.config.dungeon_config.get_weekly_list_yml_path_under_root", + return_value=self.weekly_list_path, + ), + ] + for p in patchers: + p.start() + self.addCleanup(p.stop) + + def _write_config(self, data): + dump_yaml_file(self.config_path, data) + + def _read_config(self): + return load_yaml(self.config_path) + + +class TestGetScript(UtilsConfigTestBase): + def test_get_existing_script(self): + s = get_script("a") + self.assertEqual(s, {"display_name": "原神", "script_path": "C:/a.exe"}) + + def test_get_missing_script_returns_none(self): + self.assertIsNone(get_script("none")) + + +class TestBuildScriptEntry(unittest.TestCase): + """build_script_entry:文件名去重命名 + 类型推断 + 默认字段。""" + + def test_python_type_inferred(self): + entry = build_script_entry("C:/foo/bar.py", set()) + self.assertEqual(entry["script_type"], "python") + self.assertEqual(entry["display_name"], "bar") + + def test_external_type_inferred(self): + entry = build_script_entry("C:/foo/bar.exe", set()) + self.assertEqual(entry["script_type"], "external") + self.assertEqual(entry["display_name"], "bar") + + def test_name_deduplicated_with_suffix(self): + entry = build_script_entry("C:/foo/bar.exe", {"bar"}) + self.assertEqual(entry["display_name"], "bar_1") + + def test_name_dedup_keeps_incrementing(self): + entry = build_script_entry("C:/foo/bar.exe", {"bar", "bar_1"}) + self.assertEqual(entry["display_name"], "bar_2") + + +class TestConfigFilePath(UtilsConfigTestBase): + """测试 config_file_path:python/external 分支与缺失处理。""" + + def _setup_script(self, display_name, script_type, script_path): + self._write_config( + { + "script_list": [ + { + "display_name": display_name, + "script_type": script_type, + "script_path": script_path, + } + ] + } + ) + + def test_missing_script_returns_error(self): + self._setup_script("原神", "external", "C:/a.exe") + path, error = config_file_path("none") + self.assertIsNone(path) + self.assertIn("找不到脚本", error) + + def test_external_adapted_returns_config_path(self): + self._setup_script("原神", "external", "C:/a.exe") + with ( + patch( + "src.utils_config.get_config_path", + return_value="C:/config/DailyTask.json", + ), + patch("src.utils_config.os.path.isfile", return_value=True), + ): + path, error = config_file_path("a") + self.assertEqual(path, "C:/config/DailyTask.json") + self.assertIsNone(error) + + def test_external_unadapted_returns_error(self): + self._setup_script("原神", "external", "C:/a.exe") + with patch( + "src.utils_config.get_config_path", + side_effect=AssertionError("未适配脚本: 原神"), + ): + path, error = config_file_path("a") + self.assertIsNone(path) + self.assertIn("暂未适配", error) + + def test_python_resolved_returns_py_path(self): + self._setup_script("静音", "python", "C:/proj/mute.py") + with ( + patch( + "src.utils_config.resolve_script_path", + return_value="C:/proj/mute.py", + ), + patch("src.utils_config.os.path.isfile", return_value=True), + ): + path, error = config_file_path("静音") + self.assertEqual(path, "C:/proj/mute.py") + self.assertIsNone(error) + + def test_python_missing_file_returns_error(self): + self._setup_script("静音", "python", "C:/nope/mute.py") + with ( + patch( + "src.utils_config.resolve_script_path", + return_value="C:/nope/mute.py", + ), + patch("src.utils_config.os.path.isfile", return_value=False), + ): + path, error = config_file_path("静音") + self.assertIsNone(path) + self.assertIn("找不到脚本文件", error) + + +class TestLoadSaveConfig(unittest.TestCase): + """config.yml 读写(utils_config 实现):结构断言(用临时文件,不碰真实 config)""" + + def setUp(self): + self.tmp_dir = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp_dir.cleanup) + self.config_path = os.path.join(self.tmp_dir.name, "config.yml") + + def test_load_config_reads_yaml(self): + fake_data = { + "script_list": [ + {"display_name": "测试", "script_path": "C:/x.exe"}, + ] + } + dump_yaml_file(self.config_path, fake_data) + with patch( + "src.utils_config.require_config_yml_path", + return_value=self.config_path, + ): + data = load_config() + self.assertEqual(data, fake_data) + + def test_load_config_asserts_script_list(self): + dump_yaml_file(self.config_path, {"a": 1}) + with ( + patch( + "src.utils_config.require_config_yml_path", + return_value=self.config_path, + ), + self.assertRaises(AssertionError), + ): + load_config() + + def test_save_config_writes_yaml(self): + with patch( + "src.utils_config.get_config_yml_path_under_root", + return_value=self.config_path, + ): + save_config({"script_list": [{"display_name": "测试"}]}) + saved = load_yaml(self.config_path) + self.assertEqual(saved["script_list"][0]["display_name"], "测试") + + def test_save_config_asserts_script_list(self): + with self.assertRaises(AssertionError): + save_config({"a": 1}) + + +class TestAddRemoveScript(unittest.TestCase): + """add_script / remove_script:操作 config.yml 并协作 utils_weekly 同步 weekly。""" + + def setUp(self): + self.tmp_dir = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp_dir.cleanup) + self.config_path = os.path.join(self.tmp_dir.name, "config.yml") + dump_yaml_file( + self.config_path, + {"script_list": [{"display_name": "原神", "script_path": "C:/a.exe"}]}, + ) + + def _read(self): + return load_yaml(self.config_path) + + def test_add_script_appends(self): + """add_script 在 script_list 末尾追加条目、落盘,并协作 utils_weekly 建默认条目。""" + with ( + patch( + "src.utils_config.require_config_yml_path", + return_value=self.config_path, + ), + patch( + "src.utils_config.get_config_yml_path_under_root", + return_value=self.config_path, + ), + patch("src.utils_config.ensure_weekly_entry") as mock_ensure, + patch("src.utils_config.init_config") as mock_init, + ): + add_script({"display_name": "鸣潮", "script_path": "C:/b.exe"}) + names = [s["display_name"] for s in self._read()["script_list"]] + self.assertEqual(names, ["原神", "鸣潮"]) + mock_ensure.assert_called_once_with("b") + mock_init.assert_called_once_with("b") + + def test_remove_script_removes(self): + """remove_script 从 script_list 移除指定进程条目、落盘,并协作清理 weekly 孤儿。""" + with ( + patch( + "src.utils_config.require_config_yml_path", + return_value=self.config_path, + ), + patch( + "src.utils_config.get_config_yml_path_under_root", + return_value=self.config_path, + ), + patch("src.utils_config.delete_weekly") as mock_del, + ): + remove_script("a") + self.assertEqual(self._read()["script_list"], []) + mock_del.assert_called_once_with("a") + + def test_remove_script_missing_raises(self): + """remove_script 移除不存在的脚本属非法调用:assert 表达不该发生""" + with ( + patch( + "src.utils_config.require_config_yml_path", + return_value=self.config_path, + ), + patch( + "src.utils_config.get_config_yml_path_under_root", + return_value=self.config_path, + ), + self.assertRaises(AssertionError), + ): + remove_script("不存在") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_utils_shutdown.py b/tests/test_utils_shutdown.py index 95864c7..dd7ff69 100644 --- a/tests/test_utils_shutdown.py +++ b/tests/test_utils_shutdown.py @@ -1,33 +1,14 @@ -"""测试 src/utils_shutdown.py:关机确认窗与 shutdown 命令编排。 +"""测试 src/utils_shutdown.py:关机命令编排与确认分支(纯逻辑,不加载 Qt)。 -确认窗为进程内 PySide6 弹窗,UI 测试在 offscreen 平台下运行(CI 无显示器)。 +确认窗的 GUI 实现位于 ``src/gui/shutdown_dialog.py``,其测试见 +``test_gui_shutdown_dialog.py``;本文件只测「确认后执行 shutdown」的编排逻辑。 """ -import os import sys import unittest from unittest import mock -# 在导入 PySide6 之前设置 offscreen 平台插件(CI 无显示器环境) -os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") - -from PySide6.QtWidgets import QApplication, QDialog, QPushButton - -from src.utils_shutdown import ( - ShutdownConfirmDialog, - _confirm_shutdown, - _run_shutdown_command, - shutdown_sys, -) - -# 模块级 QApplication 单例:widget 需要 GUI 应用,进程退出时随解释器销毁。 -if QApplication.instance() is None: - _APP = QApplication([]) - - -def _button(dialog: QDialog, text: str) -> QPushButton: - """按文字取弹窗按钮(按钮由 FormDialogBase._make_footer 构造,无公开引用)。""" - return next(b for b in dialog.findChildren(QPushButton) if b.text() == text) +from src.utils_shutdown import _run_shutdown_command, shutdown_sys class TestShutdownSys(unittest.TestCase): @@ -89,85 +70,5 @@ def test_nonzero_exit_logs_error(self): self.assertIn("拒绝访问", "\n".join(logs.output)) -class TestConfirmShutdown(unittest.TestCase): - """_confirm_shutdown:弹窗结果映射与 Qt 初始化失败的降级。""" - - def test_accepted_returns_true(self): - with mock.patch.object( - ShutdownConfirmDialog, - "exec", - return_value=QDialog.DialogCode.Accepted, - ): - self.assertTrue(_confirm_shutdown(30)) - - def test_rejected_returns_false(self): - with mock.patch.object( - ShutdownConfirmDialog, - "exec", - return_value=QDialog.DialogCode.Rejected, - ): - self.assertFalse(_confirm_shutdown(30)) - - def test_qt_init_failure_returns_false(self): - """Qt 初始化失败(无桌面):记诊断并按取消处理,不静默吞掉。 - - ``QApplication.instance()`` 返回 None 才会走到创建分支,故 Mock 需让 - ``instance`` 返回 None、构造调用抛 RuntimeError,与真实无桌面环境一致。 - """ - fake_app = mock.Mock() - fake_app.instance.return_value = None - fake_app.side_effect = RuntimeError("no display") - with ( - mock.patch("src.utils_shutdown.QApplication", fake_app), - self.assertLogs("src.utils_shutdown", level="ERROR") as logs, - ): - self.assertFalse(_confirm_shutdown(30)) - self.assertIn("RuntimeError", "\n".join(logs.output)) - - -class TestShutdownConfirmDialog(unittest.TestCase): - """ShutdownConfirmDialog:倒计时文案 / 归零接受 / 按钮与定时器生命周期。""" - - def test_initial_label_shows_countdown(self): - dlg = ShutdownConfirmDialog(45) - self.assertEqual(dlg._label.text(), "系统将在 45 秒后关机") - - def test_tick_decrements(self): - dlg = ShutdownConfirmDialog(45) - dlg._tick() - self.assertEqual(dlg._remain, 44) - self.assertEqual(dlg._label.text(), "系统将在 44 秒后关机") - - def test_tick_to_zero_accepts(self): - """倒计时归零即接受(关机),并停表。""" - dlg = ShutdownConfirmDialog(2) - dlg.show() - dlg._tick() - self.assertEqual(dlg.result(), QDialog.DialogCode.Rejected) # 未归零:尚未接受 - dlg._tick() - self.assertEqual(dlg.result(), QDialog.DialogCode.Accepted) - self.assertFalse(dlg._timer.isActive()) - dlg.close() - - def test_confirm_button_accepts(self): - dlg = ShutdownConfirmDialog(45) - _button(dlg, "立即关机").click() - self.assertEqual(dlg.result(), QDialog.DialogCode.Accepted) - - def test_cancel_button_rejects(self): - dlg = ShutdownConfirmDialog(45) - _button(dlg, "取消").click() - self.assertEqual(dlg.result(), QDialog.DialogCode.Rejected) - - def test_timer_starts_on_show_and_stops_on_hide(self): - """显示才起倒计时(模态 exec 前不流逝),关闭即停表。""" - dlg = ShutdownConfirmDialog(45) - self.assertFalse(dlg._timer.isActive()) - dlg.show() - self.assertTrue(dlg._timer.isActive()) - dlg.close() - self.assertFalse(dlg._timer.isActive()) - - if __name__ == "__main__": unittest.main() diff --git a/tests/test_subscript.py b/tests/test_utils_sub_config.py similarity index 90% rename from tests/test_subscript.py rename to tests/test_utils_sub_config.py index 805309f..c08b0aa 100644 --- a/tests/test_subscript.py +++ b/tests/test_utils_sub_config.py @@ -1,9 +1,10 @@ -"""测试 src/config/subscript.py:脚本唯一标识、路径解析、脚本路径读取、默认条目构造""" +"""测试 src/utils_sub_config.py:脚本唯一标识、路径解析、脚本路径读取、默认条目构造""" import unittest from unittest import mock -from src.config.subscript import ( +from src.utils import get_root_dir, safe_path_join +from src.utils_sub_config import ( check_script_name_uniqueness, default_script_entry, get_process_name, @@ -12,7 +13,6 @@ load_game_config, resolve_script_path, ) -from src.utils import get_root_dir, safe_path_join class TestGetProcessName(unittest.TestCase): @@ -120,8 +120,8 @@ def test_relative_script_path_resolved_to_root(self): ] } with ( - mock.patch("src.config.subscript._load_config_yml", return_value=fake), - mock.patch("src.config.subscript.os.path.exists", return_value=True), + mock.patch("src.utils_sub_config._load_config_yml", return_value=fake), + mock.patch("src.utils_sub_config.os.path.exists", return_value=True), ): got = get_script_path("自动关机") expected = safe_path_join(get_root_dir(), "scripts/shutdown.bat").replace( @@ -136,8 +136,8 @@ def test_absolute_script_path_preserved(self): ] } with ( - mock.patch("src.config.subscript._load_config_yml", return_value=fake), - mock.patch("src.config.subscript.os.path.exists", return_value=True), + mock.patch("src.utils_sub_config._load_config_yml", return_value=fake), + mock.patch("src.utils_sub_config.os.path.exists", return_value=True), ): got = get_script_path("BetterGI") self.assertEqual(got, "D:/games/BetterGI.exe") @@ -145,7 +145,7 @@ def test_absolute_script_path_preserved(self): def test_missing_script_raises(self): fake = {"script_list": []} with ( - mock.patch("src.config.subscript._load_config_yml", return_value=fake), + mock.patch("src.utils_sub_config._load_config_yml", return_value=fake), self.assertRaises(AssertionError), ): get_script_path("不存在") @@ -194,7 +194,7 @@ class TestLoadGameConfig(unittest.TestCase): def test_root_missing_returns_none(self): """config.yml 中无此进程(根目录解析失败)→ None""" with mock.patch( - "src.config.subscript.get_script_root_dir_soft", return_value=None + "src.utils_sub_config.get_script_root_dir_soft", return_value=None ): got = load_game_config( "ok-ww", "data/apps/ok-ww/working/configs/devices.json" @@ -205,10 +205,10 @@ def test_config_file_missing_returns_none(self): """游戏配置文件不存在 → None(不 assert)""" with ( mock.patch( - "src.config.subscript.get_script_root_dir_soft", + "src.utils_sub_config.get_script_root_dir_soft", return_value="C:/root", ), - mock.patch("src.config.subscript.os.path.exists", return_value=False), + mock.patch("src.utils_sub_config.os.path.exists", return_value=False), ): got = load_game_config( "ok-ww", "data/apps/ok-ww/working/configs/devices.json" @@ -220,12 +220,12 @@ def test_json_config_parsed(self): fake_path = "C:/root/data/apps/ok-ww/working/configs/devices.json" with ( mock.patch( - "src.config.subscript.get_script_root_dir_soft", + "src.utils_sub_config.get_script_root_dir_soft", return_value="C:/root", ), - mock.patch("src.config.subscript.os.path.exists", return_value=True), + mock.patch("src.utils_sub_config.os.path.exists", return_value=True), mock.patch( - "src.config.subscript.safe_path_join", + "src.utils_sub_config.safe_path_join", return_value=fake_path, ), mock.patch( @@ -243,12 +243,12 @@ def test_yaml_config_parsed(self): fake_path = "C:/root/config/01/game_account.yml" with ( mock.patch( - "src.config.subscript.get_script_root_dir_soft", + "src.utils_sub_config.get_script_root_dir_soft", return_value="C:/root", ), - mock.patch("src.config.subscript.os.path.exists", return_value=True), + mock.patch("src.utils_sub_config.os.path.exists", return_value=True), mock.patch( - "src.config.subscript.safe_path_join", + "src.utils_sub_config.safe_path_join", return_value=fake_path, ), mock.patch( diff --git a/tests/test_utils_weekly.py b/tests/test_utils_weekly.py new file mode 100644 index 0000000..c045105 --- /dev/null +++ b/tests/test_utils_weekly.py @@ -0,0 +1,234 @@ +"""测试 src/utils_weekly.py:周常起始日与每周超时的读写与迁移。 + +weekly_start.yml(周几起)与 weekly_timeouts.yml(每周 7 格超时)的读写由模块函数负责。 +**不含周本声明**——各游戏「有哪些周常、可选哪些副本」由 src.config.dungeon_config +模块函数读 weekly_list.yml 提供,见 test_dungeon_config.py。 +""" + +import os +import tempfile +import unittest +from unittest.mock import patch + +from src.utils_weekly import ( + check_weekly, + delete_weekly, + ensure_weekly_entry, + get_weekly_start, + rename_weekly_in_timeouts, + save_weekly, + set_weekly_start, + weekly_inputs, +) +from src.utils_yaml import dump_yaml_file, load_yaml + + +class UtilsWeeklyTestBase(unittest.TestCase): + """用临时 weekly_timeouts.yml / weekly_start.yml 隔离真实文件。""" + + def setUp(self): + self.tmp_dir = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp_dir.cleanup) + self.weekly_path = os.path.join(self.tmp_dir.name, "weekly_timeouts.yml") + self.weekly_start_path = os.path.join(self.tmp_dir.name, "weekly_start.yml") + # 两份配置均随包发布、必存在,默认建空 {} 文件贴近真实部署 + # (缺失→{} 的兜底已移除,改 assert 暴露)。 + self._write_weekly({}) + self._write_weekly_start({}) + patchers = [ + patch( + "src.utils_weekly.get_weekly_timeouts_yml_path_under_root", + return_value=self.weekly_path, + ), + patch( + "src.utils_weekly.get_weekly_start_yml_path_under_root", + return_value=self.weekly_start_path, + ), + ] + for p in patchers: + p.start() + self.addCleanup(p.stop) + + def _write_weekly(self, data): + dump_yaml_file(self.weekly_path, data) + + def _write_weekly_start(self, data): + dump_yaml_file(self.weekly_start_path, data) + + def _read_weekly(self): + if not os.path.exists(self.weekly_path): + return None + return load_yaml(self.weekly_path) + + def _read_weekly_start(self): + if not os.path.exists(self.weekly_start_path): + return None + return load_yaml(self.weekly_start_path) + + +class TestSaveWeekly(UtilsWeeklyTestBase): + """save_weekly:保存 7 格超时到 weekly_timeouts.yml。""" + + def test_save_weekly_writes_entry(self): + save_weekly("a", [60] * 7) + self.assertEqual(self._read_weekly()["a"], [60] * 7) + + def test_none_timeouts_resolved_to_default(self): + """空输入(None)→ 转默认超时。""" + save_weekly("a", [None, 60, None, 60, 60, 60, 60]) + self.assertEqual( + self._read_weekly()["a"], + [3600, 60, 3600, 60, 60, 60, 60], + ) + + def test_low_timeouts_preserved(self): + """低于 10 的输入原样保留(由 chain_gen 按「<10 当天不运行」跳过,不再 clamp)。""" + save_weekly("a", [5, 0, 60, 60, 60, 60, 60]) + self.assertEqual( + self._read_weekly()["a"], + [5, 0, 60, 60, 60, 60, 60], + ) + + +class TestRenameWeeklyInTimeouts(UtilsWeeklyTestBase): + """rename_weekly_in_timeouts:改名时迁移 weekly_timeouts.yml 条目。""" + + def test_rename_migrates_entry(self): + dump_yaml_file(self.weekly_path, {"a": [1] * 7}) + rename_weekly_in_timeouts("a", "b") + weekly = self._read_weekly() + self.assertNotIn("a", weekly) + self.assertEqual(weekly["b"], [1] * 7) + + def test_same_name_noop(self): + """同名的 rename 为 no-op,不影响已有 weekly 条目。""" + dump_yaml_file(self.weekly_path, {"a": [60] * 7}) + rename_weekly_in_timeouts("a", "a") + self.assertEqual(self._read_weekly()["a"], [60] * 7) + + def test_old_entry_missing_noop(self): + """无对应 weekly 条目 → no-op(不报错、不改文件,保持空 {})。""" + rename_weekly_in_timeouts("none", "b") + self.assertEqual(self._read_weekly(), {}) + + +class TestEnsureWeeklyEntry(UtilsWeeklyTestBase): + def test_creates_default_entry(self): + ensure_weekly_entry("a") + self.assertEqual(self._read_weekly()["a"], [3600] * 7) + + def test_existing_entry_untouched(self): + dump_yaml_file(self.weekly_path, {"a": [60] * 7}) + ensure_weekly_entry("a") + self.assertEqual(self._read_weekly()["a"], [60] * 7) + + +class TestWeeklyInputs(UtilsWeeklyTestBase): + def test_missing_entry_uses_default(self): + self.assertEqual(weekly_inputs("a"), [3600] * 7) + + def test_existing_entry_kept(self): + dump_yaml_file(self.weekly_path, {"a": [1, 2, 3, 4, 5, 6, 7]}) + self.assertEqual(weekly_inputs("a"), [1, 2, 3, 4, 5, 6, 7]) + + def test_short_entry_padded_with_default(self): + """不足 7 格 → 用默认超时补齐。""" + dump_yaml_file(self.weekly_path, {"a": [10, 20]}) + self.assertEqual( + weekly_inputs("a"), + [10, 20, 3600, 3600, 3600, 3600, 3600], + ) + + +class TestCheckWeekly(UtilsWeeklyTestBase): + """check_weekly:weekly_timeouts.yml 与传入 config 脚本条目的一致性。 + + config 由调用方(组合根 AppService)读入后传入,本模块不反向依赖 utils_config, + 故此处直接构造 config,不读盘。 + """ + + CONFIG = {"script_list": [{"display_name": "原神", "script_path": "C:/a.exe"}]} + + def test_ok_when_aligned(self): + """weekly 有 7 格条目且无孤儿 → status=ok。""" + dump_yaml_file(self.weekly_path, {"a": [3600] * 7}) + result = check_weekly(self.CONFIG) + self.assertEqual(result["status"], "ok") + self.assertEqual(result["missing_or_short"], []) + self.assertEqual(result["orphans"], []) + + def test_missing_entry_reported(self): + """config 有脚本但 weekly 无条目 → 进 missing_or_short。""" + result = check_weekly(self.CONFIG) + self.assertEqual(result["status"], "inconsistent") + self.assertEqual(result["missing_or_short"], ["a"]) + + def test_orphan_key_reported(self): + """weekly 有 config 已删除的 key → 进 orphans。""" + dump_yaml_file(self.weekly_path, {"a": [3600] * 7, "gone": [3600] * 7}) + result = check_weekly(self.CONFIG) + self.assertEqual(result["status"], "inconsistent") + self.assertEqual(result["orphans"], ["gone"]) + + def test_empty_script_list_marks_all_orphans(self): + """config 无脚本:weekly 中全部条目均视为孤儿。""" + dump_yaml_file(self.weekly_path, {"a": [3600] * 7}) + result = check_weekly({"script_list": []}) + self.assertEqual(result["status"], "inconsistent") + self.assertEqual(result["orphans"], ["a"]) + + +class TestDeleteWeekly(UtilsWeeklyTestBase): + """delete_weekly:仅清理 weekly_timeouts.yml 孤儿(总 config 移除归 AppService / utils_config)。""" + + def test_delete_weekly_cleans_orphan(self): + """删除后 weekly_timeouts.yml 中该脚本的孤儿条目被移除。""" + dump_yaml_file(self.weekly_path, {"a": [100] * 7}) + delete_weekly("a") + weekly = self._read_weekly() + self.assertNotIn("a", weekly) + self.assertEqual(weekly, {}) + + def test_delete_weekly_keeps_others(self): + """删除单个脚本不影响 weekly_timeouts.yml 中其它条目。""" + dump_yaml_file(self.weekly_path, {"a": [100] * 7, "mute": [120] * 7}) + delete_weekly("a") + weekly = self._read_weekly() + self.assertNotIn("a", weekly) + self.assertEqual(weekly, {"mute": [120] * 7}) + + def test_delete_weekly_noop_when_absent(self): + """脚本无 weekly 条目时清理为 no-op(不报错,文件保持空 {})。""" + delete_weekly("不存在") + self.assertEqual(self._read_weekly(), {}) + + +class TestSetWeeklyStart(UtilsWeeklyTestBase): + """set_weekly_start / get_weekly_start:读写独立文件 weekly_start.yml。""" + + def test_set_writes_to_weekly_start_file_only(self): + set_weekly_start("a", 4) + self.assertEqual(self._read_weekly_start(), {"a": 4}) + # 不污染 weekly_timeouts.yml + self.assertEqual(self._read_weekly(), {}) + + def test_get_returns_set_value(self): + set_weekly_start("a", 3) + self.assertEqual(get_weekly_start("a"), 3) + self.assertIsNone(get_weekly_start("缺失")) + + def test_set_none_clears_entry(self): + """start_day=None → 移除该脚本条目。""" + set_weekly_start("a", 2) + set_weekly_start("a", None) + self.assertIsNone(get_weekly_start("a")) + self.assertEqual(self._read_weekly_start(), {}) + + def test_invalid_day_raises(self): + for bad in (0, 8): + with self.subTest(bad=bad), self.assertRaises(AssertionError): + set_weekly_start("a", bad) + + +if __name__ == "__main__": + unittest.main()