From 776d93f41bce9b452da8998b35f77376df01bbd5 Mon Sep 17 00:00:00 2001 From: winsanyu <973530837@qq.com> Date: Sat, 22 Aug 2026 18:25:46 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E5=91=A8=E5=87=A0=E8=B5=B7=E7=A7=BB?= =?UTF-8?q?=E5=85=A5=E5=BC=B9=E7=AA=97=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/config/subscript.py | 34 -------------- src/gui/controllers/game_list.py | 1 + src/gui/controllers/task_card.py | 11 ++++- src/gui/dialogs.py | 49 ++++++++++++++++++-- src/gui/main_window.py | 3 ++ src/gui/qml/task_card.qml | 63 ++++++------------------- tests/test_gui_dialogs.py | 79 +++++++++++++++++++++++++++++++- 7 files changed, 151 insertions(+), 89 deletions(-) diff --git a/src/config/subscript.py b/src/config/subscript.py index 932efc77..045e9b65 100644 --- a/src/config/subscript.py +++ b/src/config/subscript.py @@ -298,37 +298,3 @@ def generate_config_from_example() -> None: data = yaml.safe_load(f) with open(config_path, "w", encoding="utf-8") as f: yaml.dump(data, f, allow_unicode=True, sort_keys=False) - - -# ============================================================ -# 网络下载 -# ============================================================ - - -def download_file(url: str, out_path: str, timeout: int = 10) -> str | None: - """下载文件到本地(通用工具,供各脚本拉取远程资源,如背景图)。 - - Args: - url: 下载地址。 - out_path: 输出路径(自动创建父目录)。 - timeout: 网络超时秒数。 - - Returns: - 输出路径;下载失败(网络/磁盘)→ None,调用方自行降级。 - """ - os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True) - tmp_path = f"{out_path}.part" - try: # 网络/磁盘操作,失败可恢复,调用方降级处理 - with ( - urllib.request.urlopen(url, timeout=timeout) as resp, # noqa: S310 - open(tmp_path, "wb") as f, - ): - f.write(resp.read()) - os.replace(tmp_path, out_path) - except (urllib.error.URLError, OSError) as exc: - logger.warning(f"[subscript] 下载失败 {url}: {exc}") - with contextlib.suppress(OSError): - os.remove(tmp_path) - return None - logger.info(f"[subscript] 下载完成: {out_path}") - return out_path diff --git a/src/gui/controllers/game_list.py b/src/gui/controllers/game_list.py index 4c2acfdc..27e3e543 100644 --- a/src/gui/controllers/game_list.py +++ b/src/gui/controllers/game_list.py @@ -347,6 +347,7 @@ def configCurrent(self): game["script_data"].get("script_path", ""), None, script_service=self._service._script_service, + chain_service=self._service, ) dialog.delete_requested.connect(self._on_delete_script) if dialog.exec() == QDialog.Accepted: diff --git a/src/gui/controllers/task_card.py b/src/gui/controllers/task_card.py index 1023d421..e96ff1b4 100644 --- a/src/gui/controllers/task_card.py +++ b/src/gui/controllers/task_card.py @@ -82,12 +82,21 @@ def weekly_supported(self) -> bool: @property def weekly_start_label(self) -> str: - """周常 chip 文字(周几起 / 选择周几)。""" + """周常起始日文字(周几起),供单脚本配置弹窗显示当前选择。""" game = self._current saved = self._ui_state.get(game["script_name"], {}) start_day = saved.get("weekly_start") return "选择周几" if start_day is None else f"{WEEKDAY_NAMES[start_day]}起" + @property + def weekly_name_label(self) -> str: + """周常行 chip 文字(周常名占位)。 + + 当前周常副本选择尚未实现,固定返回占位「选择周常」。后续支持多种周常时, + 此处改为读取具体周常名列表(组织形式可能大变,预留此接口)。 + """ + return "选择周常" + @property def master_on(self) -> bool: """总开关状态(全局 UI 态;驱动日常行开关)。""" diff --git a/src/gui/dialogs.py b/src/gui/dialogs.py index e9af02f7..04b9dea8 100644 --- a/src/gui/dialogs.py +++ b/src/gui/dialogs.py @@ -31,7 +31,7 @@ QVBoxLayout, ) -from src.config.set_config import ScriptConfig +from src.config.set_config import ScriptConfig, supports_weekly from src.config.subscript import get_script_name from src.service.script_service import ScriptService @@ -398,6 +398,7 @@ def __init__( script_path="", parent=None, script_service=None, + chain_service=None, ): super().__init__(parent) self.setWindowTitle(f"配置 {display_name}") @@ -409,6 +410,9 @@ def __init__( self.display_name = display_name # 展示名 self.script_path = script_path self._script_service = script_service or ScriptService() + # chain_service 用于读写 gui_state.json 的 weekly_start(周几起), + # 与 task_card 的 selectWeekly 走同一持久化路径;不传则跳过周常读写。 + self._chain_service = chain_service self.pending_changes = None # accept() 后供调用方取表单字段与 weekly self.init_ui() @@ -481,7 +485,23 @@ def _path_press(event): grid.addWidget(self._make_label("游戏进程:"), 6, 0) grid.addWidget(self.game_process_input, 6, 1, 1, 2) - # 行 7:每周超时(4×2 Grid 让同列等宽,数字右对齐) + # 周几起:仅支持周常的脚本显示(选择落到 gui_state.json 的 weekly_start)。 + # 不支持时整行不进布局,超时行上移到行 7,避免空行留白。 + self._weekly_start_supported = supports_weekly(self.script_name) + weekly_row = 7 if self._weekly_start_supported else -1 + timeout_row = 8 if self._weekly_start_supported else 7 + + # 周几起(行 7) + self.weekly_start_combo = self._make_combo( + ["不设置"] + [f"周{WEEKDAY_SHORT_NAMES[i]}起" for i in range(7)] + ) + self.weekly_start_combo.setStyleSheet(combo_box_qss()) + self._weekly_start_row = self.weekly_start_combo + if self._weekly_start_supported: + grid.addWidget(self._make_label("周常周几起:"), weekly_row, 0) + grid.addWidget(self.weekly_start_combo, weekly_row, 1, 1, 2) + + # 每周超时(4×2 Grid 让同列等宽,数字右对齐) timeout_grid = QGridLayout() timeout_grid.setHorizontalSpacing(4) timeout_grid.setVerticalSpacing(2) @@ -503,8 +523,8 @@ def _path_press(event): timeout_grid.addWidget(day_label, row, col) timeout_grid.addWidget(timeout_edit, row, col + 1) self.timeout_inputs.append(timeout_edit) - grid.addWidget(self._make_label("每周超时:"), 7, 0) - grid.addLayout(timeout_grid, 7, 1, 1, 2) + grid.addWidget(self._make_label("每周超时:"), timeout_row, 0) + grid.addLayout(timeout_grid, timeout_row, 1, 1, 2) # 底部按钮行:左次要(配置文件 / 删除脚本)+ 右主操作(取消 / 保存) open_cfg_btn = self._make_left_button( @@ -549,6 +569,15 @@ def load_data(self): # 阻塞运行:缺字段视为 True(默认阻塞) self.block_cb.setChecked(script_data.get("block", True)) + # 周几起(从 gui_state.json 读 weekly_start;不支持周常时跳过) + if self._weekly_start_supported and self._chain_service is not None: + ui_state = self._chain_service.load_ui_state() + saved = ui_state.get(self.script_name) + start_day = saved.get("weekly_start") if saved else None + self.weekly_start_combo.setCurrentIndex( + 0 if start_day is None else int(start_day) + ) + # 每周超时 timeouts = self._script_service.weekly_inputs(self.script_name) for idx, timeout_edit in enumerate(self.timeout_inputs): @@ -596,6 +625,18 @@ def save_data(self): text = timeout_edit.text().strip() timeouts.append(int(text) if text else None) + # 周几起:直接写 gui_state.json 的 weekly_start(与 task_card.selectWeekly 同源)。 + # index 0 = 不设置(None),1~7 对应周一~周日。 + if self._weekly_start_supported and self._chain_service is not None: + idx = self.weekly_start_combo.currentIndex() + ui_state = self._chain_service.load_ui_state() + saved = ui_state.setdefault(self.script_name, {}) + if idx <= 0: + saved.pop("weekly_start", None) + else: + saved["weekly_start"] = idx + self._chain_service.save_ui_state(ui_state) + self.pending_changes = { "old_script_name": self.script_name, "new_display_name": new_display_name, diff --git a/src/gui/main_window.py b/src/gui/main_window.py index 108ed221..a7279f35 100644 --- a/src/gui/main_window.py +++ b/src/gui/main_window.py @@ -130,6 +130,9 @@ def __init__(self): weeklyStartLabel = Property( str, lambda self: self.task_card.weekly_start_label, notify=taskStateChanged ) + weeklyNameLabel = Property( + str, lambda self: self.task_card.weekly_name_label, notify=taskStateChanged + ) masterOn = Property( bool, lambda self: self.task_card.master_on, notify=taskStateChanged ) diff --git a/src/gui/qml/task_card.qml b/src/gui/qml/task_card.qml index e2bedd48..7c150f5e 100644 --- a/src/gui/qml/task_card.qml +++ b/src/gui/qml/task_card.qml @@ -1,12 +1,14 @@ import QtQuick import OneDragonHelper 1.0 -// 任务调度卡(日常副本 / 周常周几):复刻旧 src/gui/task_card.py 的视觉与行为契约。 +// 任务调度卡(日常副本 / 周常):复刻旧 src/gui/task_card.py 的视觉与行为契约。 // 数据经 Bridge 暴露:taskTitle / taskAdapted / weeklySupported / dailyDungeonText / -// weeklyStartLabel / masterOn / dailyOn / weeklyOn / dungeonOptions; -// 写回经 selectDungeon / selectWeekly / toggleMaster / toggleWeekly +// weeklyNameLabel / masterOn / dailyOn / weeklyOn / dungeonOptions; +// 写回经 selectDungeon / toggleMaster / toggleWeekly // (dungeon/sequence/weekly_start 持久化到 gui_state.json;开关内存态)。 // +// 「周几起」选择已迁至单脚本配置弹窗(≡ 按钮打开),本卡只显示周常名占位。 +// // 布局严格按旧版固定坐标(标题 y=18 / 分隔线 y=56 / 日常 y=68 / 周常 y=134), // 只用 visible 切显隐、绝不动态改行 y —— 旧版 _set_task_rows_visible 就是这么做的, // 因此日常/周常行不可能错位。 @@ -15,6 +17,9 @@ import OneDragonHelper 1.0 // - taskAdapted 为假 → 仅显示标题,隐藏分隔线/两行(卡片收缩)。 // - 周常行:weeklySupported 为真才亮蓝可点;否则整行置灰、chip 写「未支持」、开关禁用。 // +// 颜色约定:日常行与周常行共用同一套色板(标题白 / 图标底蓝 / 图标字蓝 / chip 文字蓝), +// 保证两行视觉一致(旧版周常标题用了淡蓝与日常白色不一致,已统一)。 +// // 下拉用纯 QML 自绘(不引 QtQuick.Controls 的 Menu 组件类型):项目早期在 // 自定义 .qml 组件类型解析上有非确定失败(Type unavailable),自绘更稳。 Item { @@ -104,7 +109,6 @@ Item { Bridge.toastRequested("暂无副本选项") } else { dungeonPopup.visible = !dungeonPopup.visible - weeklyPopup.visible = false } } } @@ -131,6 +135,7 @@ Item { } // ── 周常行(固定 y=134;不支持时整行置灰,对齐旧 _refresh_weekly_chip)── + // 颜色与日常行共用同一色板(白标题 / 蓝图标底 / 蓝图标字 / 蓝 chip 字),保证一致。 Item { id: weeklyRow x: 20; y: 134; width: 440; height: 56 @@ -147,7 +152,7 @@ Item { Text { x: 58; y: 15; width: 60; height: 26 text: "周常" - color: weeklyRow.supported ? "#7DA8FF" : "#4A5568" + color: weeklyRow.supported ? "#FFFFFF" : "#4A5568" font.pixelSize: 15; font.weight: Font.DemiBold } Rectangle { @@ -158,17 +163,14 @@ Item { border.color: weeklyRow.supported ? "#33517A" : "#2A3850" Text { anchors.centerIn: parent - text: weeklyRow.supported ? Bridge.weeklyStartLabel : "未支持" + text: weeklyRow.supported ? Bridge.weeklyNameLabel : "未支持" color: weeklyRow.supported ? "#7DA8FF" : "#4A5568"; font.pixelSize: 11 } MouseArea { anchors.fill: parent hoverEnabled: true enabled: weeklyRow.supported - onClicked: { - weeklyPopup.visible = !weeklyPopup.visible - dungeonPopup.visible = false - } + onClicked: Bridge.toastRequested("周常副本选择尚未实现") } } // 周常开关(内存态,由 toggleMaster / selectWeekly 置位;不支持时禁用置灰) @@ -194,41 +196,7 @@ Item { } } - // ── 周常周几下拉 ── - Item { - id: weeklyPopup - z: 100 - visible: false - x: weeklyChip.x - y: weeklyRow.y + 56 + 4 - width: 160 - property int todayDay: (new Date().getDay() + 6) % 7 + 1 // 周一=1..周日=7 - height: weeklyList.height + 8 - - Rectangle { anchors.fill: parent; radius: 10; color: "#0F1A2E"; border.width: 1; border.color: "#33517A" } - Column { - id: weeklyList - x: 4; y: 4; width: parent.width - 8; spacing: 2 - Repeater { - model: weeklyPopup.visible ? [1, 2, 3, 4, 5, 6, 7] : [] - Rectangle { - width: weeklyList.width; height: 30; radius: 6 - color: wMouse.containsMouse ? "#1A3A7A" : "transparent" - Text { - anchors.fill: parent; leftPadding: 10 - verticalAlignment: Text.AlignVCenter - text: ["周一", "周二", "周三", "周四", "周五", "周六", "周日"][modelData - 1] - + (modelData === weeklyPopup.todayDay ? "(今天)" : "") - color: "#FFFFFF"; font.pixelSize: 13 - } - MouseArea { - id: wMouse; anchors.fill: parent; hoverEnabled: true - onClicked: { Bridge.selectWeekly(modelData); weeklyPopup.visible = false } - } - } - } - } - } + // 「周几起」选择已迁至单脚本配置弹窗(≡ 按钮触发),本卡不再内嵌周几下拉。 // ── 日常副本下拉(多级级联:一级副本 → 二级序列,对齐旧 QMenu 子菜单)── // 左列一级副本、右列二级序列,两列各自独立 Flickable 滚动(互不挤压、 @@ -399,11 +367,10 @@ Item { id: popupCatcher x: -128; y: -392; width: 1280; height: 720 z: 99 - visible: dungeonPopup.visible || weeklyPopup.visible - enabled: dungeonPopup.visible || weeklyPopup.visible + visible: dungeonPopup.visible + enabled: dungeonPopup.visible onClicked: { dungeonPopup.visible = false - weeklyPopup.visible = false } } } diff --git a/tests/test_gui_dialogs.py b/tests/test_gui_dialogs.py index bc3ce344..2aa804c4 100644 --- a/tests/test_gui_dialogs.py +++ b/tests/test_gui_dialogs.py @@ -248,8 +248,12 @@ def test_save_stores_block_in_pending_changes(self): class _FakeService: """极简 ScriptService 替身:供弹窗构造时读取脚本数据,避免依赖真实 config。""" - def __init__(self, script_type, script_path): - self._data = {"script_type": script_type, "script_path": script_path} + def __init__(self, script_type, script_path, display_name="日志分析"): + self._data = { + "script_type": script_type, + "script_path": script_path, + "display_name": display_name, + } def get_script(self, name): return self._data @@ -258,6 +262,19 @@ def weekly_inputs(self, name): return [3600] * 7 +class _FakeChainService: + """极简 ChainService 替身:读写 gui_state.json 的 weekly_start(内存态)。""" + + def __init__(self, ui_state=None): + self._ui_state = ui_state or {} + + def load_ui_state(self): + return self._ui_state + + def save_ui_state(self, state): + self._ui_state = state + + class TestSingleScriptConfigDialogOpenConfig(unittest.TestCase): """测试弹窗内「配置文件」动作:委托 ScriptService.config_file_path。""" @@ -314,6 +331,64 @@ def test_python_missing_file_shows_msg(self): mock_box.assert_called_once() +class TestSingleScriptConfigDialogWeeklyStart(unittest.TestCase): + """测试配置弹窗的「周几起」行:仅支持周常脚本显示,读写 gui_state.json。""" + + def _make_dialog(self, script_name, display_name, chain_service, supported): + with patch("src.gui.dialogs.supports_weekly", return_value=supported): + return SingleScriptConfigDialog( + script_name, + display_name, + "C:/games/run.exe", + script_service=_FakeService( + "external", "C:/games/run.exe", display_name + ), + chain_service=chain_service, + ) + + def test_hidden_when_weekly_unsupported(self): + """不支持周常的脚本周几起行应隐藏""" + dlg = self._make_dialog( + "collect_log", "日志分析", _FakeChainService(), supported=False + ) + self.assertFalse(dlg._weekly_start_supported) + self.assertFalse(dlg.weekly_start_combo.isVisible()) + + def test_visible_and_loaded_when_weekly_supported(self): + """支持周常的脚本周几起行可见,且加载 gui_state 的 weekly_start""" + chain = _FakeChainService({"run": {"weekly_start": 3}}) + dlg = self._make_dialog("run", "鸣潮", chain, supported=True) + self.assertTrue(dlg._weekly_start_supported) + # 未 show 时 isVisible 受父链影响为 False,用 isHidden 反映自身 visible 属性 + self.assertFalse(dlg.weekly_start_combo.isHidden()) + # combo index 3 → 周三起 + self.assertEqual(dlg.weekly_start_combo.currentIndex(), 3) + + def test_save_writes_weekly_start_to_gui_state(self): + """保存时把周几起(周三起)写入 gui_state.json 的 weekly_start""" + chain = _FakeChainService() + dlg = self._make_dialog("run", "鸣潮", chain, supported=True) + dlg.weekly_start_combo.setCurrentIndex(3) + with ( + patch("src.gui.dialogs.QMessageBox.warning"), + patch.object(SingleScriptConfigDialog, "accept"), + ): + dlg.save_data() + self.assertEqual(chain.load_ui_state()["run"]["weekly_start"], 3) + + def test_save_clears_weekly_start_when_unset(self): + """选择「不设置」时移除 gui_state 的 weekly_start""" + chain = _FakeChainService({"run": {"weekly_start": 5}}) + dlg = self._make_dialog("run", "鸣潮", chain, supported=True) + dlg.weekly_start_combo.setCurrentIndex(0) + with ( + patch("src.gui.dialogs.QMessageBox.warning"), + patch.object(SingleScriptConfigDialog, "accept"), + ): + dlg.save_data() + self.assertNotIn("weekly_start", chain.load_ui_state()["run"]) + + class TestSingleScriptConfigDialogDelete(unittest.TestCase): """测试弹窗内「删除脚本」动作。""" From 6c2ad96f99af9b537eea48a17ef8765317f77700 Mon Sep 17 00:00:00 2001 From: winsanyu <973530837@qq.com> Date: Sat, 22 Aug 2026 21:05:43 +0800 Subject: [PATCH 2/5] =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=A4=9A=E4=B8=AA?= =?UTF-8?q?=E5=91=A8=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/weekly_list.yml | 25 +++ src/config/set_config.md | 2 +- src/config/set_config.py | 52 +++++ src/config/subscript.py | 3 - src/gui/controllers/background.py | 6 +- src/gui/controllers/task_card.py | 103 ++++++++-- src/gui/dialogs.py | 10 +- src/gui/main_window.py | 14 +- src/gui/qml/task_card.qml | 295 ++++++++++++++++++++++------ src/service/script_service.py | 51 ++++- src/utils.py | 12 ++ tests/test_background.py | 18 +- tests/test_gui_dialogs.py | 9 +- tests/test_gui_task_card.py | 210 ++++++++++++++++++++ tests/test_qml_bridge_taskcard.py | 24 ++- tests/test_qml_launcher.py | 264 ++++++++++++++++++++++++- tests/test_script_service.py | 20 +- tests/test_set_config_subclasses.py | 13 ++ 18 files changed, 1007 insertions(+), 124 deletions(-) create mode 100644 config/weekly_list.yml create mode 100644 tests/test_gui_task_card.py diff --git a/config/weekly_list.yml b/config/weekly_list.yml new file mode 100644 index 00000000..30307edd --- /dev/null +++ b/config/weekly_list.yml @@ -0,0 +1,25 @@ +# 各脚本支持的周常声明(静态配置,进 git)。 +# 结构:每脚本一个列表,每项: +# name: 周常展示名(与 dungeon_list.yml 一致用中文名) +# dungeons?: 可选副本名清单(存在且有内容即表示该周常需选副本;含「无」表示跳过) +# 周常「周几起」由 gui_state 的 weekly_start 统一控制该脚本全部周常。 +# 用户已选副本(运行时状态)不在此文件,存于 gui_state.json 的 weekly_dungeons。 +# 副本名清单取自各脚本自身配置(如 M7A 的 assets/config/instance_names.json), +# 在此静态声明,避免运行时依赖游戏脚本已安装。 +ok-ww: + - name: 幻梦游园 +OneDragon-Launcher: + - name: 迷失之地 +March7th-Assistant: + - name: 货币战争 + - name: 历战余响 + dungeons: + - 无 + - 铁骸的锈冢 + - 晨昏的回眸 + - 心兽的战场 + - 尘梦的赞礼 + - 蛀星的旧靥 + - 不死的神实 + - 寒潮的落幕 + - 毁灭的开端 diff --git a/src/config/set_config.md b/src/config/set_config.md index 11d871a4..d2e7d227 100644 --- a/src/config/set_config.md +++ b/src/config/set_config.md @@ -23,7 +23,7 @@ - 基类 `ScriptConfig` 提供通用能力:`_load` / `_save` / `_verify_saved` / `_update_task` / `_update_sequence` / `_init_config` / `_is_aligned` / `set_dungeon` / `safe_update`。 - 子类声明 `_script_name`、`display_name` 与路径类属性:`_config_rel_path` 必填;声明了 `_game_path_keys` 则 `_game_config_rel_path` 必填;需模板初始化才设 `_template_rel_path`。`_task_key` / `_task_map` 按需覆盖。 -- 注册表 `_CONFIGS: dict[str, type[ScriptConfig]]` 由 `@register` 装饰器显式填充,key 为 `_script_name`;路径声明不完整会在 import 时 assert 暴露。 +- 注册表 `_CONFIGS: dict[str, type[ScriptConfig]]` 由 `@register` 装饰器显式填充,key 为 `_script_name`;路径声明不完整会在 import 时 assert 暴露。**注册表为模块私有,不对外 import**:外部只经模块级公开函数访问(`is_adapted` / `supports_weekly` / `get_config_path` / `get_game_exe_path` / `get_background_rel_path` / `set_config` / `set_weekly_dungeon`)。 ## 三个独立流程 diff --git a/src/config/set_config.py b/src/config/set_config.py index f804ba61..df078a4e 100644 --- a/src/config/set_config.py +++ b/src/config/set_config.py @@ -708,6 +708,26 @@ def _write_weekly(self, enabled: bool) -> None: safe_update(config, self._weekly_task_name, enabled, self.display_name) self._save(config) + def set_weekly_dungeon(self, weekly_name: str, dungeon_name: str) -> None: + """写入某周常当前选中的副本名到 config.yaml 的 instance_names。 + + 副本名清单的展示与下拉选项由 OneDragon-Helper 的 weekly_list.yml 声明 + (dungeons 字段)负责,本方法只承担把用户所选写回 M7A 游戏配置的本分。 + + Args: + weekly_name: 周常名(如「历战余响」);即 instance_names 的键。 + dungeon_name: 选中的副本名(来自 weekly_list.yml 声明)。 + """ + config = self._load() + # instance_names 是 M7A 约定键名({周常名: 副本名} 的 dict),保持不动; + # 缺字段则新建。 + instance_names = config.get("instance_names") + if not isinstance(instance_names, dict): + instance_names = {} + config["instance_names"] = instance_names + instance_names[weekly_name] = dungeon_name + self._save(config) + # ---- 异环 Neverness to Everness (NTE) ---- @register @@ -1033,3 +1053,35 @@ def supports_weekly(script_name: str) -> bool: if script_name not in _CONFIGS: return False return bool(_CONFIGS[script_name]._weekly_task_name) + + +def get_background_rel_path(script_name: str) -> str: + """读脚本默认背景图相对路径(相对脚本根目录,供 GUI 背景控制器)。 + + Args: + script_name: 脚本标识名。 + + Returns: + 背景图相对路径;未适配或未声明背景图时返回空字符串。 + """ + if script_name not in _CONFIGS: + return "" + return _CONFIGS[script_name].background + + +def set_weekly_dungeon(script_name: str, weekly_name: str, dungeon_name: str) -> None: + """适配器接口:写某周常当前选中的副本名到脚本自身 config。 + + 未适配或该脚本无「周常选副本」概念(子类未实现)时优雅跳过。 + + Args: + script_name: 脚本标识名。 + weekly_name: 周常名(如「历战余响」)。 + dungeon_name: 选中的副本名。 + """ + if script_name not in _CONFIGS: + return + cfg_cls = _CONFIGS[script_name] + if not hasattr(cfg_cls, "set_weekly_dungeon"): + return + cfg_cls().set_weekly_dungeon(weekly_name, dungeon_name) diff --git a/src/config/subscript.py b/src/config/subscript.py index 045e9b65..0940b804 100644 --- a/src/config/subscript.py +++ b/src/config/subscript.py @@ -3,13 +3,10 @@ 提供脚本根目录解析、config 路径推导、配置文件读写等功能。 """ -import contextlib import json import logging import os import re -import urllib.error -import urllib.request import yaml diff --git a/src/gui/controllers/background.py b/src/gui/controllers/background.py index 9b7270c9..de53e3ac 100644 --- a/src/gui/controllers/background.py +++ b/src/gui/controllers/background.py @@ -11,7 +11,7 @@ from PySide6.QtGui import QImage from PySide6.QtWidgets import QFileDialog -from src.config.set_config import _CONFIGS +from src.config.set_config import get_background_rel_path from src.config.subscript import _get_script_root_dir_soft, resolve_script_path logger = logging.getLogger(__name__) @@ -90,9 +90,7 @@ def _script_background(self, script_name: str) -> str: Returns: 背景图绝对路径;未适配/未声明/文件缺失 → 空字符串(交 DEFAULT_BG 兜底)。 """ - if script_name not in _CONFIGS: - return "" - rel = _CONFIGS[script_name].background + rel = get_background_rel_path(script_name) if not rel: return "" root = _get_script_root_dir_soft(script_name) diff --git a/src/gui/controllers/task_card.py b/src/gui/controllers/task_card.py index e96ff1b4..039bf635 100644 --- a/src/gui/controllers/task_card.py +++ b/src/gui/controllers/task_card.py @@ -8,8 +8,8 @@ from PySide6.QtCore import QObject, Signal, Slot from src.config.dungeon_config import get_display_name, parse_dungeon_config -from src.config.set_config import is_adapted -from src.config.set_config import supports_weekly as _supports_weekly +from src.config.set_config import is_adapted, set_weekly_dungeon +from src.service.script_service import ScriptService from src.utils_weekly import is_weekly_start_reached # 周常「周几以后开始执行」:值 1=周一 ~ 7=周日(对齐 get_week_num 的 0=周一 偏移 +1) @@ -33,6 +33,8 @@ def __init__(self, game_list, service, toast, parent=None): self._game_list = game_list self._service = 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 解析较贵且运行期不变, @@ -77,8 +79,11 @@ def daily_dungeon_text(self) -> str: @property def weekly_supported(self) -> bool: - """当前游戏是否支持周常(决定周常行可选性 / 整行样式)。""" - return _supports_weekly(self._current["script_name"]) + """当前游戏是否支持周常(决定周常行显隐)。 + + 唯一真相源为 weekly_list.yml:声明了该脚本周常即支持。 + """ + return bool(self._script_service.get_weekly_defs(self._current["script_name"])) @property def weekly_start_label(self) -> str: @@ -89,13 +94,72 @@ def weekly_start_label(self) -> str: return "选择周几" if start_day is None else f"{WEEKDAY_NAMES[start_day]}起" @property - def weekly_name_label(self) -> str: - """周常行 chip 文字(周常名占位)。 + def weekly_items(self) -> list[dict]: + """当前脚本支持的周常列表(供 QML 多周常布局)。 + + 每种周常:{name, has_dungeon, dungeon_label}。has_dungeon 由声明是否含 + dungeons 字段(且有内容)推导,不再用 needs_instance 布尔字段; + dungeon_label 为已选副本名,需选而未选时返回「选择副本」、无需选返回空。 + 声明(支持哪些周常/可选副本)来自 weekly_list.yml,已选副本来自 + gui_state.json 的 weekly_dungeons(与副本/序列同为 UI 状态)。 + """ + script_name = self._current["script_name"] + defs = self._script_service.get_weekly_defs(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: + # 需选副本:已选则显示副本名,未选显示占位提示 + label = "选择副本" + if 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。 """ - return "选择周常" + 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]: + """某周常的可选副本名列表(如历战余响的全体副本)。 + + 来自 weekly_list.yml 声明(该周常的 dungeons 字段);不再依赖游戏脚本 + 私有配置。未声明或无需副本返回空列表。 + + Args: + weekly_name: 周常名(如「历战余响」)。 + + Returns: + 副本名列表(含「无」);该周常未声明副本清单时返回空列表。 + """ + script_name = self._current["script_name"] + for d in self._script_service.get_weekly_defs(script_name): + if d["name"] != weekly_name: + continue + return list(d["dungeons"]) if "dungeons" in d else [] + return [] @property def master_on(self) -> bool: @@ -175,7 +239,7 @@ def init_weekly_toggle_states(self) -> dict: states: dict[str, bool] = {} for game in self._game_list.games: script_name = game["script_name"] - if not _supports_weekly(script_name): + if not self._script_service.get_weekly_defs(script_name): continue saved = self._ui_state.get(script_name) weekly_start = saved.get("weekly_start") if saved else None @@ -191,7 +255,7 @@ def toggleMaster(self, on: bool): """总开关:一键同步日常/周本(支持周常时周常开关一并置位)。""" self._master_on = on script_name = self._current["script_name"] - if _supports_weekly(script_name): + if self._script_service.get_weekly_defs(script_name): self._weekly_toggle_state[script_name] = on self.taskStateChanged.emit() @@ -227,3 +291,20 @@ def selectWeekly(self, start_day: int): self._weekly_toggle_state[script_name] = enabled self._service.save_ui_state(self._ui_state) self.refresh() + + @Slot(str, str) + def selectWeeklyDungeon(self, weekly_name: str, dungeon_name: str): + """选择某周常的副本(持久化 gui_state.json 并写脚本自身 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]) + set_weekly_dungeon(script_name, weekly_name, dungeon_name) + self.refresh() diff --git a/src/gui/dialogs.py b/src/gui/dialogs.py index 04b9dea8..bead954c 100644 --- a/src/gui/dialogs.py +++ b/src/gui/dialogs.py @@ -488,7 +488,6 @@ def _path_press(event): # 周几起:仅支持周常的脚本显示(选择落到 gui_state.json 的 weekly_start)。 # 不支持时整行不进布局,超时行上移到行 7,避免空行留白。 self._weekly_start_supported = supports_weekly(self.script_name) - weekly_row = 7 if self._weekly_start_supported else -1 timeout_row = 8 if self._weekly_start_supported else 7 # 周几起(行 7) @@ -496,10 +495,13 @@ def _path_press(event): ["不设置"] + [f"周{WEEKDAY_SHORT_NAMES[i]}起" for i in range(7)] ) self.weekly_start_combo.setStyleSheet(combo_box_qss()) - self._weekly_start_row = self.weekly_start_combo if self._weekly_start_supported: - grid.addWidget(self._make_label("周常周几起:"), weekly_row, 0) - grid.addWidget(self.weekly_start_combo, weekly_row, 1, 1, 2) + grid.addWidget(self._make_label("周常周几起:"), 7, 0) + grid.addWidget(self.weekly_start_combo, 7, 1, 1, 2) + else: + # _make_combo 以 self 为父:控件已是 dialog 子控件,不进布局也会按默认 + # 位置 (0,0) 绘制并盖住左上角字段,必须显式 hide()。 + self.weekly_start_combo.hide() # 每周超时(4×2 Grid 让同列等宽,数字右对齐) timeout_grid = QGridLayout() diff --git a/src/gui/main_window.py b/src/gui/main_window.py index a7279f35..acdc5329 100644 --- a/src/gui/main_window.py +++ b/src/gui/main_window.py @@ -130,8 +130,10 @@ def __init__(self): weeklyStartLabel = Property( str, lambda self: self.task_card.weekly_start_label, notify=taskStateChanged ) - weeklyNameLabel = Property( - str, lambda self: self.task_card.weekly_name_label, notify=taskStateChanged + weeklyItems = Property( + "QVariantList", + lambda self: self.task_card.weekly_items, + notify=taskStateChanged, ) masterOn = Property( bool, lambda self: self.task_card.master_on, notify=taskStateChanged @@ -247,6 +249,14 @@ def selectDungeon(self, name, seq): def selectWeekly(self, day): self.task_card.selectWeekly(day) + @Slot(str, str) + def selectWeeklyDungeon(self, weekly_name, dungeon_name): + self.task_card.selectWeeklyDungeon(weekly_name, dungeon_name) + + @Slot(str, result="QVariantList") + def weeklyDungeonOptions(self, weekly_name): + return self.task_card.weekly_dungeon_options(weekly_name) + @Slot(str) def videoError(self, reason): self.background.videoError(reason) diff --git a/src/gui/qml/task_card.qml b/src/gui/qml/task_card.qml index 7c150f5e..fe67b364 100644 --- a/src/gui/qml/task_card.qml +++ b/src/gui/qml/task_card.qml @@ -3,8 +3,8 @@ import OneDragonHelper 1.0 // 任务调度卡(日常副本 / 周常):复刻旧 src/gui/task_card.py 的视觉与行为契约。 // 数据经 Bridge 暴露:taskTitle / taskAdapted / weeklySupported / dailyDungeonText / -// weeklyNameLabel / masterOn / dailyOn / weeklyOn / dungeonOptions; -// 写回经 selectDungeon / toggleMaster / toggleWeekly +// weeklyItems / masterOn / dailyOn / weeklyOn / dungeonOptions; +// 写回经 selectDungeon / selectWeeklyDungeon / toggleMaster / toggleWeekly // (dungeon/sequence/weekly_start 持久化到 gui_state.json;开关内存态)。 // // 「周几起」选择已迁至单脚本配置弹窗(≡ 按钮打开),本卡只显示周常名占位。 @@ -24,9 +24,35 @@ import OneDragonHelper 1.0 // 自定义 .qml 组件类型解析上有非确定失败(Type unavailable),自绘更稳。 Item { id: cardRoot + objectName: "cardRoot" width: 480 - // 高度随适配态:适配 210(两行)/ 未适配 84(仅标题) - height: Bridge.taskAdapted ? 210 : 84 + // 高度随适配态:未适配 84(仅标题);适配时标题(54)+分隔线+日常(56)+周常区动态高度。 + // 该脚本不支持周常时,周常区整区隐藏,高度直接缩到日常区以下。 + // 周常区含独立头部(36)+ 每项(40)+ 底部留白(16),卡片底部再留 16。 + height: Bridge.taskAdapted ? (weeklyArea.visible ? (134 + weeklyArea.height + 16) : 134) : 84 + + // 窗口边界在卡片坐标系中的常量:卡片由 main.qml 的 Loader 固定在 (128, 392), + // 窗口固定 1280x720(popupCatcher 用的是同一套常量)。下拉据此判断上下余量。 + readonly property int winTopInCard: -392 + readonly property int winBottomInCard: 328 + + // 下拉定位:优先在锚点下方展开;下方装不下且上方更宽裕时上翻(对齐系统菜单)。 + // 高度封顶到所选方向的实际余量,使 Flickable 视口 == 可见区域,内容超出即可滚动。 + // 不封顶会出事:弹窗底部越过窗口边界后,超出部分既不可见、又因内容未溢出视口 + // 而无法滚动到 —— 周常 9 个副本只显示 3 个就是这么来的(日常因内容恰好溢出 + // 视口能滚动,才掩盖了同一个问题)。 + // + // anchorTop / anchorBottom 为锚点行在卡片坐标系的上下边,desiredH 为内容理想高度。 + // 返回 {y, h}:弹窗应放置的 y 与最终高度。 + function placePopup(anchorTop, anchorBottom, desiredH) { + var below = cardRoot.winBottomInCard - anchorBottom - 4 - 8 + var above = anchorTop - cardRoot.winTopInCard - 4 - 8 + if (desiredH <= below || below >= above) { + return { "y": anchorBottom + 4, "h": Math.min(desiredH, below) } + } + var h = Math.min(desiredH, above) + return { "y": anchorTop - 4 - h, "h": h } + } // 玻璃卡背景(半透明;文本为兄弟节点、不受 opacity 影响) Rectangle { @@ -79,7 +105,7 @@ Item { visible: Bridge.taskAdapted } - // ── 日常行(固定 y=68,对齐旧 _task_row 坐标)── + // ── 每日任务行(固定 y=68,对齐旧 _task_row 坐标)── Item { id: dailyRow x: 20; y: 68; width: 440; height: 56 @@ -90,8 +116,8 @@ Item { Text { anchors.centerIn: parent; text: "⚡"; color: "#7DA8FF"; font.pixelSize: 16 } } Text { - x: 58; y: 15; width: 60; height: 26 - text: "日常"; color: "#FFFFFF"; font.pixelSize: 15; font.weight: Font.DemiBold + x: 58; y: 15; width: 64; height: 26 + text: "每日任务"; color: "#FFFFFF"; font.pixelSize: 15; font.weight: Font.DemiBold } Rectangle { id: dailyChip @@ -113,7 +139,7 @@ Item { } } } - // 日常开关(镜像总开关;点击即切总开关,对齐旧 daily_toggle 不独立接线) + // 每日任务开关(镜像总开关;点击即切总开关,对齐旧 daily_toggle 不独立接线) Item { id: dailyToggle x: 388; y: 17; width: 40; height: 22 @@ -134,64 +160,117 @@ Item { } } - // ── 周常行(固定 y=134;不支持时整行置灰,对齐旧 _refresh_weekly_chip)── - // 颜色与日常行共用同一色板(白标题 / 蓝图标底 / 蓝图标字 / 蓝 chip 字),保证一致。 + // ── 周常区(y=134 起;不支持周常时整区隐藏)── + // 颜色与每日任务行共用同一色板(白标题 / 蓝图标底 / 蓝图标字 / 蓝 chip 字)。 + // 布局改为「头部行 + 周常列表」:头部左侧显示「周常」并放置总开关, + // 避免原先总开关贴在第一项右侧、看起来只控制第一项的歧义。 + // 每项统一左对齐日常 chip(x=130),保证两行选项在同一垂线上。 Item { - id: weeklyRow - x: 20; y: 134; width: 440; height: 56 - visible: Bridge.taskAdapted + id: weeklyArea + objectName: "weeklyArea" + x: 20; y: 134; width: 440 + visible: Bridge.taskAdapted && Bridge.weeklySupported property bool supported: Bridge.weeklySupported - Rectangle { - x: 12; y: 10; width: 36; height: 36; radius: 10 - color: weeklyRow.supported ? "#1A3A7A" : "#2A3040" - Text { - anchors.centerIn: parent; text: "📅" - color: weeklyRow.supported ? "#7DA8FF" : "#4A5568"; font.pixelSize: 16 + property int rowH: 40 + // 高度由数据模型长度推导:Column 无 count 属性(那是 Repeater 的), + // 用 Bridge.weeklyItems.length 才可靠;每项固定 rowH。 + height: weeklyHeader.height + Bridge.weeklyItems.length * rowH + 16 + + // 周常头部:📅 图标 + 「周常」标题 + 总开关 + Item { + id: weeklyHeader + x: 0; y: 0; width: parent.width; height: 36 + Rectangle { + x: 12; y: 5; width: 36; height: 26; radius: 8 + color: weeklyArea.supported ? "#1A3A7A" : "#2A3040" + Text { + anchors.centerIn: parent; text: "📅" + color: weeklyArea.supported ? "#7DA8FF" : "#4A5568" + font.pixelSize: 14 + } } - } - Text { - x: 58; y: 15; width: 60; height: 26 - text: "周常" - color: weeklyRow.supported ? "#FFFFFF" : "#4A5568" - font.pixelSize: 15; font.weight: Font.DemiBold - } - Rectangle { - id: weeklyChip - x: 130; y: 15; width: 120; height: 26; radius: 13 - color: weeklyRow.supported ? "#0F1A2E" : "#1A2028" - border.width: 1 - border.color: weeklyRow.supported ? "#33517A" : "#2A3850" Text { - anchors.centerIn: parent - text: weeklyRow.supported ? Bridge.weeklyNameLabel : "未支持" - color: weeklyRow.supported ? "#7DA8FF" : "#4A5568"; font.pixelSize: 11 + x: 58; y: 8; width: 64; height: 22 + text: "周常" + color: weeklyArea.supported ? "#FFFFFF" : "#4A5568" + font.pixelSize: 15; font.weight: Font.DemiBold + verticalAlignment: Text.AlignVCenter } - MouseArea { - anchors.fill: parent - hoverEnabled: true - enabled: weeklyRow.supported - onClicked: Bridge.toastRequested("周常副本选择尚未实现") + // 周常总开关(内存态,由 toggleMaster / selectWeekly 置位) + Item { + id: weeklyToggle + x: 388; y: 7; width: 40; height: 22 + property bool on: Bridge.weeklyOn + Rectangle { + anchors.fill: parent; radius: 11 + color: !weeklyArea.supported ? "#2A3040" + : (weeklyToggle.on ? "#2196F3" : "#2A3850") + } + Rectangle { + x: weeklyToggle.on ? 20 : 2; y: 2; width: 18; height: 18; radius: 9 + color: "#FFFFFF" + Behavior on x { NumberAnimation { duration: 120 } } + } + MouseArea { + anchors.fill: parent + enabled: weeklyArea.supported + onClicked: Bridge.toggleWeekly(!weeklyToggle.on) + } } } - // 周常开关(内存态,由 toggleMaster / selectWeekly 置位;不支持时禁用置灰) - Item { - id: weeklyToggle - x: 388; y: 17; width: 40; height: 22 - property bool on: Bridge.weeklyOn - Rectangle { - anchors.fill: parent; radius: 11 - color: !weeklyRow.supported ? "#2A3040" - : (weeklyToggle.on ? "#2196F3" : "#2A3850") - } - Rectangle { - x: weeklyToggle.on ? 20 : 2; y: 2; width: 18; height: 18; radius: 9 - color: "#FFFFFF" - Behavior on x { NumberAnimation { duration: 120 } } - } - MouseArea { - anchors.fill: parent - enabled: weeklyRow.supported - onClicked: Bridge.toggleWeekly(!weeklyToggle.on) + + // 周常列表:每种一行 + Column { + id: weeklyItemsCol + y: weeklyHeader.height + width: parent.width + spacing: 0 + Repeater { + model: Bridge.weeklyItems + Item { + width: weeklyArea.width; height: weeklyArea.rowH + property bool hasDungeon: modelData.has_dungeon + Rectangle { + x: 12; y: 7; width: 36; height: 26; radius: 8 + color: weeklyArea.supported ? "#1A3A7A" : "#2A3040" + Text { + anchors.centerIn: parent; text: "📅" + color: weeklyArea.supported ? "#7DA8FF" : "#4A5568" + font.pixelSize: 14 + } + } + Text { + x: 58; y: 9; width: 64; height: 22 + text: modelData.name + color: weeklyArea.supported ? "#FFFFFF" : "#4A5568" + font.pixelSize: 14; font.weight: Font.DemiBold + verticalAlignment: Text.AlignVCenter + } + Rectangle { + id: wkChip + x: 130; y: 7; width: 120; height: 26; radius: 13 + visible: hasDungeon + color: weeklyArea.supported ? "#0F1A2E" : "#1A2028" + border.width: 1 + border.color: weeklyArea.supported ? "#33517A" : "#2A3850" + Text { + anchors.centerIn: parent + text: modelData.dungeon_label + color: weeklyArea.supported ? "#7DA8FF" : "#4A5568" + font.pixelSize: 11 + } + MouseArea { + anchors.fill: parent + hoverEnabled: true + enabled: weeklyArea.supported + onClicked: { + weeklyDungeonPopup.weeklyName = modelData.name + weeklyDungeonPopup.visible = !weeklyDungeonPopup.visible + dungeonPopup.visible = false + } + } + } + } } } } @@ -205,10 +284,11 @@ Item { // 避免每行测量导致的 hover 重布局抖动 → 卡顿)。 Item { id: dungeonPopup + objectName: "dungeonPopup" z: 100 visible: false x: dailyChip.x - y: dailyRow.y + 56 + 4 + y: dungeonPopup.popupY // 宽度随一级列 +(出现二级列时);高度封顶避免出屏,超出由各列 Flickable 独立滚动 width: dungeonPopup.rightW > 0 ? (dungeonPopup.leftW + 4 + dungeonPopup.rightW + 8) @@ -217,6 +297,7 @@ Item { property int leftW: 200 property int rightW: 0 + property int popupY: dailyRow.y + dailyRow.height + 4 property int popupHeight: 360 property int viewportH: height - 8 property string selName: "" @@ -238,7 +319,11 @@ Item { if (measTm.width > maxW) maxW = measTm.width } leftW = Math.min(maxW + 28, 240) - popupHeight = Math.min(opts.length * 32 + 8, 360) + var geom = cardRoot.placePopup( + dailyRow.y, dailyRow.y + dailyRow.height, + Math.min(opts.length * 32 + 8, 360)) + popupY = geom.y + popupHeight = geom.h selName = "" selSequences = [] rightW = 0 @@ -360,17 +445,97 @@ Item { } } + // ── 周常副本下拉(单级:副本名列表,来自 Bridge.weeklyDungeonOptions)── + // 复用 dungeonPopup 的测量/封顶滚动模式,宽度按内容一次性算定。 + Item { + id: weeklyDungeonPopup + objectName: "weeklyDungeonPopup" + z: 100 + visible: false + x: 130 + y: weeklyDungeonPopup.popupY + width: instW + 8 + height: popupHeight + property string weeklyName: "" + property int instW: 200 + property int popupY: weeklyArea.y + weeklyArea.height + 4 + property int popupHeight: 360 + property int viewportH: height - 8 + + Rectangle { + anchors.fill: parent; radius: 10 + color: "#0F1A2E"; border.width: 1; border.color: "#33517A" + } + TextMetrics { id: instTm; font.pixelSize: 13 } + function openMenu() { + var opts = Bridge.weeklyDungeonOptions(weeklyDungeonPopup.weeklyName) + var maxW = 60 + for (var i = 0; i < opts.length; i++) { + instTm.text = opts[i] + if (instTm.width > maxW) maxW = instTm.width + } + instW = Math.min(maxW + 28, 240) + var geom = cardRoot.placePopup( + weeklyArea.y, weeklyArea.y + weeklyArea.height, + Math.min(opts.length * 32 + 8, 360)) + popupY = geom.y + popupHeight = geom.h + } + onVisibleChanged: { if (visible) openMenu() } + Flickable { + width: weeklyDungeonPopup.instW + height: weeklyDungeonPopup.viewportH + contentWidth: instCol.width + contentHeight: instCol.height + clip: true + boundsBehavior: Flickable.StopAtBounds + Column { + id: instCol + width: weeklyDungeonPopup.instW + spacing: 2 + Repeater { + model: weeklyDungeonPopup.visible + ? Bridge.weeklyDungeonOptions(weeklyDungeonPopup.weeklyName) + : [] + Rectangle { + width: weeklyDungeonPopup.instW + height: 30; radius: 6 + color: instMouse.containsMouse ? "#1A3A7A" : "transparent" + Text { + anchors.fill: parent; leftPadding: 10 + verticalAlignment: Text.AlignVCenter + text: modelData + color: "#FFFFFF"; font.pixelSize: 13 + } + MouseArea { + id: instMouse; anchors.fill: parent; hoverEnabled: true + onClicked: { + Bridge.selectWeeklyDungeon( + weeklyDungeonPopup.weeklyName, modelData) + weeklyDungeonPopup.visible = false + } + } + } + } + } + } + } + // ── 点击空白处关闭下拉(对齐旧 QMenu:弹窗外任意点击即关闭)── // 全窗透明捕获层,仅当任一弹窗打开时激活;位于弹窗(z:100)之下、卡片内容(z:0)之上, - // 故「弹窗内」点击由弹窗自身处理,「弹窗外」点击被本层拦截并关闭两个弹窗。 + // 故「弹窗内」点击由弹窗自身处理、「弹窗外」点击被本层拦截并关闭两个弹窗。 MouseArea { id: popupCatcher - x: -128; y: -392; width: 1280; height: 720 + x: -128 + y: cardRoot.winTopInCard + width: 1280 + height: cardRoot.winBottomInCard - cardRoot.winTopInCard z: 99 - visible: dungeonPopup.visible - enabled: dungeonPopup.visible + visible: dungeonPopup.visible || weeklyDungeonPopup.visible + enabled: dungeonPopup.visible || weeklyDungeonPopup.visible onClicked: { dungeonPopup.visible = false + weeklyDungeonPopup.visible = false } } } diff --git a/src/service/script_service.py b/src/service/script_service.py index 0937eb22..25dcd455 100644 --- a/src/service/script_service.py +++ b/src/service/script_service.py @@ -25,6 +25,7 @@ resolve_script_path, ) from src.utils import ( + get_weekly_list_yml_path_under_root, get_weekly_timeouts_yml_path_under_root, require_config_yml_path, ) @@ -44,12 +45,18 @@ def _load_config() -> dict: def _load_weekly() -> dict: - """读取 weekly_timeouts.yml(不存在时返回空 dict)。""" + """读取 weekly_timeouts.yml(随包发布、必存在)。 + + 与 _load_weekly_defs 同款:assert 存在且为 dict,损坏直接暴露而非静默兜底。 + """ weekly_path = get_weekly_timeouts_yml_path_under_root() - if not os.path.exists(weekly_path): - return {} + assert os.path.exists(weekly_path), f"[service] 周常超时配置缺失: {weekly_path}" with open(weekly_path, encoding="utf-8") as f: - return yaml.safe_load(f) or {} + data = yaml.safe_load(f) + assert isinstance(data, dict), ( + f"[service] 周常超时配置应为 dict(空文件或格式错误): {weekly_path}" + ) + return data def _dump_weekly(weekly_map: dict) -> None: @@ -59,6 +66,25 @@ def _dump_weekly(weekly_map: dict) -> None: yaml.dump(weekly_map, f, allow_unicode=True, sort_keys=False) +def _load_weekly_defs() -> dict: + """读取 weekly_list.yml(周常声明配置,进 git,必存在)。 + + 结构:{script_name: [{"name", "dungeons"?}, ...]}。dungeons 存在且有内容即 + 表示该周常需选副本(不再用 needs_instance 布尔字段)。 + """ + weekly_list_path = get_weekly_list_yml_path_under_root() + assert os.path.exists(weekly_list_path), ( + f"[service] 周常声明配置缺失: {weekly_list_path}" + ) + with open(weekly_list_path, encoding="utf-8") as f: + data = yaml.safe_load(f) + # 空文件或内容非 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)原样保留。 @@ -81,12 +107,27 @@ class ScriptService: """ def load_all_weekly(self) -> dict: - """返回 weekly_timeouts.yml 的完整字典(文件不存在时返回空 dict)。 + """返回 weekly_timeouts.yml 的完整字典(文件随包发布,必存在)。 key 为脚本唯一标识。 """ return _load_weekly() + def get_weekly_defs(self, script_name: str) -> list: + """返回某脚本支持的周常声明清单(weekly_list.yml)。 + + 每项:{"name", "dungeons"?}。dungeons 存在即有可选副本。文件缺失或该脚本 + 无声明时返回空列表。 + + Args: + script_name: 脚本唯一标识。 + + Returns: + 周常声明列表;无声明时为空列表。 + """ + defs_map = _load_weekly_defs() + return list(defs_map[script_name]) if script_name in defs_map else [] + def get_script(self, script_name: str) -> dict | None: """按脚本唯一标识读取单个脚本条目。 diff --git a/src/utils.py b/src/utils.py index 57312061..fc3957c0 100644 --- a/src/utils.py +++ b/src/utils.py @@ -45,6 +45,18 @@ def get_weekly_timeouts_yml_path_under_root() -> str: return safe_path_join(get_root_dir(), "config", "weekly_timeouts.yml") +def get_weekly_list_yml_path_under_root() -> str: + """ + 获取根目录下的config/weekly_list.yml文件路径。 + + 该文件是周常声明配置(静态,进 git):每脚本支持哪些周常、每种是否需选副本。 + 与 weekly_timeouts.yml 同级的周常侧配置文件。 + + :return: 根目录下的config/weekly_list.yml文件路径 + """ + return safe_path_join(get_root_dir(), "config", "weekly_list.yml") + + @lru_cache def get_root_dir() -> str: """ diff --git a/tests/test_background.py b/tests/test_background.py index 3296b68a..65e0d454 100644 --- a/tests/test_background.py +++ b/tests/test_background.py @@ -235,11 +235,9 @@ def setUp(self): game_list=MagicMock(), task_card=MagicMock(), toast=MagicMock() ) - @patch.object( - bgmod, "_CONFIGS", {"ok-ww": type("C", (), {"background": "assets/x.webp"})()} - ) + @patch.object(bgmod, "get_background_rel_path", return_value="assets/x.webp") @patch.object(bgmod, "_get_script_root_dir_soft", return_value="/script/root") - def test_declared_and_present(self, _mock_root): + def test_declared_and_present(self, _mock_root, _mock_rel): # 声明且文件存在:返回脚本根拼接的绝对路径 with patch.object(bgmod.os.path, "isfile", return_value=True): self.assertEqual( @@ -247,22 +245,20 @@ def test_declared_and_present(self, _mock_root): os.path.join("/script/root", "assets/x.webp"), ) - @patch.object( - bgmod, "_CONFIGS", {"ok-ww": type("C", (), {"background": "assets/x.webp"})()} - ) + @patch.object(bgmod, "get_background_rel_path", return_value="assets/x.webp") @patch.object(bgmod, "_get_script_root_dir_soft", return_value="/script/root") - def test_declared_but_missing(self, _mock_root): + def test_declared_but_missing(self, _mock_root, _mock_rel): # 声明但文件缺失:返回空字符串(交 DEFAULT_BG 兜底) with patch.object(bgmod.os.path, "isfile", return_value=False): self.assertEqual(self.ctrl._script_background("ok-ww"), "") - @patch.object(bgmod, "_CONFIGS", {"BetterGI": type("C", (), {"background": ""})()}) - def test_not_declared(self): + @patch.object(bgmod, "get_background_rel_path", return_value="") + def test_not_declared(self, _mock_rel): # 子类未声明 background(如原神):返回空字符串 self.assertEqual(self.ctrl._script_background("BetterGI"), "") def test_unadapted_script(self): - # 未适配脚本:返回空字符串 + # 未适配脚本:返回空字符串(get_background_rel_path 返回 "") self.assertEqual(self.ctrl._script_background("不存在"), "") diff --git a/tests/test_gui_dialogs.py b/tests/test_gui_dialogs.py index 2aa804c4..6d071d1e 100644 --- a/tests/test_gui_dialogs.py +++ b/tests/test_gui_dialogs.py @@ -347,12 +347,17 @@ def _make_dialog(self, script_name, display_name, chain_service, supported): ) def test_hidden_when_weekly_unsupported(self): - """不支持周常的脚本周几起行应隐藏""" + """不支持周常的脚本周几起行应隐藏。 + + combo 以 dialog 为父控件,仅「不进布局」不够——未布局的子控件会按默认 + 位置 (0,0) 绘制并盖住左上角字段。必须 isHidden() 为真(未 show 的 dialog + 上 isVisible() 恒为 False,断不出这个 bug)。 + """ dlg = self._make_dialog( "collect_log", "日志分析", _FakeChainService(), supported=False ) self.assertFalse(dlg._weekly_start_supported) - self.assertFalse(dlg.weekly_start_combo.isVisible()) + self.assertTrue(dlg.weekly_start_combo.isHidden()) def test_visible_and_loaded_when_weekly_supported(self): """支持周常的脚本周几起行可见,且加载 gui_state 的 weekly_start""" diff --git a/tests/test_gui_task_card.py b/tests/test_gui_task_card.py new file mode 100644 index 00000000..308c7f2e --- /dev/null +++ b/tests/test_gui_task_card.py @@ -0,0 +1,210 @@ +"""测试 src/gui/controllers/task_card.py:多周常 items 与选副本持久化。""" + +import os +import tempfile +import unittest +from unittest.mock import MagicMock, patch + +import yaml + +from src.gui.controllers import task_card as task_card_mod +from src.gui.controllers.task_card import TaskCardController + + +class _FakeGameList: + def __init__(self, games): + self.games = games + self.current_game = games[0] + + +def _write_defs(tmp, data): + """写临时 weekly_list.yml(周常声明配置)。""" + path = os.path.join(tmp.name, "weekly_list.yml") + with open(path, "w", encoding="utf-8") as f: + yaml.dump(data, f, allow_unicode=True, sort_keys=False) + return path + + +def _make_controller( + script_name="March7th-Assistant", display_name="崩铁", ui_state=None +): + 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 + toast = MagicMock() + ctrl = TaskCardController(game_list, service, toast) + return ctrl + + +class TestWeeklyItems(unittest.TestCase): + def test_weekly_items_for_star_rail(self): + """崩铁两种周常(来自 weekly_list.yml):货币战争(无副本) + 历战余响(有副本)。""" + tmp = tempfile.TemporaryDirectory() + defs_path = _write_defs( + tmp, + { + "March7th-Assistant": [ + {"name": "货币战争"}, + { + "name": "历战余响", + "dungeons": ["无", "铁骸的锈冢", "晨昏的回眸"], + }, + ] + }, + ) + with patch( + "src.service.script_service.get_weekly_list_yml_path_under_root", + return_value=defs_path, + ): + ctrl = _make_controller() + items = ctrl.weekly_items + tmp.cleanup() + self.assertEqual(len(items), 2) + self.assertEqual(items[0]["name"], "货币战争") + self.assertFalse(items[0]["has_dungeon"]) + self.assertEqual(items[0]["dungeon_label"], "") + self.assertEqual(items[1]["name"], "历战余响") + self.assertTrue(items[1]["has_dungeon"]) + self.assertEqual(items[1]["dungeon_label"], "选择副本") + + def test_weekly_dungeon_options_reads_from_config(self): + """副本清单来自 weekly_list.yml 的 dungeons 字段,不再依赖游戏脚本配置。""" + tmp = tempfile.TemporaryDirectory() + defs_path = _write_defs( + tmp, + { + "March7th-Assistant": [ + { + "name": "历战余响", + "dungeons": ["无", "铁骸的锈冢", "晨昏的回眸"], + }, + ] + }, + ) + with patch( + "src.service.script_service.get_weekly_list_yml_path_under_root", + return_value=defs_path, + ): + ctrl = _make_controller() + options = ctrl.weekly_dungeon_options("历战余响") + tmp.cleanup() + self.assertEqual(options, ["无", "铁骸的锈冢", "晨昏的回眸"]) + + def test_weekly_dungeon_options_unknown_weekly_returns_empty(self): + """未声明的周常名 → 空列表。""" + tmp = tempfile.TemporaryDirectory() + defs_path = _write_defs( + tmp, + { + "March7th-Assistant": [ + {"name": "历战余响", "dungeons": ["无", "铁骸的锈冢"]}, + ] + }, + ) + with patch( + "src.service.script_service.get_weekly_list_yml_path_under_root", + return_value=defs_path, + ): + ctrl = _make_controller() + self.assertEqual(ctrl.weekly_dungeon_options("不存在"), []) + tmp.cleanup() + + def test_weekly_dungeon_options_without_dungeons_key(self): + """无 dungeons 字段的周常 → 空列表(不报 KeyError)。""" + tmp = tempfile.TemporaryDirectory() + defs_path = _write_defs( + tmp, + { + "March7th-Assistant": [ + {"name": "货币战争"}, + ] + }, + ) + with patch( + "src.service.script_service.get_weekly_list_yml_path_under_root", + return_value=defs_path, + ): + ctrl = _make_controller() + self.assertEqual(ctrl.weekly_dungeon_options("货币战争"), []) + tmp.cleanup() + + def test_weekly_supported_follows_config(self): + """weekly_supported 唯一真相源为 weekly_list.yml:声明即支持,未声明即不支持。""" + # 崩铁声明、鸣潮未声明 + tmp = tempfile.TemporaryDirectory() + defs_path = _write_defs( + tmp, + { + "March7th-Assistant": [ + {"name": "历战余响", "dungeons": ["无"]}, + ] + }, + ) + with patch( + "src.service.script_service.get_weekly_list_yml_path_under_root", + return_value=defs_path, + ): + ctrl_star = _make_controller("March7th-Assistant", "崩铁") + ctrl_ww = _make_controller("ok-ww", "鸣潮") + self.assertTrue(ctrl_star.weekly_supported) + self.assertFalse(ctrl_ww.weekly_supported) + tmp.cleanup() + + def test_weekly_items_empty_for_non_weekly_script(self): + tmp = tempfile.TemporaryDirectory() + # ok-ww 不在 weekly_list.yml 声明 → 空列表 + defs_path = _write_defs(tmp, {}) + with patch( + "src.service.script_service.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, + ): + 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 适配器接口。""" + 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 约定键名) + mock_set.assert_called_once_with("March7th-Assistant", "历战余响", "铁骸的锈冢") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_qml_bridge_taskcard.py b/tests/test_qml_bridge_taskcard.py index ecce8345..56abb81f 100644 --- a/tests/test_qml_bridge_taskcard.py +++ b/tests/test_qml_bridge_taskcard.py @@ -1,9 +1,9 @@ """测试 QmlBridge 任务卡后端(日常副本 / 周常周几)。 复用 test_qml_launcher 的 _make_bridge:用 mock 隔离 ChainService 的 config / -ui_state / 壁纸 I/O;is_adapted / supports_weekly / dungeon_map / parse_dungeon_config -按用例 patch(这些名字实际由 task_card 子模块引用,patch 目标指向 task_card), -验证 QML 任务卡所需的数据与写回行为。 +ui_state / 壁纸 I/O;is_adapted / ScriptService.get_weekly_defs / dungeon_map / +parse_dungeon_config 按用例 patch(这些名字实际由 task_card 子模块引用, +patch 目标指向 task_card),验证 QML 任务卡所需的数据与写回行为。 """ import unittest @@ -18,7 +18,9 @@ class TestTaskCard(unittest.TestCase): """任务卡数据 / 写回:与旧 task_card.py 对齐。""" @patch.object(task_card, "is_adapted", return_value=True) - @patch.object(task_card, "_supports_weekly", return_value=True) + @patch.object( + task_card.ScriptService, "get_weekly_defs", return_value=[{"name": "周常"}] + ) @patch.object(main_window.ChainService, "dungeon_map", return_value={}) def test_daily_text_default_is_placeholder(self, *_): b = _make_bridge() @@ -50,7 +52,7 @@ def test_daily_supported_false_when_no_dungeon_cfg(self, *_): self.assertFalse(b.dailySupported) @patch.object(task_card, "is_adapted", return_value=True) - @patch.object(task_card, "_supports_weekly", return_value=False) + @patch.object(task_card.ScriptService, "get_weekly_defs", return_value=[]) @patch.object(main_window.ChainService, "dungeon_map", return_value={}) def test_select_dungeon_persists(self, *_): b = _make_bridge() @@ -65,7 +67,7 @@ def test_select_dungeon_persists(self, *_): m_save.assert_called_once() @patch.object(task_card, "is_adapted", return_value=True) - @patch.object(task_card, "_supports_weekly", return_value=False) + @patch.object(task_card.ScriptService, "get_weekly_defs", return_value=[]) @patch.object(main_window.ChainService, "dungeon_map", return_value={}) def test_select_dungeon_clear_removes(self, *_): b = _make_bridge() @@ -77,7 +79,9 @@ def test_select_dungeon_clear_removes(self, *_): self.assertEqual(b.dailyDungeonText, "选择副本") @patch.object(task_card, "is_adapted", return_value=True) - @patch.object(task_card, "_supports_weekly", return_value=True) + @patch.object( + task_card.ScriptService, "get_weekly_defs", return_value=[{"name": "周常"}] + ) @patch.object(task_card, "is_weekly_start_reached", return_value=True) @patch.object(main_window.ChainService, "dungeon_map", return_value={}) def test_select_weekly_persists_and_toggle(self, *_): @@ -92,7 +96,7 @@ def test_select_weekly_persists_and_toggle(self, *_): m_save.assert_called_once() @patch.object(task_card, "is_adapted", return_value=True) - @patch.object(task_card, "_supports_weekly", return_value=False) + @patch.object(task_card.ScriptService, "get_weekly_defs", return_value=[]) @patch.object(main_window.ChainService, "dungeon_map", return_value={}) def test_toggle_weekly_memory_only(self, *_): b = _make_bridge() @@ -106,7 +110,9 @@ def test_toggle_weekly_memory_only(self, *_): m_save.assert_not_called() @patch.object(task_card, "is_adapted", return_value=True) - @patch.object(task_card, "_supports_weekly", return_value=True) + @patch.object( + task_card.ScriptService, "get_weekly_defs", return_value=[{"name": "周常"}] + ) @patch.object(main_window.ChainService, "dungeon_map", return_value={}) def test_toggle_master_syncs_weekly_when_supported(self, *_): b = _make_bridge() diff --git a/tests/test_qml_launcher.py b/tests/test_qml_launcher.py index 39f0d869..4935fe4a 100644 --- a/tests/test_qml_launcher.py +++ b/tests/test_qml_launcher.py @@ -399,6 +399,256 @@ def test_main_qml_loads(self): self.assertNotIn("ReferenceError", proc.stderr) +class TestTaskCardPopupGeometry(unittest.TestCase): + """下拉必须完整落在窗口内:超出窗口的部分不可见且滚不到(副本显示不全)。 + + 崩铁历战余响 9 个副本原先锚在周常区下方(卡片 y≈242 → 窗口 y≈634), + 弹窗高 296 越过 720 底边;又因内容(286) < 视口(288) 而无法滚动, + 实际只看得到 3 个。placePopup 在下方装不下时上翻并按余量封顶高度。 + """ + + def test_popups_fit_inside_window(self): + """离屏加载 main.qml,打开两个下拉,断言几何完整落在窗口内。""" + code = textwrap.dedent( + """ + import os + os.environ["QT_QPA_PLATFORM"] = "offscreen" + os.environ["QML_DISABLE_DISK_CACHE"] = "1" + from unittest.mock import patch + from PySide6.QtCore import QUrl, QTimer, QPointF + 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.gui import main_window + from src.gui.icons import UiIconProvider + from src.gui.main_window import QmlBridge + + app = QApplication([]) + # 崩铁:真实 config/weekly_list.yml 里历战余响声明了 9 个副本 + scripts = [{ + "display_name": "崩坏:星穹铁道", + "script_path": "scripts/March7th-Assistant/March7th-Assistant.exe", + "script_type": "external", + }] + 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(main_window.BackgroundController, "resolve_bg", + return_value=None), + ): + bridge = QmlBridge() + qmlRegisterSingletonInstance( + QmlBridge, "OneDragonHelper", 1, 0, "Bridge", bridge) + engine = QQmlApplicationEngine() + engine.addImageProvider("uiicon", UiIconProvider()) + engine.load(QUrl.fromLocalFile(resolve_script_path("src/gui/qml/main.qml"))) + win = engine.rootObjects()[0] + + def report(name): + item = win.findChild(QQuickItem, name) + top = item.mapToScene(QPointF(0, 0)).y() + print(f"{name} TOP {top:.0f} H {item.height():.0f} " + f"WIN {win.height()}", flush=True) + + def measure(): + wk = win.findChild(QQuickItem, "weeklyDungeonPopup") + wk.setProperty("weeklyName", "历战余响") + wk.setProperty("visible", True) + dg = win.findChild(QQuickItem, "dungeonPopup") + dg.setProperty("visible", True) + QTimer.singleShot(200, lambda: (report("weeklyDungeonPopup"), + report("dungeonPopup"), + app.quit())) + + QTimer.singleShot(600, measure) + app.exec() + print("OPTS", len(bridge.weeklyDungeonOptions("历战余响")), flush=True) + """ + ) + proc = subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + text=True, + timeout=60, + cwd=os.getcwd(), + ) + self.assertNotIn("ReferenceError", proc.stderr) + opts_line = [ln for ln in proc.stdout.splitlines() if ln.startswith("OPTS")] + self.assertTrue(opts_line, f"未取到副本数,stdout={proc.stdout}") + n_opts = int(opts_line[0].split()[1]) + self.assertGreater(n_opts, 3, "副本清单应多于 3(否则测不出显示不全)") + + measured = {} + for line in proc.stdout.splitlines(): + parts = line.split() + if len(parts) == 7 and parts[1] == "TOP": + measured[parts[0]] = (int(parts[2]), int(parts[4]), int(parts[6])) + for name in ("weeklyDungeonPopup", "dungeonPopup"): + self.assertIn(name, measured, f"未测到 {name},stdout={proc.stdout}") + top, height, win_h = measured[name] + self.assertGreaterEqual(top, 0, f"{name} 顶部超出窗口上沿") + self.assertLessEqual( + top + height, win_h, f"{name} 底部超出窗口(top={top} h={height})" + ) + # 周常下拉应放得下全部副本(9*32+8=296),不被余量截断 + self.assertEqual(measured["weeklyDungeonPopup"][1], n_opts * 32 + 8) + + +class TestTaskCardWeeklyHiddenForUnsupportedScript(unittest.TestCase): + """无周常的已适配脚本不应显示周常区及总开关(回归 issue)。""" + + def test_weekly_area_hidden_when_not_supported(self): + code = textwrap.dedent( + r""" + from PySide6.QtCore import QTimer, QUrl + from PySide6.QtQml import QQmlApplicationEngine, qmlRegisterSingletonInstance + 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.gui import main_window + from src.gui.icons import UiIconProvider + from src.gui.main_window import QmlBridge + + app = QApplication([]) + # 异环 ok-nte:已适配(在 set_config 注册)但 weekly_list.yml 未声明周常 + scripts = [{ + "display_name": "异环", + "script_path": "scripts/ok-nte/ok-nte.exe", + "script_type": "external", + }] + 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(main_window.BackgroundController, "resolve_bg", + return_value=None), + ): + bridge = QmlBridge() + qmlRegisterSingletonInstance( + QmlBridge, "OneDragonHelper", 1, 0, "Bridge", bridge) + engine = QQmlApplicationEngine() + engine.addImageProvider("uiicon", UiIconProvider()) + engine.load(QUrl.fromLocalFile(resolve_script_path("src/gui/qml/main.qml"))) + win = engine.rootObjects()[0] + + def report(): + wk_area = win.findChild(QQuickItem, "weeklyArea") + card = win.findChild(QQuickItem, "cardRoot") + print(f"WEEKLY_VISIBLE {wk_area.isVisible()} CARD_H {card.height():.0f}") + app.quit() + + # 等 Loader 自动加载第 0 个脚本的任务卡 + QTimer.singleShot(600, report) + app.exec() + """ + ) + proc = subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + text=True, + timeout=60, + cwd=os.getcwd(), + ) + self.assertNotIn("ReferenceError", proc.stderr) + # 输出样例:WEEKLY_VISIBLE false CARD_H 134 + visible = None + height = None + for line in proc.stdout.splitlines(): + if line.startswith("WEEKLY_VISIBLE"): + parts = line.split() + visible = parts[1] + height = int(parts[3]) + self.assertEqual( + visible, "False", f"无周常脚本应隐藏周常区,stdout={proc.stdout}" + ) + # 134 = 标题(54) + 分隔线 + 日常行(56) + 留白,不含周常区 + self.assertEqual( + height, 134, f"无周常脚本卡片高度应为 134,stdout={proc.stdout}" + ) + + +class TestTaskCardWeeklyAreaHeightForSupportedScript(unittest.TestCase): + """有周常的已适配脚本:周常区高度必须由数据模型长度推导(回归 issue)。 + + 历史 bug:周常区高度曾绑定 `weeklyItemsCol.count`,但 Column 类型并无 + count 属性(那是 Repeater 的),绑定求值出错回退为 0,导致卡片背景被 + 算成 134+0+padding、把周常内容截短("背景不够长")。正确来源是 + Bridge.weeklyItems.length(与 Repeater 渲染数一致)。此测试把高度钉死, + 防止再次回退到 Column.count / 写错模型长度。 + """ + + def test_weekly_area_height_matches_item_count(self): + code = textwrap.dedent( + r""" + from PySide6.QtCore import QTimer, QUrl + from PySide6.QtQml import QQmlApplicationEngine, qmlRegisterSingletonInstance + 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.gui import main_window + from src.gui.icons import UiIconProvider + from src.gui.main_window import QmlBridge + + app = QApplication([]) + # 崩铁 March7th-Assistant:weekly_list.yml 声明 2 种周常 + # (货币战争 / 历战余响),历战余响需选副本 + scripts = [{ + "display_name": "崩坏:星穹铁道", + "script_path": "scripts/March7th-Assistant/March7th-Assistant.exe", + "script_type": "external", + }] + 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(main_window.BackgroundController, "resolve_bg", + return_value=None), + ): + bridge = QmlBridge() + qmlRegisterSingletonInstance( + QmlBridge, "OneDragonHelper", 1, 0, "Bridge", bridge) + engine = QQmlApplicationEngine() + engine.addImageProvider("uiicon", UiIconProvider()) + engine.load(QUrl.fromLocalFile(resolve_script_path("src/gui/qml/main.qml"))) + win = engine.rootObjects()[0] + + def report(): + wk_area = win.findChild(QQuickItem, "weeklyArea") + card = win.findChild(QQuickItem, "cardRoot") + print(f"WEEKLY_VISIBLE {wk_area.isVisible()} " + f"WK_H {wk_area.height():.0f} CARD_H {card.height():.0f}") + app.quit() + + QTimer.singleShot(600, report) + app.exec() + """ + ) + proc = subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + text=True, + timeout=60, + cwd=os.getcwd(), + ) + self.assertNotIn("ReferenceError", proc.stderr) + visible = wk_h = card_h = None + for line in proc.stdout.splitlines(): + if line.startswith("WEEKLY_VISIBLE"): + parts = line.split() + visible = parts[1] + wk_h = int(parts[3]) + card_h = int(parts[5]) + self.assertEqual(visible, "True", f"崩铁应显示周常区,stdout={proc.stdout}") + # 周常区 = 头部(36) + 项数(2) * 行高(40) + 底部留白(16) = 132 + # 卡片 = 134 + 周常区 + 卡片底部留白(16) = 282 + self.assertEqual(wk_h, 132, f"周常区高度应=132,stdout={proc.stdout}") + self.assertEqual(card_h, 282, f"卡片高度应=282,stdout={proc.stdout}") + + class TestScriptIconProvider(unittest.TestCase): """图标缓存按 script_name(稳定身份)索引,而非行 index。 @@ -501,7 +751,11 @@ def test_init_from_weekly_start(self): b = _make_bridge() b.task_card._ui_state = {"ok-ww": {"weekly_start": 2}} with ( - patch.object(task_card, "_supports_weekly", return_value=True), + patch.object( + task_card.ScriptService, + "get_weekly_defs", + return_value=[{"name": "周常花园"}], + ), patch.object(task_card, "is_weekly_start_reached", return_value=True), ): states = b.task_card.init_weekly_toggle_states() @@ -510,7 +764,7 @@ def test_init_from_weekly_start(self): def test_unsupported_or_no_start_is_false(self): b = _make_bridge() b.task_card._ui_state = {"ok-ww": {"weekly_start": 2}} - with patch.object(task_card, "_supports_weekly", return_value=False): + with patch.object(task_card.ScriptService, "get_weekly_defs", return_value=[]): states = b.task_card.init_weekly_toggle_states() self.assertEqual(states.get("ok-ww", "absent"), "absent") @@ -526,7 +780,11 @@ def test_bridge_init_populates_toggle_state(self): "load_ui_state", return_value={"ok-ww": {"weekly_start": 2}}, ), - patch.object(task_card, "_supports_weekly", return_value=True), + patch.object( + task_card.ScriptService, + "get_weekly_defs", + return_value=[{"name": "周常花园"}], + ), patch.object(task_card, "is_weekly_start_reached", return_value=True), ): b = QmlBridge() diff --git a/tests/test_script_service.py b/tests/test_script_service.py index 7778018f..0dacc48c 100644 --- a/tests/test_script_service.py +++ b/tests/test_script_service.py @@ -18,9 +18,13 @@ def setUp(self): 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._write_config( {"script_list": [{"display_name": "原神", "script_path": "C:/a.exe"}]} ) + # weekly_timeouts.yml 随包发布、必存在,默认建一个空 {} 文件, + # 贴近真实部署;缺失→{} 的兜底已移除(改 assert 暴露)。 + self._write_weekly({}) patchers = [ patch( "src.service.script_service.require_config_yml_path", @@ -30,6 +34,10 @@ def setUp(self): "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, + ), ] for p in patchers: p.start() @@ -39,6 +47,10 @@ def _write_config(self, data): with open(self.config_path, "w", encoding="utf-8") as f: yaml.dump(data, f, allow_unicode=True, sort_keys=False) + def _write_weekly(self, data): + with open(self.weekly_path, "w", encoding="utf-8") as f: + yaml.dump(data, f, allow_unicode=True, sort_keys=False) + def _read_config(self): with open(self.config_path, encoding="utf-8") as f: return yaml.safe_load(f) @@ -102,9 +114,9 @@ def test_same_name_noop(self): self.assertEqual(self._read_weekly()["a"], [60] * 7) def test_old_entry_missing_noop(self): - """旧名无 weekly 条目 → no-op(不报错,不写文件)。""" + """旧名无 weekly 条目 → no-op(不报错、不改文件,保持空 {})。""" ScriptService().rename_weekly_in_timeouts("none", "b") - self.assertIsNone(self._read_weekly()) + self.assertEqual(self._read_weekly(), {}) class TestEnsureWeeklyEntry(ScriptServiceTestBase): @@ -292,9 +304,9 @@ def test_delete_weekly_keeps_others(self): self.assertEqual(weekly, {"mute": [120] * 7}) def test_delete_weekly_noop_when_absent(self): - """脚本无 weekly 条目时清理为 no-op(不报错)""" + """脚本无 weekly 条目时清理为 no-op(不报错,文件保持空 {})""" ScriptService().delete_weekly("不存在") - self.assertIsNone(self._read_weekly()) + self.assertEqual(self._read_weekly(), {}) if __name__ == "__main__": diff --git a/tests/test_set_config_subclasses.py b/tests/test_set_config_subclasses.py index b69a7089..7466ab32 100644 --- a/tests/test_set_config_subclasses.py +++ b/tests/test_set_config_subclasses.py @@ -647,6 +647,19 @@ def test_set_dungeon_changed_saves(self): cfg.set_dungeon("新本") mock_save.assert_called_once_with({"instance_type": "新本"}) + def test_set_weekly_dungeon_writes_instance_names(self): + """set_weekly_dungeon 写 config.yaml 的 instance_names[周常名],容错建 dict。""" + with patch.object(StarRailConfig, "_init_config"): + cfg = StarRailConfig() + config: dict = {} + with ( + patch.object(cfg, "_load", return_value=config), + patch.object(cfg, "_save") as mock_save, + ): + cfg.set_weekly_dungeon("历战余响", "铁骸的锈冢") + mock_save.assert_called_once() + self.assertEqual(config["instance_names"]["历战余响"], "铁骸的锈冢") + # ============================================================ # 异环 NTEConfig From 2fb9ef22ede013bcce8e8c5ad53d23bd5d8cf1e9 Mon Sep 17 00:00:00 2001 From: winsanyu <973530837@qq.com> Date: Sat, 22 Aug 2026 21:48:18 +0800 Subject: [PATCH 3/5] =?UTF-8?q?=E5=B0=86=E5=91=A8=E5=87=A0=E8=B5=B7?= =?UTF-8?q?=E5=88=B7=E5=91=A8=E6=9C=AC=E7=A7=BB=E5=85=A5=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/weekly_list.yml | 2 +- config/weekly_start.yml | 6 +++ src/cli.py | 19 ++------ src/gui/controllers/game_list.py | 1 - src/gui/controllers/task_card.py | 18 +++---- src/gui/dialogs.py | 28 ++++------- src/service/chain_gen.py | 24 ++++++---- src/service/chain_service.py | 11 +++++ src/service/script_service.py | 78 ++++++++++++++++++++++++++++++- src/utils.py | 12 +++++ tests/test_chain_service.py | 22 ++++++--- tests/test_cli.py | 43 +++++++---------- tests/test_gui_dialogs.py | 53 +++++++++------------ tests/test_qml_bridge_taskcard.py | 11 +++-- tests/test_qml_launcher.py | 11 ++--- tests/test_script_service.py | 73 +++++++++++++++++++++++++++++ tests/test_state.py | 10 ++-- 17 files changed, 287 insertions(+), 135 deletions(-) create mode 100644 config/weekly_start.yml diff --git a/config/weekly_list.yml b/config/weekly_list.yml index 30307edd..3cd090f3 100644 --- a/config/weekly_list.yml +++ b/config/weekly_list.yml @@ -2,7 +2,7 @@ # 结构:每脚本一个列表,每项: # name: 周常展示名(与 dungeon_list.yml 一致用中文名) # dungeons?: 可选副本名清单(存在且有内容即表示该周常需选副本;含「无」表示跳过) -# 周常「周几起」由 gui_state 的 weekly_start 统一控制该脚本全部周常。 +# 周常「周几起」另存于 config/weekly_start.yml({脚本标识: 1~7}),不在此文件。 # 用户已选副本(运行时状态)不在此文件,存于 gui_state.json 的 weekly_dungeons。 # 副本名清单取自各脚本自身配置(如 M7A 的 assets/config/instance_names.json), # 在此静态声明,避免运行时依赖游戏脚本已安装。 diff --git a/config/weekly_start.yml b/config/weekly_start.yml new file mode 100644 index 00000000..4e285a43 --- /dev/null +++ b/config/weekly_start.yml @@ -0,0 +1,6 @@ +# 各脚本的周常起始日(周几起)持久化配置,进 git。 +# 结构:{脚本标识: 1~7},1=周一 ~ 7=周日;缺省即「不设置」。 +# 与周常声明(weekly_list.yml)、周常超时(weekly_timeouts.yml)平级,三套各自独立。 +ok-ww: 3 +OneDragon-Launcher: 1 +March7th-Assistant: 2 diff --git a/src/cli.py b/src/cli.py index fe79baae..7a2db56f 100644 --- a/src/cli.py +++ b/src/cli.py @@ -370,8 +370,9 @@ def _run_generate_chain(args) -> int: ui_state[script_name] = {} ui_state[script_name]["sequence"] = sequence_overrides[script_name] - # 命令行覆盖:--weekly-start 合并到内存 ui_state,并持久化(周几跑是长期配置, - # 同 GUI 改周几起 _set_weekly 语义;与 --dungeon/--sequence 的临时覆盖不同) + # 命令行覆盖:--weekly-start 持久化到 weekly_start.yml + # (周几跑是长期配置,同 GUI 改周几起语义;与 --dungeon/--sequence 的临时覆盖不同)。 + # 校验失败(未知脚本/未支持周常/非整数/越界)在写盘前拦截并返回 1。 try: weekly_overrides = _parse_overrides(args.weekly_start) except ValueError as exc: @@ -404,18 +405,8 @@ def _run_generate_chain(args) -> int: f"--weekly-start 中 {script_name} 的值越界: {start_day}(应为 1~7)", ) return 1 - if script_name not in ui_state: - ui_state[script_name] = {} - ui_state[script_name]["weekly_start"] = start_day - - if weekly_overrides: - # 基于持久化状态(重新读取)合并周几起再落盘, - # 避免把 --dungeon / --sequence 临时覆盖写回 gui_state.json - persist_state = service.load_ui_state() - for script_name, raw in weekly_overrides.items(): - entry = persist_state.setdefault(script_name, {}) - entry["weekly_start"] = int(raw) - service.save_ui_state(persist_state) + # 仅做持久化,不并入内存 ui_state(ui_state 只承载 --dungeon/--sequence 临时覆盖) + service.set_weekly_start(script_name, start_day) out = args.out if out: diff --git a/src/gui/controllers/game_list.py b/src/gui/controllers/game_list.py index 27e3e543..4c2acfdc 100644 --- a/src/gui/controllers/game_list.py +++ b/src/gui/controllers/game_list.py @@ -347,7 +347,6 @@ def configCurrent(self): game["script_data"].get("script_path", ""), None, script_service=self._service._script_service, - chain_service=self._service, ) dialog.delete_requested.connect(self._on_delete_script) if dialog.exec() == QDialog.Accepted: diff --git a/src/gui/controllers/task_card.py b/src/gui/controllers/task_card.py index 039bf635..5ea70bb9 100644 --- a/src/gui/controllers/task_card.py +++ b/src/gui/controllers/task_card.py @@ -89,8 +89,7 @@ def weekly_supported(self) -> bool: def weekly_start_label(self) -> str: """周常起始日文字(周几起),供单脚本配置弹窗显示当前选择。""" game = self._current - saved = self._ui_state.get(game["script_name"], {}) - start_day = saved.get("weekly_start") + start_day = self._script_service.get_weekly_start(game["script_name"]) return "选择周几" if start_day is None else f"{WEEKDAY_NAMES[start_day]}起" @property @@ -234,15 +233,15 @@ def _build_dungeon_options(self, script_name: str) -> list: def init_weekly_toggle_states(self) -> dict: """初始化各脚本周常开关(纯内存 UI 态,不持久化)。 - 已设置「周几起」且今天 >= 起始日 → True,否则 False。 + 已设置「周几起」且今天 >= 起始日 → True,否则 False。周几起来自 + weekly_list.yml(ScriptService 持久化),不再存于 gui_state.json。 """ states: dict[str, bool] = {} for game in self._game_list.games: script_name = game["script_name"] if not self._script_service.get_weekly_defs(script_name): continue - saved = self._ui_state.get(script_name) - weekly_start = saved.get("weekly_start") if saved else None + weekly_start = self._script_service.get_weekly_start(script_name) states[script_name] = weekly_start is not None and is_weekly_start_reached( weekly_start ) @@ -282,14 +281,15 @@ def selectDungeon(self, dungeon_name: str, sequence): @Slot(int) def selectWeekly(self, start_day: int): - """选择周常起始日(持久化 weekly_start;周常开关按「今天>=起始日」置位)。""" + """选择周常起始日(持久化到 weekly_start.yml)。 + + 周常开关按「今天>=起始日」置位(内存态,不随持久化落盘)。 + """ assert start_day in WEEKDAY_NAMES, f"[bridge] 非法周几: {start_day}" script_name = self._current["script_name"] - saved = self._ui_state.setdefault(script_name, {}) - saved["weekly_start"] = start_day + self._script_service.set_weekly_start(script_name, start_day) enabled = is_weekly_start_reached(start_day) self._weekly_toggle_state[script_name] = enabled - self._service.save_ui_state(self._ui_state) self.refresh() @Slot(str, str) diff --git a/src/gui/dialogs.py b/src/gui/dialogs.py index bead954c..7ff589a2 100644 --- a/src/gui/dialogs.py +++ b/src/gui/dialogs.py @@ -398,7 +398,6 @@ def __init__( script_path="", parent=None, script_service=None, - chain_service=None, ): super().__init__(parent) self.setWindowTitle(f"配置 {display_name}") @@ -410,9 +409,6 @@ def __init__( self.display_name = display_name # 展示名 self.script_path = script_path self._script_service = script_service or ScriptService() - # chain_service 用于读写 gui_state.json 的 weekly_start(周几起), - # 与 task_card 的 selectWeekly 走同一持久化路径;不传则跳过周常读写。 - self._chain_service = chain_service self.pending_changes = None # accept() 后供调用方取表单字段与 weekly self.init_ui() @@ -485,7 +481,7 @@ def _path_press(event): grid.addWidget(self._make_label("游戏进程:"), 6, 0) grid.addWidget(self.game_process_input, 6, 1, 1, 2) - # 周几起:仅支持周常的脚本显示(选择落到 gui_state.json 的 weekly_start)。 + # 周几起:仅支持周常的脚本显示(选择落到 weekly_start.yml)。 # 不支持时整行不进布局,超时行上移到行 7,避免空行留白。 self._weekly_start_supported = supports_weekly(self.script_name) timeout_row = 8 if self._weekly_start_supported else 7 @@ -571,11 +567,9 @@ def load_data(self): # 阻塞运行:缺字段视为 True(默认阻塞) self.block_cb.setChecked(script_data.get("block", True)) - # 周几起(从 gui_state.json 读 weekly_start;不支持周常时跳过) - if self._weekly_start_supported and self._chain_service is not None: - ui_state = self._chain_service.load_ui_state() - saved = ui_state.get(self.script_name) - start_day = saved.get("weekly_start") if saved else None + # 周几起(从 weekly_start.yml 读;不支持周常时跳过) + if self._weekly_start_supported: + start_day = self._script_service.get_weekly_start(self.script_name) self.weekly_start_combo.setCurrentIndex( 0 if start_day is None else int(start_day) ) @@ -627,17 +621,13 @@ def save_data(self): text = timeout_edit.text().strip() timeouts.append(int(text) if text else None) - # 周几起:直接写 gui_state.json 的 weekly_start(与 task_card.selectWeekly 同源)。 + # 周几起:持久化到 weekly_start.yml(与 task_card.selectWeekly 同源)。 # index 0 = 不设置(None),1~7 对应周一~周日。 - if self._weekly_start_supported and self._chain_service is not None: + if self._weekly_start_supported: idx = self.weekly_start_combo.currentIndex() - ui_state = self._chain_service.load_ui_state() - saved = ui_state.setdefault(self.script_name, {}) - if idx <= 0: - saved.pop("weekly_start", None) - else: - saved["weekly_start"] = idx - self._chain_service.save_ui_state(ui_state) + self._script_service.set_weekly_start( + self.script_name, None if idx <= 0 else idx + ) self.pending_changes = { "old_script_name": self.script_name, diff --git a/src/service/chain_gen.py b/src/service/chain_gen.py index afaa002c..4d8e4142 100644 --- a/src/service/chain_gen.py +++ b/src/service/chain_gen.py @@ -64,24 +64,29 @@ def _resolve_daily_run(script: dict, weekly_timeouts: dict) -> bool: return True -def _resolve_weekly_start(ui_state: dict, script_name: str) -> int | None: +def _resolve_weekly_start(weekly_start_map: dict, script_name: str) -> int | None: """取脚本的周常起始日(1=周一 ~ 7=周日),未设置返回 None。 周常开关(enabled)是 GUI 内存态,不参与链生成;GUI 与 CLI 统一按 「今天周几 >= 起始日」由 set_config 判断启用/停用写入脚本配置 (与日常副本选择落盘不受日常开关影响的模型一致)。 + 起始日来源为 weekly_start.yml(运行时由 ScriptService 持久化),经 + weekly_start_map 传入,不再来自 gui_state.json。 + Args: - ui_state: gui_state.json 的 UI 状态,key 为脚本唯一标识。 + weekly_start_map: weekly_start.yml 的全量映射({脚本标识: 1~7})。 script_name: 脚本唯一标识(exe 为进程名、python/bat 为 display_name)。 Returns: 周常起始日(1~7),未设置返回 None。 """ - saved = ui_state.get(script_name) - weekly_start = saved.get("weekly_start") if saved else None - if weekly_start is None: + if script_name not in weekly_start_map: return None + weekly_start = weekly_start_map[script_name] + assert isinstance(weekly_start, int), ( + f"[chain_gen] {script_name} 非法 weekly_start: {weekly_start!r}(应为整数 1~7)" + ) assert 1 <= weekly_start <= 7, ( f"[chain_gen] {script_name} 非法 weekly_start: {weekly_start}(应为 1~7)" ) @@ -133,11 +138,12 @@ def generate_chain_config( ui_state: dict | None = None, out_path: str | None = None, weekly_timeouts: dict | None = None, + weekly_start_map: dict | None = None, ) -> str: """生成 ScriptChainer 配置文件(仅含启用的脚本)。 - weekly_timeouts 由调用方(ChainService)通过 ScriptService 加载后传入, - 不再直接读取磁盘文件。 + weekly_timeouts 与 weekly_start_map 均由调用方(ChainService)通过 + ScriptService 加载后传入,不再直接读取磁盘文件。 Args: all_config_data: config.yml 完整数据(含 script_list)。 @@ -146,12 +152,14 @@ def generate_chain_config( ui_state: gui_state.json 的 UI 状态(副本/序列选择),key 为脚本唯一标识。 out_path: 输出路径;None 时默认 config/script_chain/.yml。 weekly_timeouts: weekly_timeouts.yml 的全量字典(默认空 dict)。 + weekly_start_map: weekly_start.yml 的全量映射({脚本标识: 1~7})。 Returns: 输出文件路径。 """ ui_state = ui_state or {} weekly_timeouts = weekly_timeouts or {} + weekly_start_map = weekly_start_map or {} dungeon_map = load_dungeon_map() enabled_dungeons, enabled_sequences = _collect_enabled_selections( @@ -168,7 +176,7 @@ def generate_chain_config( if script_name in enabled_keys: if not _resolve_daily_run(script, weekly_timeouts): continue - weekly_start = _resolve_weekly_start(ui_state, script_name) + weekly_start = _resolve_weekly_start(weekly_start_map, script_name) set_config( script_name, dungeon_name=enabled_dungeons.get(script_name), diff --git a/src/service/chain_service.py b/src/service/chain_service.py index 6f022412..8602b5a4 100644 --- a/src/service/chain_service.py +++ b/src/service/chain_service.py @@ -231,6 +231,7 @@ def generate_chain( 输出文件路径。 """ weekly_timeouts = self._script_service.load_all_weekly() + weekly_start_map = self._script_service.get_weekly_start_map() return _generate_chain_config( all_config_data, enabled_keys, @@ -238,8 +239,18 @@ def generate_chain( ui_state, out_path, weekly_timeouts=weekly_timeouts, + weekly_start_map=weekly_start_map, ) + # ---------- 周常起始日(weekly_start)---------- + + def set_weekly_start(self, script_name: str, start_day: int | None) -> None: + """持久化某脚本的周常起始日到 weekly_start.yml(None 表示「不设置」)。 + + 委托内部 ScriptService,调用方(CLI)不感知底层文件。 + """ + self._script_service.set_weekly_start(script_name, start_day) + def collect_invalid_scripts(self, script_list: list[dict]) -> list[tuple[str, str]]: """收集脚本列表中配置不合法的条目。 diff --git a/src/service/script_service.py b/src/service/script_service.py index 25dcd455..0baea6df 100644 --- a/src/service/script_service.py +++ b/src/service/script_service.py @@ -26,6 +26,7 @@ ) 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, ) @@ -66,11 +67,37 @@ def _dump_weekly(weekly_map: dict) -> None: yaml.dump(weekly_map, f, allow_unicode=True, sort_keys=False) +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}" + ) + with open(weekly_start_path, encoding="utf-8") as f: + data = yaml.safe_load(f) + 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() + with open(weekly_start_path, "w", encoding="utf-8") as f: + yaml.dump(data, f, allow_unicode=True, sort_keys=False) + + def _load_weekly_defs() -> dict: """读取 weekly_list.yml(周常声明配置,进 git,必存在)。 结构:{script_name: [{"name", "dungeons"?}, ...]}。dungeons 存在且有内容即 - 表示该周常需选副本(不再用 needs_instance 布尔字段)。 + 表示该周常需选副本(不再用 needs_instance 布尔字段)。周常起始日(周几起) + 另存于 weekly_start.yml,不在本文件。 """ weekly_list_path = get_weekly_list_yml_path_under_root() assert os.path.exists(weekly_list_path), ( @@ -128,6 +155,55 @@ def get_weekly_defs(self, script_name: str) -> list: defs_map = _load_weekly_defs() return list(defs_map[script_name]) if script_name in defs_map else [] + 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: """按脚本唯一标识读取单个脚本条目。 diff --git a/src/utils.py b/src/utils.py index fc3957c0..9aaaec87 100644 --- a/src/utils.py +++ b/src/utils.py @@ -57,6 +57,18 @@ def get_weekly_list_yml_path_under_root() -> str: return safe_path_join(get_root_dir(), "config", "weekly_list.yml") +def get_weekly_start_yml_path_under_root() -> str: + """ + 获取根目录下的config/weekly_start.yml文件路径。 + + 该文件持久化各脚本的周常起始日(周几起,{脚本标识: 1~7}),与周常声明 + (weekly_list.yml)、周常超时(weekly_timeouts.yml)平级,三套周常侧配置各自独立。 + + :return: 根目录下的config/weekly_start.yml文件路径 + """ + return safe_path_join(get_root_dir(), "config", "weekly_start.yml") + + @lru_cache def get_root_dir() -> str: """ diff --git a/tests/test_chain_service.py b/tests/test_chain_service.py index d2660101..fa2632d7 100644 --- a/tests/test_chain_service.py +++ b/tests/test_chain_service.py @@ -76,6 +76,7 @@ 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: @@ -84,7 +85,13 @@ def test_generate_chain_delegates(self): ) self.assertEqual(out, "out.yml") m.assert_called_once_with( - data, {"A"}, "88", {"A": {}}, "out.yml", weekly_timeouts={} + data, + {"A"}, + "88", + {"A": {}}, + "out.yml", + weekly_timeouts={}, + weekly_start_map={}, ) def test_collect_invalid_scripts_delegates(self): @@ -99,9 +106,9 @@ def test_collect_invalid_scripts_delegates(self): class TestGenerateChainWeeklyStart(unittest.TestCase): - """generate_chain_config:ui_state 的 weekly_start 原样传给 set_config(CLI 兜底,不判断今天)""" + """generate_chain_config:weekly_start_map 的 weekly_start 原样传给 set_config(CLI 兜底,不判断今天)""" - def _run_chain(self, ui_state): + def _run_chain(self, weekly_start_map): from src.service import chain_gen data = { @@ -126,13 +133,16 @@ def _run_chain(self, ui_state): patch("builtins.open"), ): chain_gen.generate_chain_config( - data, {"测试"}, ui_state=ui_state, out_path="out.yml" + data, + {"测试"}, + weekly_start_map=weekly_start_map, + out_path="out.yml", ) return mock_set_config def test_weekly_start_passed_through(self): """weekly_start=4 → set_config 收到(CLI 无 GUI 时按周几兜底)""" - mock = self._run_chain({"测试": {"weekly_start": 4}}) + mock = self._run_chain({"测试": 4}) mock.assert_called_once_with( "测试", dungeon_name=None, @@ -142,7 +152,7 @@ def test_weekly_start_passed_through(self): def test_no_weekly_start_skips(self): """未设置周常 → set_config(weekly_start=None),不处理周常""" - mock = self._run_chain({"测试": {"dungeon": "副本"}}) + mock = self._run_chain({}) mock.assert_called_once_with( "测试", dungeon_name=None, diff --git a/tests/test_cli.py b/tests/test_cli.py index 9d70b544..a188ca18 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -13,7 +13,6 @@ 避免副作用、并使其不依赖本机是否装有游戏。 """ -import copy import json import os import sys @@ -434,13 +433,17 @@ def test_unknown_script_in_dungeon_exits_one(self): self.assertIn("未知的脚本标识", _read_cli_file("generate_chain")) def test_weekly_start_override_merged_into_ui_state(self): - """--weekly-start 覆盖值合并到传给 generate_chain 的 ui_state(int)。""" + """--weekly-start 调用 service.set_weekly_start 持久化(周几跑是长期配置), + 且不并入传给 generate_chain 的内存 ui_state。""" with tempfile.NamedTemporaryFile("w", suffix=".yml", delete=False) as fh: out = fh.name try: - with patch.object( - cli.ChainService, "generate_chain", return_value=out - ) as mock_gen: + with ( + patch.object( + cli.ChainService, "generate_chain", return_value=out + ) as mock_gen, + patch.object(cli.ChainService, "set_weekly_start") as mock_set, + ): _run_main( [ "--generate-chain", @@ -453,31 +456,22 @@ def test_weekly_start_override_merged_into_ui_state(self): ], expect_exit=0, ) + mock_set.assert_called_once_with(self._target, 4) + # 内存 ui_state 不再承载 weekly_start(仅 --dungeon/--sequence 临时覆盖) ui_state = mock_gen.call_args.args[3] - self.assertEqual(ui_state[self._target]["weekly_start"], 4) + self.assertNotIn("weekly_start", ui_state.get(self._target, {})) finally: if os.path.exists(out): os.remove(out) def test_weekly_start_persisted(self): - """--weekly-start 持久化到 gui_state.json(周几跑是长期配置,同 GUI 改周几起), + """--weekly-start 持久化到 weekly_start.yml(set_weekly_start), 且不把 --dungeon 临时覆盖写回。""" - saved = [] - base_state = {self._target: {"dungeon": "基线副本"}} with tempfile.NamedTemporaryFile("w", suffix=".yml", delete=False) as fh: out = fh.name try: with ( - patch.object( - cli.ChainService, - "load_ui_state", - side_effect=lambda: copy.deepcopy(base_state), - ), - patch.object( - cli.ChainService, - "save_ui_state", - side_effect=lambda state: saved.append(state), - ), + patch.object(cli.ChainService, "set_weekly_start") as mock_set, patch.object( cli.ChainService, "generate_chain", return_value=out ) as mock_gen, @@ -496,15 +490,12 @@ def test_weekly_start_persisted(self): ], expect_exit=0, ) - # 落盘版本:含 weekly_start=4,不含 --dungeon 临时覆盖 - self.assertEqual( - saved, - [{self._target: {"dungeon": "基线副本", "weekly_start": 4}}], - ) - # 内存 ui_state(传 generate_chain):含 dungeon 覆盖 + weekly_start + # 周几起经 set_weekly_start 持久化(不再落 gui_state.json) + mock_set.assert_called_once_with(self._target, 4) + # 内存 ui_state(传 generate_chain):含 dungeon 覆盖,不含 weekly_start ui_state = mock_gen.call_args.args[3] self.assertEqual(ui_state[self._target]["dungeon"], "凝素领域") - self.assertEqual(ui_state[self._target]["weekly_start"], 4) + self.assertNotIn("weekly_start", ui_state.get(self._target, {})) finally: if os.path.exists(out): os.remove(out) diff --git a/tests/test_gui_dialogs.py b/tests/test_gui_dialogs.py index 6d071d1e..8775867e 100644 --- a/tests/test_gui_dialogs.py +++ b/tests/test_gui_dialogs.py @@ -248,12 +248,16 @@ def test_save_stores_block_in_pending_changes(self): class _FakeService: """极简 ScriptService 替身:供弹窗构造时读取脚本数据,避免依赖真实 config。""" - def __init__(self, script_type, script_path, display_name="日志分析"): + def __init__( + self, script_type, script_path, display_name="日志分析", weekly_start=None + ): self._data = { "script_type": script_type, "script_path": script_path, "display_name": display_name, } + self._weekly_start = weekly_start + self.saved_weekly_start = None def get_script(self, name): return self._data @@ -261,18 +265,11 @@ def get_script(self, name): def weekly_inputs(self, name): return [3600] * 7 + def get_weekly_start(self, script_name): + return self._weekly_start -class _FakeChainService: - """极简 ChainService 替身:读写 gui_state.json 的 weekly_start(内存态)。""" - - def __init__(self, ui_state=None): - self._ui_state = ui_state or {} - - def load_ui_state(self): - return self._ui_state - - def save_ui_state(self, state): - self._ui_state = state + def set_weekly_start(self, script_name, start_day): + self.saved_weekly_start = start_day class TestSingleScriptConfigDialogOpenConfig(unittest.TestCase): @@ -332,18 +329,17 @@ def test_python_missing_file_shows_msg(self): class TestSingleScriptConfigDialogWeeklyStart(unittest.TestCase): - """测试配置弹窗的「周几起」行:仅支持周常脚本显示,读写 gui_state.json。""" + """测试配置弹窗的「周几起」行:仅支持周常脚本显示,读写 weekly_list.yml。""" - def _make_dialog(self, script_name, display_name, chain_service, supported): + def _make_dialog(self, script_name, display_name, weekly_start, supported): with patch("src.gui.dialogs.supports_weekly", return_value=supported): return SingleScriptConfigDialog( script_name, display_name, "C:/games/run.exe", script_service=_FakeService( - "external", "C:/games/run.exe", display_name + "external", "C:/games/run.exe", display_name, weekly_start ), - chain_service=chain_service, ) def test_hidden_when_weekly_unsupported(self): @@ -353,45 +349,40 @@ def test_hidden_when_weekly_unsupported(self): 位置 (0,0) 绘制并盖住左上角字段。必须 isHidden() 为真(未 show 的 dialog 上 isVisible() 恒为 False,断不出这个 bug)。 """ - dlg = self._make_dialog( - "collect_log", "日志分析", _FakeChainService(), supported=False - ) + dlg = self._make_dialog("collect_log", "日志分析", None, supported=False) self.assertFalse(dlg._weekly_start_supported) self.assertTrue(dlg.weekly_start_combo.isHidden()) def test_visible_and_loaded_when_weekly_supported(self): - """支持周常的脚本周几起行可见,且加载 gui_state 的 weekly_start""" - chain = _FakeChainService({"run": {"weekly_start": 3}}) - dlg = self._make_dialog("run", "鸣潮", chain, supported=True) + """支持周常的脚本周几起行可见,且加载 weekly_start.yml 的 weekly_start""" + dlg = self._make_dialog("run", "鸣潮", 3, supported=True) self.assertTrue(dlg._weekly_start_supported) # 未 show 时 isVisible 受父链影响为 False,用 isHidden 反映自身 visible 属性 self.assertFalse(dlg.weekly_start_combo.isHidden()) # combo index 3 → 周三起 self.assertEqual(dlg.weekly_start_combo.currentIndex(), 3) - def test_save_writes_weekly_start_to_gui_state(self): - """保存时把周几起(周三起)写入 gui_state.json 的 weekly_start""" - chain = _FakeChainService() - dlg = self._make_dialog("run", "鸣潮", chain, supported=True) + def test_save_writes_weekly_start(self): + """保存时把周几起(周三起)经 ScriptService 持久化(写 weekly_start.yml)""" + dlg = self._make_dialog("run", "鸣潮", None, supported=True) dlg.weekly_start_combo.setCurrentIndex(3) with ( patch("src.gui.dialogs.QMessageBox.warning"), patch.object(SingleScriptConfigDialog, "accept"), ): dlg.save_data() - self.assertEqual(chain.load_ui_state()["run"]["weekly_start"], 3) + self.assertEqual(dlg._script_service.saved_weekly_start, 3) def test_save_clears_weekly_start_when_unset(self): - """选择「不设置」时移除 gui_state 的 weekly_start""" - chain = _FakeChainService({"run": {"weekly_start": 5}}) - dlg = self._make_dialog("run", "鸣潮", chain, supported=True) + """选择「不设置」时经 ScriptService 清除(传 None)""" + dlg = self._make_dialog("run", "鸣潮", 5, supported=True) dlg.weekly_start_combo.setCurrentIndex(0) with ( patch("src.gui.dialogs.QMessageBox.warning"), patch.object(SingleScriptConfigDialog, "accept"), ): dlg.save_data() - self.assertNotIn("weekly_start", chain.load_ui_state()["run"]) + self.assertIsNone(dlg._script_service.saved_weekly_start) class TestSingleScriptConfigDialogDelete(unittest.TestCase): diff --git a/tests/test_qml_bridge_taskcard.py b/tests/test_qml_bridge_taskcard.py index 56abb81f..92d26d73 100644 --- a/tests/test_qml_bridge_taskcard.py +++ b/tests/test_qml_bridge_taskcard.py @@ -21,6 +21,7 @@ class TestTaskCard(unittest.TestCase): @patch.object( task_card.ScriptService, "get_weekly_defs", return_value=[{"name": "周常"}] ) + @patch.object(task_card.ScriptService, "get_weekly_start", return_value=None) @patch.object(main_window.ChainService, "dungeon_map", return_value={}) def test_daily_text_default_is_placeholder(self, *_): b = _make_bridge() @@ -84,16 +85,16 @@ def test_select_dungeon_clear_removes(self, *_): ) @patch.object(task_card, "is_weekly_start_reached", return_value=True) @patch.object(main_window.ChainService, "dungeon_map", return_value={}) + @patch.object(task_card.ScriptService, "set_weekly_start") + @patch.object(task_card.ScriptService, "get_weekly_start", return_value=3) def test_select_weekly_persists_and_toggle(self, *_): b = _make_bridge() name = b.games[0]["script_name"] - with patch.object(b.service, "save_ui_state") as m_save: - b.selectWeekly(3) - self.assertEqual(b.task_card._ui_state[name]["weekly_start"], 3) + b.selectWeekly(3) + task_card.ScriptService.set_weekly_start.assert_called_once_with(name, 3) self.assertTrue(b.weeklyOn) self.assertEqual(b.weeklyStartLabel, "周三起") - # 对齐旧 GUI:周常起始日持久化到 gui_state.json - m_save.assert_called_once() + # 周常起始日持久化到 weekly_start.yml(不再经 gui_state) @patch.object(task_card, "is_adapted", return_value=True) @patch.object(task_card.ScriptService, "get_weekly_defs", return_value=[]) diff --git a/tests/test_qml_launcher.py b/tests/test_qml_launcher.py index 4935fe4a..a0b57817 100644 --- a/tests/test_qml_launcher.py +++ b/tests/test_qml_launcher.py @@ -744,18 +744,18 @@ def test_github_icon_paints_at_same_scale_as_others(self): class TestWeeklyToggleInit(unittest.TestCase): - """周常开关应在启动时按 weekly_start 还原(对齐旧 GUI,此前漏迁移)。""" + """周常开关应在启动时按 weekly_start 还原(来源 weekly_start.yml)。""" def test_init_from_weekly_start(self): # 鸣潮 是 exe 脚本,script_name = 进程名 ok-ww(非 display_name) b = _make_bridge() - b.task_card._ui_state = {"ok-ww": {"weekly_start": 2}} with ( patch.object( task_card.ScriptService, "get_weekly_defs", return_value=[{"name": "周常花园"}], ), + patch.object(task_card.ScriptService, "get_weekly_start", return_value=2), patch.object(task_card, "is_weekly_start_reached", return_value=True), ): states = b.task_card.init_weekly_toggle_states() @@ -763,7 +763,6 @@ def test_init_from_weekly_start(self): def test_unsupported_or_no_start_is_false(self): b = _make_bridge() - b.task_card._ui_state = {"ok-ww": {"weekly_start": 2}} with patch.object(task_card.ScriptService, "get_weekly_defs", return_value=[]): states = b.task_card.init_weekly_toggle_states() self.assertEqual(states.get("ok-ww", "absent"), "absent") @@ -775,16 +774,12 @@ def test_bridge_init_populates_toggle_state(self): "load_config", return_value={"script_list": list(_SCRIPTS)}, ), - patch.object( - main_window.ChainService, - "load_ui_state", - return_value={"ok-ww": {"weekly_start": 2}}, - ), patch.object( task_card.ScriptService, "get_weekly_defs", return_value=[{"name": "周常花园"}], ), + patch.object(task_card.ScriptService, "get_weekly_start", return_value=2), patch.object(task_card, "is_weekly_start_reached", return_value=True), ): b = QmlBridge() diff --git a/tests/test_script_service.py b/tests/test_script_service.py index 0dacc48c..241b35eb 100644 --- a/tests/test_script_service.py +++ b/tests/test_script_service.py @@ -19,12 +19,15 @@ def setUp(self): 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", @@ -38,6 +41,10 @@ def setUp(self): "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() @@ -51,6 +58,10 @@ def _write_weekly(self, data): with open(self.weekly_path, "w", encoding="utf-8") as f: yaml.dump(data, f, allow_unicode=True, sort_keys=False) + def _write_weekly_start(self, data): + with open(self.weekly_start_path, "w", encoding="utf-8") as f: + yaml.dump(data, f, allow_unicode=True, sort_keys=False) + def _read_config(self): with open(self.config_path, encoding="utf-8") as f: return yaml.safe_load(f) @@ -61,6 +72,12 @@ def _read_weekly(self): with open(self.weekly_path, encoding="utf-8") as f: return yaml.safe_load(f) + def _read_weekly_start(self): + if not os.path.exists(self.weekly_start_path): + return None + with open(self.weekly_start_path, encoding="utf-8") as f: + return yaml.safe_load(f) + class TestGetScript(ScriptServiceTestBase): def test_get_existing_script(self): @@ -309,5 +326,61 @@ def test_delete_weekly_noop_when_absent(self): 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 断言),但本测试不依赖其内容。 + with open(self.weekly_list_path, "w", encoding="utf-8") as f: + yaml.dump({}, f, allow_unicode=True) + with open(self.weekly_start_path, "w", encoding="utf-8") as f: + yaml.dump({}, f, allow_unicode=True) + 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): + with open(self.weekly_start_path, encoding="utf-8") as f: + return yaml.safe_load(f) + + 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}) + with open(self.weekly_list_path, encoding="utf-8") as f: + self.assertEqual(yaml.safe_load(f), {}) + + 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) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_state.py b/tests/test_state.py index fe4ffd55..5b421b15 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -79,25 +79,23 @@ def test_ten_seconds_still_runs(self, _mock): class TestResolveWeeklyStart(unittest.TestCase): - """_resolve_weekly_start:从 ui_state 取周常起始日(1=周一~7=周日),不判断今天。""" + """_resolve_weekly_start:从 weekly_start_map(weekly_start.yml 全量映射)取周常起始日(1=周一~7=周日),不判断今天。""" def test_missing_weekly_start_returns_none(self): """未设置 weekly_start → None(不处理周常,保持脚本配置原样)""" self.assertIsNone(_resolve_weekly_start({}, "ok-ww")) - self.assertIsNone( - _resolve_weekly_start({"ok-ww": {"dungeon": "无音区"}}, "ok-ww") - ) + self.assertIsNone(_resolve_weekly_start({"other": 4}, "ok-ww")) def test_returns_weekly_start_value(self): """已设置 weekly_start → 返回原值(启用/停用由 set_config 自行判断)""" - result = _resolve_weekly_start({"ok-ww": {"weekly_start": 4}}, "ok-ww") + result = _resolve_weekly_start({"ok-ww": 4}, "ok-ww") self.assertEqual(result, 4) def test_invalid_weekly_start_raises(self): """weekly_start 越界(0 / 8)→ assert""" for bad in (0, 8): with self.subTest(bad=bad), self.assertRaises(AssertionError): - _resolve_weekly_start({"ok-ww": {"weekly_start": bad}}, "ok-ww") + _resolve_weekly_start({"ok-ww": bad}, "ok-ww") if __name__ == "__main__": From cd5889d1e688cba79cbec54a5aec941b3ed5c147 Mon Sep 17 00:00:00 2001 From: winsanyu <973530837@qq.com> Date: Sat, 22 Aug 2026 22:07:35 +0800 Subject: [PATCH 4/5] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E4=B8=8D=E5=BF=85?= =?UTF-8?q?=E8=A6=81=E7=9A=84=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/gui/controllers/task_card.py | 63 ++--------------- src/gui/main_window.py | 20 +----- src/gui/qml/task_card.qml | 110 ++++-------------------------- tests/test_qml_bridge_taskcard.py | 37 +--------- tests/test_qml_launcher.py | 53 ++------------ 5 files changed, 25 insertions(+), 258 deletions(-) diff --git a/src/gui/controllers/task_card.py b/src/gui/controllers/task_card.py index 5ea70bb9..59b99de0 100644 --- a/src/gui/controllers/task_card.py +++ b/src/gui/controllers/task_card.py @@ -1,8 +1,8 @@ """任务卡控制器:日常副本 / 周常周几(数据 + 选择持久化)。 -独立 QObject,自管状态(_ui_state / _dungeon_*_cache / _weekly_toggle_state / -_master_on)。当前游戏经构造注入的 game_list 引用读取。dungeonOptions 从缓存读取 -(build_dungeon_cache 时构建)。 +独立 QObject,自管状态(_ui_state / _dungeon_*_cache)。当前游戏经构造注入的 +game_list 引用读取。dungeonOptions 从缓存读取(build_dungeon_cache 时构建)。 +启用控制不在此处:日常靠控制模式、周常靠周几起(均在别处实现)。 """ from PySide6.QtCore import QObject, Signal, Slot @@ -10,7 +10,6 @@ from src.config.dungeon_config import get_display_name, parse_dungeon_config from src.config.set_config import is_adapted, set_weekly_dungeon from src.service.script_service import ScriptService -from src.utils_weekly import is_weekly_start_reached # 周常「周几以后开始执行」:值 1=周一 ~ 7=周日(对齐 get_week_num 的 0=周一 偏移 +1) WEEKDAY_NAMES = { @@ -41,9 +40,6 @@ def __init__(self, game_list, service, toast, parent=None): # build_dungeon_cache 时一次性构建。 self._dungeon_map_cache: dict = {} self._dungeon_options_cache: dict[str, list] = {} - # 周常开关 UI 态(纯内存,不持久化);总开关为全局 UI 态(驱动日常行开关) - self._weekly_toggle_state: dict[str, bool] = {} - self._master_on = True # ── 读接口(供 QmlBridge 委托)──────────────────────────────────── @property @@ -160,21 +156,6 @@ def weekly_dungeon_options(self, weekly_name: str) -> list[str]: return list(d["dungeons"]) if "dungeons" in d else [] return [] - @property - def master_on(self) -> bool: - """总开关状态(全局 UI 态;驱动日常行开关)。""" - return self._master_on - - @property - def daily_on(self) -> bool: - """日常行开关(镜像总开关,由 toggle_master 驱动)。""" - return self._master_on - - @property - def weekly_on(self) -> bool: - """周常行开关(内存态,由 toggle_master / select_weekly 置位)。""" - return self._weekly_toggle_state.get(self._current["script_name"], False) - @property def dungeon_options(self) -> list: """日常副本下拉数据:[{name, clear, sequences:[{label,value}]}, ...],从缓存读取。""" @@ -230,41 +211,7 @@ def _build_dungeon_options(self, script_name: str) -> list: ) return result - def init_weekly_toggle_states(self) -> dict: - """初始化各脚本周常开关(纯内存 UI 态,不持久化)。 - - 已设置「周几起」且今天 >= 起始日 → True,否则 False。周几起来自 - weekly_list.yml(ScriptService 持久化),不再存于 gui_state.json。 - """ - states: dict[str, bool] = {} - for game in self._game_list.games: - script_name = game["script_name"] - if not self._script_service.get_weekly_defs(script_name): - continue - weekly_start = self._script_service.get_weekly_start(script_name) - states[script_name] = weekly_start is not None and is_weekly_start_reached( - weekly_start - ) - self._weekly_toggle_state = states - return states - # ── 交互 ─────────────────────────────────────────────────────────── - @Slot(bool) - def toggleMaster(self, on: bool): - """总开关:一键同步日常/周本(支持周常时周常开关一并置位)。""" - self._master_on = on - script_name = self._current["script_name"] - if self._script_service.get_weekly_defs(script_name): - self._weekly_toggle_state[script_name] = on - self.taskStateChanged.emit() - - @Slot(bool) - def toggleWeekly(self, on: bool): - """周常开关(内存态,不持久化;与日常开关模型一致)。""" - script_name = self._current["script_name"] - self._weekly_toggle_state[script_name] = on - self.taskStateChanged.emit() - @Slot(str, "QVariant") def selectDungeon(self, dungeon_name: str, sequence): """选择日常副本(持久化到 gui_state.json 的 dungeon/sequence)。""" @@ -283,13 +230,11 @@ def selectDungeon(self, dungeon_name: str, sequence): def selectWeekly(self, start_day: int): """选择周常起始日(持久化到 weekly_start.yml)。 - 周常开关按「今天>=起始日」置位(内存态,不随持久化落盘)。 + 周常是否启用由「今天>=起始日」在链生成时独立计算,本方法只持久化该起始日。 """ assert start_day in WEEKDAY_NAMES, f"[bridge] 非法周几: {start_day}" script_name = self._current["script_name"] self._script_service.set_weekly_start(script_name, start_day) - enabled = is_weekly_start_reached(start_day) - self._weekly_toggle_state[script_name] = enabled self.refresh() @Slot(str, str) diff --git a/src/gui/main_window.py b/src/gui/main_window.py index acdc5329..60ba617c 100644 --- a/src/gui/main_window.py +++ b/src/gui/main_window.py @@ -76,9 +76,8 @@ def __init__(self): self.launch.toastRequested.connect(self.toastRequested.emit) self.links.toastRequested.connect(self.toastRequested.emit) - # 编排启动:重建列表 → 构建副本缓存 → 还原周常开关 → 刷新当前 + # 编排启动:重建列表 → 构建副本缓存 → 刷新当前 self._reload_games() - self.task_card.init_weekly_toggle_states() self._on_current_changed() # ── QML 属性(委托到子控制器)──────────────────────────────────── @@ -135,15 +134,6 @@ def __init__(self): lambda self: self.task_card.weekly_items, notify=taskStateChanged, ) - masterOn = Property( - bool, lambda self: self.task_card.master_on, notify=taskStateChanged - ) - dailyOn = Property( - bool, lambda self: self.task_card.daily_on, notify=taskStateChanged - ) - weeklyOn = Property( - bool, lambda self: self.task_card.weekly_on, notify=taskStateChanged - ) dungeonOptions = Property( "QVariantList", lambda self: self.task_card.dungeon_options, @@ -233,14 +223,6 @@ def minimize(self): def closeWindow(self): self.window.closeWindow() - @Slot(bool) - def toggleMaster(self, on): - self.task_card.toggleMaster(on) - - @Slot(bool) - def toggleWeekly(self, on): - self.task_card.toggleWeekly(on) - @Slot(str, "QVariant") def selectDungeon(self, name, seq): self.task_card.selectDungeon(name, seq) diff --git a/src/gui/qml/task_card.qml b/src/gui/qml/task_card.qml index fe67b364..f9f11ace 100644 --- a/src/gui/qml/task_card.qml +++ b/src/gui/qml/task_card.qml @@ -3,9 +3,9 @@ import OneDragonHelper 1.0 // 任务调度卡(日常副本 / 周常):复刻旧 src/gui/task_card.py 的视觉与行为契约。 // 数据经 Bridge 暴露:taskTitle / taskAdapted / weeklySupported / dailyDungeonText / -// weeklyItems / masterOn / dailyOn / weeklyOn / dungeonOptions; -// 写回经 selectDungeon / selectWeeklyDungeon / toggleMaster / toggleWeekly -// (dungeon/sequence/weekly_start 持久化到 gui_state.json;开关内存态)。 +// weeklyItems / dungeonOptions;写回经 selectDungeon / selectWeeklyDungeon / selectWeekly。 +// dungeon/sequence 持久化到 gui_state.json;周几起(weekly_start)持久化到 weekly_start.yml。 +// 启用控制不在此卡:日常靠控制模式、周常靠周几起(均在别处实现),本卡只做副本选择。 // // 「周几起」选择已迁至单脚本配置弹窗(≡ 按钮打开),本卡只显示周常名占位。 // @@ -15,10 +15,10 @@ import OneDragonHelper 1.0 // // 显隐规则(对齐旧 _set_task_rows_visible / _refresh_weekly_chip): // - taskAdapted 为假 → 仅显示标题,隐藏分隔线/两行(卡片收缩)。 -// - 周常行:weeklySupported 为真才亮蓝可点;否则整行置灰、chip 写「未支持」、开关禁用。 +// - 周常区:weeklySupported 为真才显示子项;否则整区隐藏。 // // 颜色约定:日常行与周常行共用同一套色板(标题白 / 图标底蓝 / 图标字蓝 / chip 文字蓝), -// 保证两行视觉一致(旧版周常标题用了淡蓝与日常白色不一致,已统一)。 +// 保证两行视觉一致。 // // 下拉用纯 QML 自绘(不引 QtQuick.Controls 的 Menu 组件类型):项目早期在 // 自定义 .qml 组件类型解析上有非确定失败(Type unavailable),自绘更稳。 @@ -26,9 +26,9 @@ Item { id: cardRoot objectName: "cardRoot" width: 480 - // 高度随适配态:未适配 84(仅标题);适配时标题(54)+分隔线+日常(56)+周常区动态高度。 - // 该脚本不支持周常时,周常区整区隐藏,高度直接缩到日常区以下。 - // 周常区含独立头部(36)+ 每项(40)+ 底部留白(16),卡片底部再留 16。 + // 高度随适配态:未适配 84(仅标题);适配时标题+分隔线+日常(56)占 134, + // 再加周常区动态高度 + 卡片底部留白(16)。不支持周常时周常区隐藏,高度缩到 134。 + // 周常区 = 每项(40) * 项数 + 底部留白(16)。 height: Bridge.taskAdapted ? (weeklyArea.visible ? (134 + weeklyArea.height + 16) : 134) : 84 // 窗口边界在卡片坐标系中的常量:卡片由 main.qml 的 Loader 固定在 (128, 392), @@ -77,25 +77,6 @@ Item { text: Bridge.taskTitle color: "#FFFFFF"; font.pixelSize: 19; font.weight: Font.Bold } - // 总开关(一键同步日常/周本;对齐旧 master_toggle) - Item { - id: masterToggle - x: 388; y: 7; width: 40; height: 22 - property bool on: Bridge.masterOn - Rectangle { - anchors.fill: parent; radius: 11 - color: masterToggle.on ? "#2196F3" : "#2A3850" - } - Rectangle { - x: masterToggle.on ? 20 : 2; y: 2; width: 18; height: 18; radius: 9 - color: "#FFFFFF" - Behavior on x { NumberAnimation { duration: 120 } } - } - MouseArea { - anchors.fill: parent - onClicked: Bridge.toggleMaster(!masterToggle.on) - } - } } // 分隔线(仅适配时显示) @@ -121,7 +102,7 @@ Item { } Rectangle { id: dailyChip - x: 130; y: 15; width: 120; height: 26; radius: 13 + x: 130; y: 15; width: 300; height: 26; radius: 13 color: "#0F1A2E"; border.width: 1; border.color: "#33517A" Text { anchors.centerIn: parent; text: Bridge.dailyDungeonText @@ -139,32 +120,12 @@ Item { } } } - // 每日任务开关(镜像总开关;点击即切总开关,对齐旧 daily_toggle 不独立接线) - Item { - id: dailyToggle - x: 388; y: 17; width: 40; height: 22 - property bool on: Bridge.masterOn - Rectangle { - anchors.fill: parent; radius: 11 - color: dailyToggle.on ? "#2196F3" : "#2A3850" - } - Rectangle { - x: dailyToggle.on ? 20 : 2; y: 2; width: 18; height: 18; radius: 9 - color: "#FFFFFF" - Behavior on x { NumberAnimation { duration: 120 } } - } - MouseArea { - anchors.fill: parent - onClicked: Bridge.toggleMaster(!dailyToggle.on) - } - } } // ── 周常区(y=134 起;不支持周常时整区隐藏)── // 颜色与每日任务行共用同一色板(白标题 / 蓝图标底 / 蓝图标字 / 蓝 chip 字)。 - // 布局改为「头部行 + 周常列表」:头部左侧显示「周常」并放置总开关, - // 避免原先总开关贴在第一项右侧、看起来只控制第一项的歧义。 - // 每项统一左对齐日常 chip(x=130),保证两行选项在同一垂线上。 + // 仅列出各周常子项(如「货币战争」「历战余响」),父分类标题已去除; + // 每项统一左对齐每日 chip(x=130),与日常行选项在同一垂线上。 Item { id: weeklyArea objectName: "weeklyArea" @@ -174,55 +135,12 @@ Item { property int rowH: 40 // 高度由数据模型长度推导:Column 无 count 属性(那是 Repeater 的), // 用 Bridge.weeklyItems.length 才可靠;每项固定 rowH。 - height: weeklyHeader.height + Bridge.weeklyItems.length * rowH + 16 - - // 周常头部:📅 图标 + 「周常」标题 + 总开关 - Item { - id: weeklyHeader - x: 0; y: 0; width: parent.width; height: 36 - Rectangle { - x: 12; y: 5; width: 36; height: 26; radius: 8 - color: weeklyArea.supported ? "#1A3A7A" : "#2A3040" - Text { - anchors.centerIn: parent; text: "📅" - color: weeklyArea.supported ? "#7DA8FF" : "#4A5568" - font.pixelSize: 14 - } - } - Text { - x: 58; y: 8; width: 64; height: 22 - text: "周常" - color: weeklyArea.supported ? "#FFFFFF" : "#4A5568" - font.pixelSize: 15; font.weight: Font.DemiBold - verticalAlignment: Text.AlignVCenter - } - // 周常总开关(内存态,由 toggleMaster / selectWeekly 置位) - Item { - id: weeklyToggle - x: 388; y: 7; width: 40; height: 22 - property bool on: Bridge.weeklyOn - Rectangle { - anchors.fill: parent; radius: 11 - color: !weeklyArea.supported ? "#2A3040" - : (weeklyToggle.on ? "#2196F3" : "#2A3850") - } - Rectangle { - x: weeklyToggle.on ? 20 : 2; y: 2; width: 18; height: 18; radius: 9 - color: "#FFFFFF" - Behavior on x { NumberAnimation { duration: 120 } } - } - MouseArea { - anchors.fill: parent - enabled: weeklyArea.supported - onClicked: Bridge.toggleWeekly(!weeklyToggle.on) - } - } - } + height: Bridge.weeklyItems.length * rowH + 16 // 周常列表:每种一行 Column { id: weeklyItemsCol - y: weeklyHeader.height + y: 0 width: parent.width spacing: 0 Repeater { @@ -248,7 +166,7 @@ Item { } Rectangle { id: wkChip - x: 130; y: 7; width: 120; height: 26; radius: 13 + x: 130; y: 7; width: 300; height: 26; radius: 13 visible: hasDungeon color: weeklyArea.supported ? "#0F1A2E" : "#1A2028" border.width: 1 diff --git a/tests/test_qml_bridge_taskcard.py b/tests/test_qml_bridge_taskcard.py index 92d26d73..dd39b784 100644 --- a/tests/test_qml_bridge_taskcard.py +++ b/tests/test_qml_bridge_taskcard.py @@ -83,52 +83,17 @@ def test_select_dungeon_clear_removes(self, *_): @patch.object( task_card.ScriptService, "get_weekly_defs", return_value=[{"name": "周常"}] ) - @patch.object(task_card, "is_weekly_start_reached", return_value=True) @patch.object(main_window.ChainService, "dungeon_map", return_value={}) @patch.object(task_card.ScriptService, "set_weekly_start") @patch.object(task_card.ScriptService, "get_weekly_start", return_value=3) - def test_select_weekly_persists_and_toggle(self, *_): + def test_select_weekly_persists(self, *_): b = _make_bridge() name = b.games[0]["script_name"] b.selectWeekly(3) task_card.ScriptService.set_weekly_start.assert_called_once_with(name, 3) - self.assertTrue(b.weeklyOn) self.assertEqual(b.weeklyStartLabel, "周三起") # 周常起始日持久化到 weekly_start.yml(不再经 gui_state) - @patch.object(task_card, "is_adapted", return_value=True) - @patch.object(task_card.ScriptService, "get_weekly_defs", return_value=[]) - @patch.object(main_window.ChainService, "dungeon_map", return_value={}) - def test_toggle_weekly_memory_only(self, *_): - b = _make_bridge() - name = b.games[0]["script_name"] - with patch.object(b.service, "save_ui_state") as m_save: - b.toggleWeekly(True) - self.assertTrue(b.weeklyOn) - # 不持久化(脚本未支持周常也不写 weekly_start) - self.assertNotIn("weekly_start", b.task_card._ui_state.get(name, {})) - # 对齐旧 GUI:周常开关是纯内存态,不写 gui_state.json - m_save.assert_not_called() - - @patch.object(task_card, "is_adapted", return_value=True) - @patch.object( - task_card.ScriptService, "get_weekly_defs", return_value=[{"name": "周常"}] - ) - @patch.object(main_window.ChainService, "dungeon_map", return_value={}) - def test_toggle_master_syncs_weekly_when_supported(self, *_): - b = _make_bridge() - with patch.object(b.service, "save_ui_state") as m_save: - b.toggleMaster(False) - self.assertFalse(b.masterOn) - self.assertFalse(b.dailyOn) - self.assertFalse(b.weeklyOn) - b.toggleMaster(True) - self.assertTrue(b.masterOn) - self.assertTrue(b.dailyOn) - self.assertTrue(b.weeklyOn) - # 对齐旧 GUI:总开关是全局 UI 态,不持久化 - m_save.assert_not_called() - @patch.object(task_card, "is_adapted", return_value=True) @patch.object( task_card, diff --git a/tests/test_qml_launcher.py b/tests/test_qml_launcher.py index a0b57817..658252c8 100644 --- a/tests/test_qml_launcher.py +++ b/tests/test_qml_launcher.py @@ -27,7 +27,7 @@ from PySide6.QtWidgets import QApplication # noqa: E402 from src.gui import main_window # noqa: E402 -from src.gui.controllers import launch, links, task_card # noqa: E402 +from src.gui.controllers import launch, links # noqa: E402 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 @@ -643,10 +643,10 @@ def report(): wk_h = int(parts[3]) card_h = int(parts[5]) self.assertEqual(visible, "True", f"崩铁应显示周常区,stdout={proc.stdout}") - # 周常区 = 头部(36) + 项数(2) * 行高(40) + 底部留白(16) = 132 - # 卡片 = 134 + 周常区 + 卡片底部留白(16) = 282 - self.assertEqual(wk_h, 132, f"周常区高度应=132,stdout={proc.stdout}") - self.assertEqual(card_h, 282, f"卡片高度应=282,stdout={proc.stdout}") + # 周常区 = 项数(2) * 行高(40) + 底部留白(16) = 96(父标题已去除) + # 卡片 = 134 + 周常区 + 卡片底部留白(16) = 246 + self.assertEqual(wk_h, 96, f"周常区高度应=96,stdout={proc.stdout}") + self.assertEqual(card_h, 246, f"卡片高度应=246,stdout={proc.stdout}") class TestScriptIconProvider(unittest.TestCase): @@ -743,48 +743,5 @@ def test_github_icon_paints_at_same_scale_as_others(self): self.assertLessEqual(span, 32) -class TestWeeklyToggleInit(unittest.TestCase): - """周常开关应在启动时按 weekly_start 还原(来源 weekly_start.yml)。""" - - def test_init_from_weekly_start(self): - # 鸣潮 是 exe 脚本,script_name = 进程名 ok-ww(非 display_name) - b = _make_bridge() - with ( - patch.object( - task_card.ScriptService, - "get_weekly_defs", - return_value=[{"name": "周常花园"}], - ), - patch.object(task_card.ScriptService, "get_weekly_start", return_value=2), - patch.object(task_card, "is_weekly_start_reached", return_value=True), - ): - states = b.task_card.init_weekly_toggle_states() - self.assertEqual(states.get("ok-ww"), True) - - def test_unsupported_or_no_start_is_false(self): - b = _make_bridge() - with patch.object(task_card.ScriptService, "get_weekly_defs", return_value=[]): - states = b.task_card.init_weekly_toggle_states() - self.assertEqual(states.get("ok-ww", "absent"), "absent") - - def test_bridge_init_populates_toggle_state(self): - with ( - patch.object( - main_window.ChainService, - "load_config", - return_value={"script_list": list(_SCRIPTS)}, - ), - patch.object( - task_card.ScriptService, - "get_weekly_defs", - return_value=[{"name": "周常花园"}], - ), - patch.object(task_card.ScriptService, "get_weekly_start", return_value=2), - patch.object(task_card, "is_weekly_start_reached", return_value=True), - ): - b = QmlBridge() - self.assertEqual(b.task_card._weekly_toggle_state.get("ok-ww"), True) - - if __name__ == "__main__": unittest.main() From 86fd0a2f0a533f47c6067eb10895ef8f9f600f50 Mon Sep 17 00:00:00 2001 From: winsanyu <973530837@qq.com> Date: Sat, 22 Aug 2026 22:48:57 +0800 Subject: [PATCH 5/5] =?UTF-8?q?=E7=BE=8E=E5=8C=96GUI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/gui/qml/task_card.qml | 59 ++++++++++++++++++++++++++------------ tests/test_qml_launcher.py | 14 ++++----- 2 files changed, 47 insertions(+), 26 deletions(-) diff --git a/src/gui/qml/task_card.qml b/src/gui/qml/task_card.qml index f9f11ace..80e98a5e 100644 --- a/src/gui/qml/task_card.qml +++ b/src/gui/qml/task_card.qml @@ -26,10 +26,14 @@ Item { id: cardRoot objectName: "cardRoot" width: 480 - // 高度随适配态:未适配 84(仅标题);适配时标题+分隔线+日常(56)占 134, - // 再加周常区动态高度 + 卡片底部留白(16)。不支持周常时周常区隐藏,高度缩到 134。 - // 周常区 = 每项(40) * 项数 + 底部留白(16)。 - height: Bridge.taskAdapted ? (weeklyArea.visible ? (134 + weeklyArea.height + 16) : 134) : 84 + // 副本/周常 chip 水平位置:在标签(58+64)右侧剩余空间内居中, + // 使选项卡片在横向空白块内左右留白一致。 + readonly property int chipX: 181 + // 高度随适配态:未适配 84(仅标题);适配时标题+分隔线+日常(56)占 128, + // 再加周常区动态高度 + 卡片底部留白(16)。不支持周常时周常区隐藏,高度缩到 128。 + // 周常区 = 每项(56) * 项数 + 底部留白(16)。 + // 周常区上沿(128)与每日行底(124)对齐其它段间距,纵向节奏统一(文字块间均 34px)。 + height: Bridge.taskAdapted ? (weeklyArea.visible ? (128 + weeklyArea.height + 16) : 128) : 84 // 窗口边界在卡片坐标系中的常量:卡片由 main.qml 的 Loader 固定在 (128, 392), // 窗口固定 1280x720(popupCatcher 用的是同一套常量)。下拉据此判断上下余量。 @@ -102,10 +106,17 @@ Item { } Rectangle { id: dailyChip - x: 130; y: 15; width: 300; height: 26; radius: 13 + x: cardRoot.chipX; y: 15 + width: 200 + height: 26; radius: 13 color: "#0F1A2E"; border.width: 1; border.color: "#33517A" Text { - anchors.centerIn: parent; text: Bridge.dailyDungeonText + anchors.fill: parent + leftPadding: 12; rightPadding: 12 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + text: Bridge.dailyDungeonText color: "#7DA8FF"; font.pixelSize: 11 } MouseArea { @@ -125,14 +136,14 @@ Item { // ── 周常区(y=134 起;不支持周常时整区隐藏)── // 颜色与每日任务行共用同一色板(白标题 / 蓝图标底 / 蓝图标字 / 蓝 chip 字)。 // 仅列出各周常子项(如「货币战争」「历战余响」),父分类标题已去除; - // 每项统一左对齐每日 chip(x=130),与日常行选项在同一垂线上。 + // 每行尺寸、图标、文字、chip 的 y 都与「每日任务」行严格对齐,保证视觉统一。 Item { id: weeklyArea objectName: "weeklyArea" - x: 20; y: 134; width: 440 + x: 20; y: 128; width: 440 visible: Bridge.taskAdapted && Bridge.weeklySupported property bool supported: Bridge.weeklySupported - property int rowH: 40 + property int rowH: 56 // 高度由数据模型长度推导:Column 无 count 属性(那是 Repeater 的), // 用 Bridge.weeklyItems.length 才可靠;每项固定 rowH。 height: Bridge.weeklyItems.length * rowH + 16 @@ -149,7 +160,7 @@ Item { width: weeklyArea.width; height: weeklyArea.rowH property bool hasDungeon: modelData.has_dungeon Rectangle { - x: 12; y: 7; width: 36; height: 26; radius: 8 + x: 12; y: 10; width: 36; height: 36; radius: 10 color: weeklyArea.supported ? "#1A3A7A" : "#2A3040" Text { anchors.centerIn: parent; text: "📅" @@ -158,21 +169,27 @@ Item { } } Text { - x: 58; y: 9; width: 64; height: 22 + x: 58; y: 15; width: 64; height: 26 text: modelData.name color: weeklyArea.supported ? "#FFFFFF" : "#4A5568" - font.pixelSize: 14; font.weight: Font.DemiBold + font.pixelSize: 15; font.weight: Font.Bold verticalAlignment: Text.AlignVCenter } Rectangle { id: wkChip - x: 130; y: 7; width: 300; height: 26; radius: 13 + x: cardRoot.chipX; y: 15 + width: 200 + height: 26; radius: 13 visible: hasDungeon color: weeklyArea.supported ? "#0F1A2E" : "#1A2028" border.width: 1 border.color: weeklyArea.supported ? "#33517A" : "#2A3850" Text { - anchors.centerIn: parent + anchors.fill: parent + leftPadding: 12; rightPadding: 12 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight text: modelData.dungeon_label color: weeklyArea.supported ? "#7DA8FF" : "#4A5568" font.pixelSize: 11 @@ -183,6 +200,9 @@ Item { enabled: weeklyArea.supported onClicked: { weeklyDungeonPopup.weeklyName = modelData.name + var rowTop = weeklyArea.y + index * weeklyArea.rowH + weeklyDungeonPopup.anchorTop = rowTop + weeklyDungeonPopup.anchorBottom = rowTop + weeklyArea.rowH weeklyDungeonPopup.visible = !weeklyDungeonPopup.visible dungeonPopup.visible = false } @@ -364,19 +384,21 @@ Item { } // ── 周常副本下拉(单级:副本名列表,来自 Bridge.weeklyDungeonOptions)── - // 复用 dungeonPopup 的测量/封顶滚动模式,宽度按内容一次性算定。 + // 锚点从整个周常区底部改为被点击的具体子项行,弹出位置与点击行对齐。 Item { id: weeklyDungeonPopup objectName: "weeklyDungeonPopup" z: 100 visible: false - x: 130 + x: cardRoot.chipX y: weeklyDungeonPopup.popupY width: instW + 8 height: popupHeight property string weeklyName: "" property int instW: 200 - property int popupY: weeklyArea.y + weeklyArea.height + 4 + property int anchorTop: weeklyArea.y + property int anchorBottom: weeklyArea.y + weeklyArea.height + property int popupY: anchorBottom + 4 property int popupHeight: 360 property int viewportH: height - 8 @@ -393,8 +415,7 @@ Item { if (instTm.width > maxW) maxW = instTm.width } instW = Math.min(maxW + 28, 240) - var geom = cardRoot.placePopup( - weeklyArea.y, weeklyArea.y + weeklyArea.height, + var geom = cardRoot.placePopup(anchorTop, anchorBottom, Math.min(opts.length * 32 + 8, 360)) popupY = geom.y popupHeight = geom.h diff --git a/tests/test_qml_launcher.py b/tests/test_qml_launcher.py index 658252c8..7c80056c 100644 --- a/tests/test_qml_launcher.py +++ b/tests/test_qml_launcher.py @@ -492,7 +492,7 @@ def measure(): self.assertLessEqual( top + height, win_h, f"{name} 底部超出窗口(top={top} h={height})" ) - # 周常下拉应放得下全部副本(9*32+8=296),不被余量截断 + # 周常下拉高度 = 选项数 * 32 + 8,应完整放下不被截断 self.assertEqual(measured["weeklyDungeonPopup"][1], n_opts * 32 + 8) @@ -564,9 +564,9 @@ def report(): self.assertEqual( visible, "False", f"无周常脚本应隐藏周常区,stdout={proc.stdout}" ) - # 134 = 标题(54) + 分隔线 + 日常行(56) + 留白,不含周常区 + # 128 = 标题+分隔线+日常行(56) 的固定高度,不含周常区(与周常上沿对齐) self.assertEqual( - height, 134, f"无周常脚本卡片高度应为 134,stdout={proc.stdout}" + height, 128, f"无周常脚本卡片高度应为 128,stdout={proc.stdout}" ) @@ -643,10 +643,10 @@ def report(): wk_h = int(parts[3]) card_h = int(parts[5]) self.assertEqual(visible, "True", f"崩铁应显示周常区,stdout={proc.stdout}") - # 周常区 = 项数(2) * 行高(40) + 底部留白(16) = 96(父标题已去除) - # 卡片 = 134 + 周常区 + 卡片底部留白(16) = 246 - self.assertEqual(wk_h, 96, f"周常区高度应=96,stdout={proc.stdout}") - self.assertEqual(card_h, 246, f"卡片高度应=246,stdout={proc.stdout}") + # 周常区 = 项数(2) * 行高(56) + 底部留白(16) = 128 + # 卡片 = 128 + 周常区 + 卡片底部留白(16) = 272 + self.assertEqual(wk_h, 128, f"周常区高度应=128,stdout={proc.stdout}") + self.assertEqual(card_h, 272, f"卡片高度应=272,stdout={proc.stdout}") class TestScriptIconProvider(unittest.TestCase):