diff --git a/README.ja.md b/README.ja.md index c452be9..c003b67 100644 --- a/README.ja.md +++ b/README.ja.md @@ -21,7 +21,12 @@ v0.1.0を公開済みです。 - PySide6によるデスクトップGUI - JSONによるルール保存・読み込み +- `rules/*.json` によるルールセット作成・切り替え - ルールの追加・編集・削除 +- ルールの複製・並び替え +- ルール一覧での最終状態・スコア表示 +- 検知画像プレビュー +- スクリーンショットからの検知画像切り出し - 画面上での探索範囲選択 - 検知画像上でのクリック位置指定 - マルチディスプレイ対応 @@ -145,7 +150,9 @@ GitHub Releasesでは、Windows向けzipを配布しています。 8. ルールを保存する。 9. `Test Detection`でクリックなしの検知確認を行う。 10. `Start`で実行する。 -11. `Stop`で停止する。 +11. `Stop`または`Esc`で停止する。 + +メイン画面上部の`Rule Set`からルールセットを切り替えられます。`New`で空のルールセットを`rules`フォルダ内に作成できます。 ## ルール例 diff --git a/README.md b/README.md index c0c2d73..bcf93a5 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,12 @@ Current implementation includes: - PySide6 desktop GUI - JSON rule loading and saving +- Rule set creation and switching from `rules/*.json` - Rule add, edit, and delete +- Rule duplication and reordering +- Last status and score display in the rule list +- Detection image preview +- Detection image capture from screenshots - Screen region selection - Click position selection on the template image - Multi-monitor region support @@ -149,7 +154,9 @@ GitHub Releases can be created from tags such as `v0.1.0-alpha.1`. The release w 8. Save the rule. 9. Use `Test Detection` to verify matching without clicking. 10. Click `Start` to run the macro loop. -11. Click `Stop` to stop execution. +11. Click `Stop` or press `Esc` to stop execution. + +Use the `Rule Set` dropdown in the main window to switch rule sets. Click `New` to create an empty rule set under the `rules` directory. ## Rule Example diff --git a/app/detector.py b/app/detector.py index 36888e2..a643ee0 100644 --- a/app/detector.py +++ b/app/detector.py @@ -48,6 +48,14 @@ def __init__(self, base_dir: str | Path = ".") -> None: def detect(self, screenshot: np.ndarray, rule: Rule) -> MatchResult | None: """Return the best match when it meets the rule confidence.""" + match = self.find_best_match(screenshot, rule) + if match is None or match.score < rule.confidence: + return None + + return match + + def find_best_match(self, screenshot: np.ndarray, rule: Rule) -> MatchResult | None: + """Return the best match even when it is below the rule confidence.""" origin_x = 0 origin_y = 0 if isinstance(screenshot, CapturedScreenshot): @@ -67,9 +75,6 @@ def detect(self, screenshot: np.ndarray, rule: Rule) -> MatchResult | None: result = self._match_template(region_image, template) _, max_score, _, max_location = cv2.minMaxLoc(result) - if max_score < rule.confidence: - return None - match_x = rule.region.x + max_location[0] match_y = rule.region.y + max_location[1] return MatchResult( diff --git a/app/rule_operations.py b/app/rule_operations.py index cb7fa0d..207b251 100644 --- a/app/rule_operations.py +++ b/app/rule_operations.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import replace from pathlib import Path from app.models import Rule, RuleSet @@ -36,6 +37,59 @@ def remove_rule(rule_set: RuleSet, index: int) -> RuleSet: return RuleSet(version=rule_set.version, rules=rules) +def duplicate_rule(rule_set: RuleSet, index: int) -> RuleSet: + """Return a new RuleSet with the selected rule copied after itself.""" + if index < 0 or index >= len(rule_set.rules): + raise RuleOperationError("rule index is out of range") + + rules = list(rule_set.rules) + original = rules[index] + duplicated = replace( + original, + name=_copy_rule_name(original.name, {rule.name for rule in rules}), + ) + rules.insert(index + 1, duplicated) + return RuleSet(version=rule_set.version, rules=rules) + + +def move_rule(rule_set: RuleSet, index: int, target_index: int) -> RuleSet: + """Return a new RuleSet with one rule moved to target_index.""" + if index < 0 or index >= len(rule_set.rules): + raise RuleOperationError("rule index is out of range") + if target_index < 0 or target_index >= len(rule_set.rules): + raise RuleOperationError("target rule index is out of range") + + rules = list(rule_set.rules) + rule = rules.pop(index) + rules.insert(target_index, rule) + return RuleSet(version=rule_set.version, rules=rules) + + +def reorder_rules(rule_set: RuleSet, order: list[int]) -> RuleSet: + """Return a new RuleSet reordered by a list of original indices.""" + if len(order) != len(rule_set.rules): + raise RuleOperationError("rule order length does not match rule count") + if sorted(order) != list(range(len(rule_set.rules))): + raise RuleOperationError("rule order must contain each rule index exactly once") + + return RuleSet( + version=rule_set.version, + rules=[rule_set.rules[index] for index in order], + ) + + +def _copy_rule_name(name: str, existing_names: set[str]) -> str: + base_name = f"{name} copy" + if base_name not in existing_names: + return base_name + + suffix = 2 + while f"{base_name} {suffix}" in existing_names: + suffix += 1 + + return f"{base_name} {suffix}" + + def make_image_path_relative(rule: Rule, base_dir: str | Path) -> Rule: """Return a copy of rule with image path relative to base_dir when possible.""" image_path = Path(rule.image) diff --git a/app/runner.py b/app/runner.py index 0dc5793..6857bf2 100644 --- a/app/runner.py +++ b/app/runner.py @@ -33,6 +33,7 @@ class RuleRunResult: matched: bool = False triggered: bool = False skipped_cooldown: bool = False + score: float | None = None match: MatchResult | None = None click_target: ClickTarget | None = None error: str | None = None @@ -108,7 +109,7 @@ def test_once(self, screenshot: Any) -> RunnerCycleResult: continue try: - match = self.detector.detect(screenshot, rule) + match = self._find_best_match_for_test(screenshot, rule) except DetectionError as error: results.append( RuleRunResult( @@ -120,11 +121,19 @@ def test_once(self, screenshot: Any) -> RunnerCycleResult: if match is None: results.append(RuleRunResult(rule_name=rule.name)) + elif match.score < rule.confidence: + results.append( + RuleRunResult( + rule_name=rule.name, + score=match.score, + ) + ) else: results.append( RuleRunResult( rule_name=rule.name, matched=True, + score=match.score, match=match, ) ) @@ -162,6 +171,7 @@ def run_once(self, screenshot: Any) -> RunnerCycleResult: rule_name=rule.name, matched=True, triggered=True, + score=match.score, match=match, click_target=click_target, ) @@ -190,3 +200,10 @@ def _capture_screenshot(self, screenshot_provider: ScreenshotProvider) -> Any: return capture_frame() return screenshot_provider.capture() + + def _find_best_match_for_test(self, screenshot: Any, rule: Rule) -> MatchResult | None: + find_best_match = getattr(self.detector, "find_best_match", None) + if callable(find_best_match): + return find_best_match(screenshot, rule) + + return self.detector.detect(screenshot, rule) diff --git a/app/storage.py b/app/storage.py index 6759e60..be2d3cd 100644 --- a/app/storage.py +++ b/app/storage.py @@ -3,6 +3,8 @@ from __future__ import annotations import json +import re +from dataclasses import dataclass from pathlib import Path from typing import Any @@ -13,6 +15,67 @@ class RuleStorageError(RuntimeError): """Raised when a rule file cannot be loaded or saved.""" +@dataclass(frozen=True) +class RuleProfile: + title: str + path: Path + + +def list_rule_profiles(base_dir: str | Path = ".") -> list[RuleProfile]: + """List the root rules.json and rule JSON files from the rules directory.""" + root_dir = Path(base_dir) + profile_paths = [] + + legacy_rules_path = root_dir / "rules.json" + if legacy_rules_path.is_file(): + profile_paths.append(legacy_rules_path) + + profiles_dir = root_dir / "rules" + if profiles_dir.exists() and profiles_dir.is_dir(): + profile_paths.extend( + path + for path in sorted(profiles_dir.glob("*.json"), key=lambda item: item.stem.lower()) + if path.is_file() + ) + + return _title_rule_profiles(profile_paths) + + +def _title_rule_profiles(paths: list[Path]) -> list[RuleProfile]: + title_counts: dict[str, int] = {} + profiles = [] + for path in paths: + base_title = path.stem + count = title_counts.get(base_title, 0) + title_counts[base_title] = count + 1 + title = base_title if count == 0 else f"{base_title} ({count})" + profiles.append(RuleProfile(title=title, path=path)) + + return profiles + + +def safe_rule_profile_stem(name: str) -> str: + """Return a filesystem-friendly rule profile filename stem.""" + stem = re.sub(r"[^0-9A-Za-z_-]+", "_", name.strip()).strip("_") + return stem or "rules" + + +def new_rule_profile_path(base_dir: str | Path, name: str) -> Path: + """Return a non-existing path for a new rule profile under rules/.""" + profiles_dir = Path(base_dir) / "rules" + stem = safe_rule_profile_stem(name) + path = profiles_dir / f"{stem}.json" + if not path.exists(): + return path + + suffix = 1 + while True: + candidate = profiles_dir / f"{stem}_{suffix}.json" + if not candidate.exists(): + return candidate + suffix += 1 + + def load_rules(path: str | Path) -> RuleSet: """Load a rule set from JSON. diff --git a/app/ui/main_window.py b/app/ui/main_window.py index ad92cc9..ada493f 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -9,12 +9,15 @@ from app.models import RuleSet from app.rule_operations import ( add_rule, + duplicate_rule, make_image_path_relative, make_rule_set_image_paths_relative, + move_rule, + reorder_rules, remove_rule, replace_rule, ) -from app.runner import MacroRunner, RunnerCycleResult +from app.runner import MacroRunner, RuleRunResult, RunnerCycleResult from app.storage import RuleStorageError, save_rules from app.system import is_windows_admin @@ -44,12 +47,94 @@ def is_check_area_position(x: int, check_area_width: int = 28) -> bool: return x <= check_area_width +def is_escape_key(key: int, escape_key: int) -> bool: + """Return whether the pressed key is Escape.""" + return key == escape_key + + +def is_valid_rule_row(row: int, rule_count: int) -> bool: + """Return whether a row points to an existing rule.""" + return 0 <= row < rule_count + + +def resolve_rule_image_path(image: str, base_dir: str | Path | None = None) -> Path: + """Resolve a rule image path against the rules file directory.""" + image_path = Path(image) + if base_dir is not None and not image_path.is_absolute(): + return Path(base_dir) / image_path + return image_path + + +def rule_profile_base_dir(rules_path: str | Path | None) -> Path: + """Return the project directory used to find rules/*.json profiles.""" + if rules_path is None: + return Path(".") + + path = Path(rules_path) + parent = path.parent + if parent.name == "rules": + return parent.parent + return parent + + +def format_score_percent(score: float) -> str: + """Format an OpenCV match score as a percentage.""" + return f"{score * 100:.1f}%" + + +def format_rule_test_result(result: RuleRunResult | None, confidence: float) -> list[str]: + """Format the latest test detection result for the rule summary panel.""" + if result is None: + return ["Last Test: not run"] + + confidence_text = format_score_percent(confidence) + if result.error: + return [ + "Last Test: error", + f"Error: {result.error}", + ] + + if result.matched and result.match is not None: + score_text = format_score_percent(result.match.score) + return [ + f"Last Test: matched ({score_text} >= {confidence_text})", + f"Match: x={result.match.x}, y={result.match.y}, center=({result.match.center_x}, {result.match.center_y})", + ] + + if result.score is not None: + score_text = format_score_percent(result.score) + return [f"Last Test: below threshold ({score_text} < {confidence_text})"] + + return ["Last Test: not matched"] + + +def format_rule_list_text(name: str, result: RuleRunResult | None = None) -> str: + """Format a rule list item with the latest status when available.""" + if result is None: + return name + + if result.error: + return f"{name} [error]" + if result.triggered and result.score is not None: + return f"{name} [clicked {format_score_percent(result.score)}]" + if result.skipped_cooldown: + return f"{name} [cooldown]" + if result.matched and result.score is not None: + return f"{name} [matched {format_score_percent(result.score)}]" + if result.score is not None: + return f"{name} [below {format_score_percent(result.score)}]" + + return f"{name} [not matched]" + + def import_qt_widgets(): try: from PySide6.QtWidgets import ( # type: ignore[import-not-found] + QComboBox, QDialog, QFrame, QHBoxLayout, + QInputDialog, QLabel, QListWidget, QListWidgetItem, @@ -63,13 +148,17 @@ def import_qt_widgets(): QWidget, ) from PySide6.QtCore import QTimer, Qt # type: ignore[import-not-found] + from PySide6.QtGui import QImage, QPixmap # type: ignore[import-not-found] except ImportError as error: raise UiDependencyError("PySide6 is not installed") from error return { + "QComboBox": QComboBox, "QDialog": QDialog, "QFrame": QFrame, "QHBoxLayout": QHBoxLayout, + "QInputDialog": QInputDialog, + "QImage": QImage, "QLabel": QLabel, "QListWidget": QListWidget, "QListWidgetItem": QListWidgetItem, @@ -77,6 +166,7 @@ def import_qt_widgets(): "QMessageBox": QMessageBox, "QPushButton": QPushButton, "QPlainTextEdit": QPlainTextEdit, + "QPixmap": QPixmap, "QSizePolicy": QSizePolicy, "QSplitter": QSplitter, "QTimer": QTimer, @@ -94,9 +184,12 @@ def create_main_window(rule_set: RuleSet, rules_path: str | Path | None = None): """ qt = import_qt_widgets() Qt = qt["Qt"] + QComboBox = qt["QComboBox"] QDialog = qt["QDialog"] QFrame = qt["QFrame"] QHBoxLayout = qt["QHBoxLayout"] + QImage = qt["QImage"] + QInputDialog = qt["QInputDialog"] QLabel = qt["QLabel"] QListWidget = qt["QListWidget"] QListWidgetItem = qt["QListWidgetItem"] @@ -104,6 +197,7 @@ def create_main_window(rule_set: RuleSet, rules_path: str | Path | None = None): QMessageBox = qt["QMessageBox"] QPushButton = qt["QPushButton"] QPlainTextEdit = qt["QPlainTextEdit"] + QPixmap = qt["QPixmap"] QSizePolicy = qt["QSizePolicy"] QSplitter = qt["QSplitter"] QTimer = qt["QTimer"] @@ -111,6 +205,10 @@ def create_main_window(rule_set: RuleSet, rules_path: str | Path | None = None): QWidget = qt["QWidget"] class RuleListWidget(QListWidget): + def __init__(self) -> None: + super().__init__() + self.on_reordered = None + def mouseDoubleClickEvent(self, event) -> None: position = event.position().toPoint() if self.itemAt(position) is not None and is_check_area_position(position.x()): @@ -119,6 +217,16 @@ def mouseDoubleClickEvent(self, event) -> None: super().mouseDoubleClickEvent(event) + def dropEvent(self, event) -> None: + before_order = self.rule_order() + super().dropEvent(event) + after_order = self.rule_order() + if after_order != before_order and callable(self.on_reordered): + self.on_reordered(after_order) + + def rule_order(self) -> list[int]: + return [self.item(row).data(Qt.UserRole) for row in range(self.count())] + class MainWindow(QMainWindow): def __init__(self, rules: RuleSet, path: str | Path | None = None) -> None: super().__init__() @@ -130,12 +238,17 @@ def __init__(self, rules: RuleSet, path: str | Path | None = None) -> None: self.is_tick_running = False self.is_loading_rules = False self.last_rule_log_states = {} + self.last_test_results = {} + self.last_rule_results = {} + self.is_loading_rule_profiles = False + self.rule_profiles = [] self.setWindowTitle("Macro Tool") self.resize(980, 680) self._build_ui() self.run_timer = QTimer(self) self.run_timer.setInterval(500) self.run_timer.timeout.connect(self._run_loop_tick) + self._load_rule_profiles() self._load_rules() self.append_log(f"Loaded {len(self.rule_set.rules)} rule(s).") self._append_environment_hints() @@ -151,6 +264,15 @@ def _build_ui(self) -> None: self.status_label = QLabel("Stopped") self.status_label.setObjectName("statusLabel") toolbar.addWidget(self.status_label) + toolbar.addSpacing(16) + toolbar.addWidget(QLabel("Rule Set")) + self.rule_profile_input = QComboBox() + self.rule_profile_input.setMinimumWidth(180) + self.rule_profile_input.currentIndexChanged.connect(self._on_rule_profile_changed) + toolbar.addWidget(self.rule_profile_input) + self.new_rule_profile_button = QPushButton("New") + self.new_rule_profile_button.clicked.connect(self._create_rule_profile) + toolbar.addWidget(self.new_rule_profile_button) toolbar.addStretch(1) self.test_button = QPushButton("Test Detection") @@ -180,21 +302,40 @@ def _build_ui(self) -> None: self.rule_list.currentRowChanged.connect(self._show_rule_summary) self.rule_list.itemChanged.connect(self._on_rule_item_changed) self.rule_list.itemDoubleClicked.connect(self._open_rule_item_editor) + self.rule_list.on_reordered = self._on_rule_list_reordered + self.rule_list.setDragDropMode(QListWidget.InternalMove) + self.rule_list.setDefaultDropAction(Qt.MoveAction) + self.rule_list.setDropIndicatorShown(True) left_layout.addWidget(self.rule_list, 1) rule_buttons = QHBoxLayout() self.add_button = QPushButton("Add") self.edit_button = QPushButton("Edit") + self.duplicate_button = QPushButton("Duplicate") self.delete_button = QPushButton("Delete") self.edit_button.setEnabled(False) + self.duplicate_button.setEnabled(False) self.delete_button.setEnabled(False) self.add_button.clicked.connect(self._open_new_rule_editor) self.edit_button.clicked.connect(self._open_selected_rule_editor) + self.duplicate_button.clicked.connect(self._duplicate_selected_rule) self.delete_button.clicked.connect(self._delete_selected_rule) rule_buttons.addWidget(self.add_button) rule_buttons.addWidget(self.edit_button) + rule_buttons.addWidget(self.duplicate_button) rule_buttons.addWidget(self.delete_button) left_layout.addLayout(rule_buttons) + + move_buttons = QHBoxLayout() + self.move_up_button = QPushButton("Up") + self.move_down_button = QPushButton("Down") + self.move_up_button.setEnabled(False) + self.move_down_button.setEnabled(False) + self.move_up_button.clicked.connect(self._move_selected_rule_up) + self.move_down_button.clicked.connect(self._move_selected_rule_down) + move_buttons.addWidget(self.move_up_button) + move_buttons.addWidget(self.move_down_button) + left_layout.addLayout(move_buttons) splitter.addWidget(left_panel) right_panel = QWidget() @@ -203,6 +344,14 @@ def _build_ui(self) -> None: right_layout.setSpacing(8) right_layout.addWidget(QLabel("Selected Rule")) + self.preview_label = QLabel("No image preview.") + self.preview_label.setAlignment(Qt.AlignCenter) + self.preview_label.setMinimumSize(220, 150) + self.preview_label.setMaximumHeight(190) + self.preview_label.setFrameShape(QFrame.StyledPanel) + self.preview_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + right_layout.addWidget(self.preview_label, 0) + self.summary_label = QLabel("No rule selected.") self.summary_label.setAlignment(Qt.AlignTop | Qt.AlignLeft) self.summary_label.setWordWrap(True) @@ -222,9 +371,10 @@ def _build_ui(self) -> None: def _load_rules(self) -> None: self.is_loading_rules = True self.rule_list.clear() - for rule in self.rule_set.rules: - item = QListWidgetItem(rule.name) - item.setFlags(item.flags() | Qt.ItemIsUserCheckable) + for index, rule in enumerate(self.rule_set.rules): + item = QListWidgetItem(format_rule_list_text(rule.name, self.last_rule_results.get(rule.name))) + item.setFlags(item.flags() | Qt.ItemIsUserCheckable | Qt.ItemIsDragEnabled) + item.setData(Qt.UserRole, index) item.setCheckState(Qt.Checked if rule.enabled else Qt.Unchecked) self.rule_list.addItem(item) self.is_loading_rules = False @@ -232,16 +382,116 @@ def _load_rules(self) -> None: if self.rule_set.rules: self.rule_list.setCurrentRow(0) + def _load_rule_profiles(self) -> None: + from app.storage import list_rule_profiles + + self.rule_profiles = list_rule_profiles(rule_profile_base_dir(self.rules_path)) + self.is_loading_rule_profiles = True + self.rule_profile_input.clear() + + if not self.rule_profiles: + self.rule_profile_input.addItem("No rule sets") + self.rule_profile_input.setEnabled(False) + self.is_loading_rule_profiles = False + return + + for profile in self.rule_profiles: + self.rule_profile_input.addItem(profile.title, str(profile.path)) + + selected_index = -1 + if self.rules_path is not None: + current_path = self.rules_path.resolve() + for index, profile in enumerate(self.rule_profiles): + if profile.path.resolve() == current_path: + selected_index = index + break + + self.rule_profile_input.setCurrentIndex(selected_index) + self.rule_profile_input.setEnabled(not self.is_running) + self.is_loading_rule_profiles = False + + def _select_rule_profile_path(self, profile_path: Path) -> None: + for index in range(self.rule_profile_input.count()): + item_path = self.rule_profile_input.itemData(index) + if item_path and Path(item_path).resolve() == profile_path.resolve(): + self.rule_profile_input.setCurrentIndex(index) + return + + def _on_rule_profile_changed(self, index: int) -> None: + if self.is_loading_rule_profiles or self.is_running or index < 0: + return + + path_text = self.rule_profile_input.itemData(index) + if not path_text: + return + + profile_path = Path(path_text) + if self.rules_path is not None and profile_path.resolve() == self.rules_path.resolve(): + return + + from app.storage import load_rules + + try: + self.rule_set = load_rules(profile_path) + except RuleStorageError as error: + QMessageBox.warning(self, "Could not load rule set", str(error)) + self.append_log(f"Rule set load failed: {error}") + self._load_rule_profiles() + return + + self.rules_path = profile_path + self.last_rule_log_states = {} + self.last_test_results = {} + self.last_rule_results = {} + self._load_rules() + self.append_log(f"Loaded rule set: {profile_path} ({len(self.rule_set.rules)} rule(s).)") + + def _create_rule_profile(self) -> None: + if self.is_running: + return + + name, accepted = QInputDialog.getText(self, "New rule set", "Rule set name:") + if not accepted: + return + if not name.strip(): + QMessageBox.warning(self, "Invalid rule set name", "Rule set name is required.") + return + + from app.storage import new_rule_profile_path + + profile_path = new_rule_profile_path(rule_profile_base_dir(self.rules_path), name) + try: + save_rules(profile_path, RuleSet(rules=[])) + except RuleStorageError as error: + QMessageBox.warning(self, "Could not create rule set", str(error)) + self.append_log(f"Rule set create failed: {error}") + return + + self.rules_path = profile_path + self.rule_set = RuleSet(rules=[]) + self.last_rule_log_states = {} + self.last_test_results = {} + self.last_rule_results = {} + self._load_rule_profiles() + self._select_rule_profile_path(profile_path) + self._load_rules() + self.append_log(f"Created rule set: {profile_path}") + def _show_rule_summary(self, row: int) -> None: - self.edit_button.setEnabled(row >= 0 and not self.is_running) - self.delete_button.setEnabled(row >= 0 and not self.is_running) - if row < 0: + self._update_rule_buttons(row) + if not is_valid_rule_row(row, len(self.rule_set.rules)): + self._clear_rule_preview("No image preview.") self.summary_label.setText("No rule selected.") return rule = self.rule_set.rules[row] + self._show_rule_preview(rule.image) region = rule.region offset = rule.action.offset + test_result_lines = format_rule_test_result( + self.last_test_results.get(rule.name), + rule.confidence, + ) self.summary_label.setText( "\n".join( [ @@ -253,10 +503,48 @@ def _show_rule_summary(self, row: int) -> None: f"Action: {rule.action.type} / {rule.action.button}", f"Offset: x={offset.x}, y={offset.y}", f"Cooldown: {rule.cooldown}s", + "", + *test_result_lines, ] ) ) + def _show_rule_preview(self, image: str) -> None: + base_dir = self.rules_path.parent if self.rules_path is not None else Path(".") + image_path = resolve_rule_image_path(image, base_dir) + if not image_path.exists() or not image_path.is_file(): + self._clear_rule_preview("Image not found.") + return + + try: + image_data = image_path.read_bytes() + except OSError: + self._clear_rule_preview("Could not read image.") + return + + qimage = QImage.fromData(image_data) + if qimage.isNull(): + self._clear_rule_preview("Could not preview image.") + return + + pixmap = QPixmap.fromImage(qimage) + max_width = max(1, self.preview_label.width() - 12) + max_height = max(1, self.preview_label.height() - 12) + self.preview_label.setPixmap( + pixmap.scaled( + max_width, + max_height, + Qt.KeepAspectRatio, + Qt.SmoothTransformation, + ) + ) + self.preview_label.setToolTip(str(image_path)) + + def _clear_rule_preview(self, message: str) -> None: + self.preview_label.clear() + self.preview_label.setText(message) + self.preview_label.setToolTip("") + def append_log(self, message: str) -> None: timestamp = datetime.now().strftime("%H:%M:%S") self.log_view.appendPlainText(f"[{timestamp}] {message}") @@ -315,10 +603,33 @@ def _open_rule_editor(self, rule, index) -> None: self.append_log(f"Rule save failed: {error}") return + self.last_test_results = {} + self.last_rule_results = {} self._load_rules() + self._load_rule_profiles() self.rule_list.setCurrentRow(selected_row) self.append_log(f"Saved rule: {edited_rule.name}") + def _duplicate_selected_rule(self) -> None: + if self.is_running: + return + row = self.rule_list.currentRow() + if row < 0: + return + + try: + self.rule_set = duplicate_rule(self.rule_set, row) + self._save_rules() + except Exception as error: + QMessageBox.warning(self, "Could not duplicate rule", str(error)) + self.append_log(f"Rule duplicate failed: {error}") + return + + duplicated_rule = self.rule_set.rules[row + 1] + self._load_rules() + self.rule_list.setCurrentRow(row + 1) + self.append_log(f"Duplicated rule: {duplicated_rule.name}") + def _delete_selected_rule(self) -> None: if self.is_running: return @@ -345,6 +656,8 @@ def _delete_selected_rule(self) -> None: self.append_log(f"Rule delete failed: {error}") return + self.last_test_results = {} + self.last_rule_results = {} self._load_rules() if self.rule_set.rules: self.rule_list.setCurrentRow(min(row, len(self.rule_set.rules) - 1)) @@ -352,6 +665,56 @@ def _delete_selected_rule(self) -> None: self._show_rule_summary(-1) self.append_log(f"Deleted rule: {rule.name}") + def _move_selected_rule_up(self) -> None: + self._move_selected_rule(-1) + + def _move_selected_rule_down(self) -> None: + self._move_selected_rule(1) + + def _move_selected_rule(self, direction: int) -> None: + if self.is_running: + return + row = self.rule_list.currentRow() + target_row = row + direction + if row < 0 or target_row < 0 or target_row >= len(self.rule_set.rules): + return + + rule_name = self.rule_set.rules[row].name + try: + self.rule_set = move_rule(self.rule_set, row, target_row) + self._save_rules() + except Exception as error: + QMessageBox.warning(self, "Could not move rule", str(error)) + self.append_log(f"Rule move failed: {error}") + return + + self._load_rules() + self.rule_list.setCurrentRow(target_row) + self.append_log(f"Moved rule: {rule_name}") + + def _on_rule_list_reordered(self, order: list[int]) -> None: + if self.is_loading_rules: + return + if self.is_running: + self._load_rules() + return + + current_item = self.rule_list.currentItem() + selected_original_index = current_item.data(Qt.UserRole) if current_item is not None else None + try: + self.rule_set = reorder_rules(self.rule_set, order) + self._save_rules() + except Exception as error: + QMessageBox.warning(self, "Could not reorder rules", str(error)) + self.append_log(f"Rule reorder failed: {error}") + self._load_rules() + return + + selected_row = order.index(selected_original_index) if selected_original_index in order else 0 + self._load_rules() + self.rule_list.setCurrentRow(selected_row) + self.append_log("Reordered rules.") + def _on_rule_item_changed(self, item) -> None: if self.is_loading_rules: return @@ -419,14 +782,22 @@ def _append_test_detection_result(self, result: RunnerCycleResult) -> None: not_matched_count = len(result.results) - matched_count - error_count for item in result.results: + self.last_test_results[item.rule_name] = item + self.last_rule_results[item.rule_name] = item if item.error: self.append_log(f"[{item.rule_name}] error: {item.error}") elif item.matched and item.match is not None: self.append_log( f"[{item.rule_name}] matched score={item.match.score:.3f} " + f"({item.match.score * 100:.1f}%) " f"at x={item.match.x}, y={item.match.y} " f"center=({item.match.center_x}, {item.match.center_y})" ) + elif item.score is not None: + self.append_log( + f"[{item.rule_name}] not matched best_score={item.score:.3f} " + f"({item.score * 100:.1f}%)" + ) else: self.append_log(f"[{item.rule_name}] not matched") @@ -434,6 +805,8 @@ def _append_test_detection_result(self, result: RunnerCycleResult) -> None: "Test detection completed: " f"matched={matched_count}, not_matched={not_matched_count}, errors={error_count}" ) + self._refresh_rule_list_texts() + self._show_rule_summary(self.rule_list.currentRow()) def _start_running(self) -> None: enabled_rules = [rule for rule in self.rule_set.rules if rule.enabled] @@ -453,9 +826,12 @@ def _start_running(self) -> None: ) self.screenshot_provider = PyAutoGuiScreenshotProvider() self.last_rule_log_states = {} + self.last_rule_results = {} + self._refresh_rule_list_texts() self._set_running_state(True) self.append_log( - f"Macro started. enabled_rules={len(enabled_rules)}, interval={self.run_timer.interval()}ms" + f"Macro started. enabled_rules={len(enabled_rules)}, interval={self.run_timer.interval()}ms. " + "Press Esc to stop." ) self.run_timer.start() @@ -521,10 +897,20 @@ def _set_running_state(self, running: bool) -> None: self.start_button.setEnabled(not running) self.stop_button.setEnabled(running) self.test_button.setEnabled(not running) + self.rule_profile_input.setEnabled(bool(self.rule_profiles) and not running) + self.new_rule_profile_button.setEnabled(not running) self.add_button.setEnabled(not running) - selected = self.rule_list.currentRow() >= 0 - self.edit_button.setEnabled(selected and not running) - self.delete_button.setEnabled(selected and not running) + self.rule_list.setDragDropMode(QListWidget.NoDragDrop if running else QListWidget.InternalMove) + self._update_rule_buttons(self.rule_list.currentRow()) + + def _update_rule_buttons(self, row: int) -> None: + selected = is_valid_rule_row(row, len(self.rule_set.rules)) + can_edit = selected and not self.is_running + self.edit_button.setEnabled(can_edit) + self.duplicate_button.setEnabled(can_edit) + self.delete_button.setEnabled(can_edit) + self.move_up_button.setEnabled(can_edit and row > 0) + self.move_down_button.setEnabled(can_edit and row < len(self.rule_set.rules) - 1) def _run_loop_tick(self) -> None: if ( @@ -544,6 +930,7 @@ def _run_loop_tick(self) -> None: def _append_run_result(self, result: RunnerCycleResult) -> None: for item in result.results: + self.last_rule_results[item.rule_name] = item state = self._run_result_state(item) if item.error: self._append_state_change_log(item.rule_name, state, f"[{item.rule_name}] error: {item.error}") @@ -558,6 +945,15 @@ def _append_run_result(self, result: RunnerCycleResult) -> None: self._append_state_change_log(item.rule_name, state, f"[{item.rule_name}] cooldown") else: self._append_state_change_log(item.rule_name, state, f"[{item.rule_name}] not matched") + self._refresh_rule_list_texts() + + def _refresh_rule_list_texts(self) -> None: + self.is_loading_rules = True + for row, rule in enumerate(self.rule_set.rules): + item = self.rule_list.item(row) + if item is not None: + item.setText(format_rule_list_text(rule.name, self.last_rule_results.get(rule.name))) + self.is_loading_rules = False def _run_result_state(self, item) -> str: if item.error: @@ -577,6 +973,14 @@ def _append_state_change_log(self, rule_name: str, state: str, message: str) -> self.last_rule_log_states[rule_name] = state self.append_log(message) + def keyPressEvent(self, event) -> None: + if self.is_running and is_escape_key(event.key(), Qt.Key_Escape): + event.accept() + self._stop_running() + return + + super().keyPressEvent(event) + def closeEvent(self, event) -> None: if self.is_running: self._stop_running() diff --git a/app/ui/region_selector.py b/app/ui/region_selector.py index 67058a0..dc977ba 100644 --- a/app/ui/region_selector.py +++ b/app/ui/region_selector.py @@ -36,6 +36,21 @@ def region_from_points(start: Point, end: Point, origin: Point | None = None) -> return Region(x=x + origin.x, y=y + origin.y, width=width, height=height) +def crop_image_by_region(image: np.ndarray, region: Region, origin: Point | None = None) -> np.ndarray: + """Crop an image using a screen-coordinate region and screenshot origin.""" + origin = origin or Point(0, 0) + left = region.x - origin.x + top = region.y - origin.y + right = left + region.width + bottom = top + region.height + + image_height, image_width = image.shape[:2] + if left < 0 or top < 0 or right > image_width or bottom > image_height: + raise RegionSelectionError("Selected region is outside the screenshot.") + + return image[top:bottom, left:right].copy() + + def import_qt_modules(): try: from PySide6.QtCore import QPoint, QRect, Qt # type: ignore[import-not-found] @@ -154,6 +169,8 @@ def __init__(self, image: np.ndarray, origin: Point, parent_widget=None) -> None super().__init__(parent_widget) self.setWindowTitle("Select Region") self.resize(960, 640) + self.image = image + self.origin = origin self._build_ui(image, origin) def _build_ui(self, image: np.ndarray, origin: Point) -> None: @@ -181,6 +198,9 @@ def _build_ui(self, image: np.ndarray, origin: Point) -> None: def selected_region(self) -> Region: return self.screenshot_label.selected_region() + def selected_image(self) -> np.ndarray: + return crop_image_by_region(self.image, self.selected_region(), self.origin) + def accept(self) -> None: try: self.selected_region() diff --git a/app/ui/rule_editor.py b/app/ui/rule_editor.py index 74673e5..0890e15 100644 --- a/app/ui/rule_editor.py +++ b/app/ui/rule_editor.py @@ -4,6 +4,7 @@ from dataclasses import dataclass from pathlib import Path +import re import cv2 import numpy as np @@ -16,6 +17,32 @@ class RuleFormValidationError(ValueError): """Raised when rule editor form data is invalid.""" +def safe_template_file_stem(name: str) -> str: + """Return a filesystem-friendly template filename stem.""" + stem = re.sub(r"[^0-9A-Za-z_-]+", "_", name.strip()).strip("_") + return stem or "template" + + +def default_captured_template_path(base_dir: str | Path | None, rule_name: str) -> Path: + """Return the default output path for a captured template image.""" + root = Path(base_dir) if base_dir is not None else Path(".") + return root / "image" / f"{safe_template_file_stem(rule_name)}.png" + + +def save_captured_template(path: str | Path, image: np.ndarray) -> None: + """Save a captured BGR template image as PNG.""" + output_path = Path(path) + output_path.parent.mkdir(parents=True, exist_ok=True) + success, encoded_image = cv2.imencode(".png", image) + if not success: + raise RuleFormValidationError(f"Could not encode captured image: {output_path}") + + try: + encoded_image.tofile(str(output_path)) + except OSError as error: + raise RuleFormValidationError(f"Could not save captured image: {output_path}") from error + + def resolve_image_path(image: str, base_dir: str | Path | None = None) -> Path: image_path = Path(image) if base_dir is not None and not image_path.is_absolute(): @@ -193,11 +220,14 @@ def _build_ui(self) -> None: image_layout.setContentsMargins(0, 0, 0, 0) self.image_input = QLineEdit() self.image_button = QPushButton("Browse") + self.capture_button = QPushButton("Capture") self.mask_button = QPushButton("Edit Mask") self.image_button.clicked.connect(self._browse_image) + self.capture_button.clicked.connect(self._capture_image) self.mask_button.clicked.connect(self._edit_mask) image_layout.addWidget(self.image_input, 1) image_layout.addWidget(self.image_button) + image_layout.addWidget(self.capture_button) image_layout.addWidget(self.mask_button) form.addRow("Detection image", image_row) @@ -273,6 +303,37 @@ def _browse_image(self) -> None: if path: self.image_input.setText(self._display_image_path(path)) + def _capture_image(self) -> None: + from app.ui.region_selector import create_region_selector + + dialog = create_region_selector(parent=self) + if dialog is None: + return + if dialog.exec() != QDialog.Accepted: + return + + default_path = default_captured_template_path(base_dir, self.name_input.text()) + path, _ = QFileDialog.getSaveFileName( + self, + "Save captured template", + str(default_path), + "PNG image (*.png);;All files (*.*)", + ) + if not path: + return + + output_path = Path(path) + if output_path.suffix.lower() != ".png": + output_path = output_path.with_suffix(".png") + + try: + save_captured_template(output_path, dialog.selected_image()) + except RuleFormValidationError as error: + QMessageBox.warning(self, "Could not save captured image", str(error)) + return + + self.image_input.setText(self._display_image_path(str(output_path))) + def _display_image_path(self, path: str) -> str: image_path = Path(path) if base_dir is None or not image_path.is_absolute(): diff --git a/docs/assets/execution.png b/docs/assets/execution.png index daefa98..1559d57 100644 Binary files a/docs/assets/execution.png and b/docs/assets/execution.png differ diff --git a/docs/assets/home.png b/docs/assets/home.png index 896d9ba..65569d2 100644 Binary files a/docs/assets/home.png and b/docs/assets/home.png differ diff --git a/rules/browser.json b/rules/browser.json new file mode 100644 index 0000000..ae107c7 --- /dev/null +++ b/rules/browser.json @@ -0,0 +1,4 @@ +{ + "version": 1, + "rules": [] +} diff --git a/rules/tower.json b/rules/tower.json new file mode 100644 index 0000000..ae107c7 --- /dev/null +++ b/rules/tower.json @@ -0,0 +1,4 @@ +{ + "version": 1, + "rules": [] +} diff --git a/tests/test_detector.py b/tests/test_detector.py index 8f93f01..1976e7c 100644 --- a/tests/test_detector.py +++ b/tests/test_detector.py @@ -145,6 +145,19 @@ def test_detect_returns_none_when_score_is_below_confidence(tmp_path): assert result is None +def test_find_best_match_returns_score_below_confidence(tmp_path): + screenshot = np.zeros((80, 80, 3), dtype=np.uint8) + screenshot[25:35, 30:40] = make_marker((0, 255, 0)) + template = make_marker((0, 0, 255)) + write_image(tmp_path / "marker.png", template) + + detector = TemplateDetector(base_dir=tmp_path) + result = detector.find_best_match(screenshot, make_rule("marker.png", confidence=0.99)) + + assert result is not None + assert result.score < 0.99 + + def test_detect_returns_none_when_template_is_larger_than_region(tmp_path): screenshot = np.zeros((80, 80, 3), dtype=np.uint8) template = np.zeros((50, 50, 3), dtype=np.uint8) diff --git a/tests/test_region_selector.py b/tests/test_region_selector.py index 5361b7f..4abc201 100644 --- a/tests/test_region_selector.py +++ b/tests/test_region_selector.py @@ -3,7 +3,7 @@ from app.models import Region from app.ui.main_window import UiDependencyError -from app.ui.region_selector import Point, RegionSelectionError, region_from_points +from app.ui.region_selector import Point, RegionSelectionError, crop_image_by_region, region_from_points def test_region_from_points_handles_down_right_drag(): @@ -38,6 +38,25 @@ def test_region_from_points_rejects_empty_region(): region_from_points(Point(10, 20), Point(10, 20)) +def test_crop_image_by_region_uses_virtual_origin(): + image = np.arange(5 * 6 * 3, dtype=np.uint8).reshape((5, 6, 3)) + + cropped = crop_image_by_region( + image, + Region(x=-8, y=21, width=3, height=2), + origin=Point(-10, 20), + ) + + np.testing.assert_array_equal(cropped, image[1:3, 2:5]) + + +def test_crop_image_by_region_rejects_outside_region(): + image = np.zeros((5, 6, 3), dtype=np.uint8) + + with pytest.raises(RegionSelectionError, match="outside"): + crop_image_by_region(image, Region(x=5, y=0, width=2, height=2)) + + def test_create_region_selector_raises_clear_error_without_pyside6(monkeypatch): import app.ui.region_selector as region_selector diff --git a/tests/test_rule_editor.py b/tests/test_rule_editor.py index 7b13e77..8a0e8b3 100644 --- a/tests/test_rule_editor.py +++ b/tests/test_rule_editor.py @@ -4,7 +4,14 @@ from app.models import Action, Offset, Region, Rule from app.ui.main_window import UiDependencyError -from app.ui.rule_editor import RuleFormData, RuleFormValidationError, validate_detection_image +from app.ui.rule_editor import ( + RuleFormData, + RuleFormValidationError, + default_captured_template_path, + safe_template_file_stem, + save_captured_template, + validate_detection_image, +) def write_image(path, image: np.ndarray) -> None: @@ -117,6 +124,30 @@ def test_validate_detection_image_rejects_fully_transparent_png(tmp_path): validate_detection_image("transparent.png", base_dir=tmp_path) +def test_safe_template_file_stem_removes_unsafe_characters(): + assert safe_template_file_stem("Start Button!?") == "Start_Button" + + +def test_safe_template_file_stem_uses_fallback_for_blank_name(): + assert safe_template_file_stem(" ") == "template" + + +def test_default_captured_template_path_uses_image_directory(tmp_path): + assert default_captured_template_path(tmp_path, "Start Button") == tmp_path / "image" / "Start_Button.png" + + +def test_save_captured_template_writes_png(tmp_path): + image = np.zeros((4, 5, 3), dtype=np.uint8) + image[:, :] = (0, 0, 255) + output_path = tmp_path / "画像" / "button.png" + + save_captured_template(output_path, image) + + decoded = cv2.imdecode(np.fromfile(str(output_path), dtype=np.uint8), cv2.IMREAD_UNCHANGED) + assert decoded is not None + assert decoded.shape == (4, 5, 3) + + def test_create_rule_editor_raises_clear_error_without_pyside6(monkeypatch): import app.ui.rule_editor as rule_editor diff --git a/tests/test_rule_operations.py b/tests/test_rule_operations.py index 2fea5b9..c966b5e 100644 --- a/tests/test_rule_operations.py +++ b/tests/test_rule_operations.py @@ -4,8 +4,11 @@ from app.rule_operations import ( RuleOperationError, add_rule, + duplicate_rule, make_image_path_relative, make_rule_set_image_paths_relative, + move_rule, + reorder_rules, remove_rule, replace_rule, ) @@ -60,6 +63,73 @@ def test_remove_rule_rejects_out_of_range_index(): remove_rule(RuleSet(rules=[]), 0) +def test_duplicate_rule_inserts_copy_after_selected_rule(): + original = RuleSet(rules=[make_rule("a"), make_rule("b")]) + + updated = duplicate_rule(original, 0) + + assert [rule.name for rule in original.rules] == ["a", "b"] + assert [rule.name for rule in updated.rules] == ["a", "a copy", "b"] + assert updated.rules[1].image == "images/a.png" + + +def test_duplicate_rule_uses_unique_copy_name(): + original = RuleSet( + rules=[ + make_rule("a"), + make_rule("a copy"), + make_rule("a copy 2"), + ] + ) + + updated = duplicate_rule(original, 0) + + assert [rule.name for rule in updated.rules] == ["a", "a copy 3", "a copy", "a copy 2"] + + +def test_duplicate_rule_rejects_out_of_range_index(): + with pytest.raises(RuleOperationError, match="out of range"): + duplicate_rule(RuleSet(rules=[]), 0) + + +def test_move_rule_reorders_rules(): + original = RuleSet(rules=[make_rule("a"), make_rule("b"), make_rule("c")]) + + updated = move_rule(original, 2, 0) + + assert [rule.name for rule in original.rules] == ["a", "b", "c"] + assert [rule.name for rule in updated.rules] == ["c", "a", "b"] + + +def test_move_rule_rejects_out_of_range_index(): + with pytest.raises(RuleOperationError, match="out of range"): + move_rule(RuleSet(rules=[make_rule("a")]), 1, 0) + + +def test_move_rule_rejects_out_of_range_target_index(): + with pytest.raises(RuleOperationError, match="target rule index is out of range"): + move_rule(RuleSet(rules=[make_rule("a")]), 0, 1) + + +def test_reorder_rules_reorders_by_original_indices(): + original = RuleSet(rules=[make_rule("a"), make_rule("b"), make_rule("c")]) + + updated = reorder_rules(original, [2, 0, 1]) + + assert [rule.name for rule in original.rules] == ["a", "b", "c"] + assert [rule.name for rule in updated.rules] == ["c", "a", "b"] + + +def test_reorder_rules_rejects_length_mismatch(): + with pytest.raises(RuleOperationError, match="length"): + reorder_rules(RuleSet(rules=[make_rule("a"), make_rule("b")]), [1]) + + +def test_reorder_rules_rejects_duplicate_indices(): + with pytest.raises(RuleOperationError, match="exactly once"): + reorder_rules(RuleSet(rules=[make_rule("a"), make_rule("b")]), [0, 0]) + + def test_make_image_path_relative_converts_path_inside_base_dir(tmp_path): base_dir = tmp_path / "project" image_path = base_dir / "image" / "button.png" diff --git a/tests/test_runner.py b/tests/test_runner.py index 0d7689c..e000381 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -23,6 +23,19 @@ def detect(self, screenshot, rule: Rule) -> MatchResult | None: return self.matches.get(rule.name) +class FakeBestMatchDetector: + def __init__(self, matches: dict[str, MatchResult | None]) -> None: + self.matches = matches + self.calls = [] + + def find_best_match(self, screenshot, rule: Rule) -> MatchResult | None: + self.calls.append(rule.name) + return self.matches.get(rule.name) + + def detect(self, screenshot, rule: Rule) -> MatchResult | None: + raise AssertionError("test_once should use find_best_match when available") + + class FailingDetector: def detect(self, screenshot, rule: Rule) -> MatchResult | None: raise DetectionError("detection failed") @@ -232,6 +245,33 @@ def test_test_once_detects_without_clicking_or_cooldown(): assert runner.last_triggered_at == {} +def test_test_once_records_below_confidence_score_without_clicking(): + rule = make_rule() + match = MatchResult( + rule_name="Rule", + score=0.84, + x=10, + y=20, + width=10, + height=10, + ) + mouse = FakeMouse() + detector = FakeBestMatchDetector({"Rule": match}) + runner = MacroRunner( + rules=[rule], + detector=detector, + mouse=mouse, + ) + + result = runner.test_once(screenshot=object()) + + assert result.results[0].matched is False + assert result.results[0].score == 0.84 + assert result.results[0].match is None + assert mouse.clicks == [] + assert detector.calls == ["Rule"] + + def test_test_once_skips_disabled_rules(): rule = make_rule(enabled=False) detector = FakeDetector({"Rule": make_match()}) diff --git a/tests/test_storage.py b/tests/test_storage.py index 1e94540..4ef7cff 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -3,7 +3,14 @@ import pytest from app.models import Action, Region, Rule, RuleSet -from app.storage import RuleStorageError, load_rules, save_rules +from app.storage import ( + RuleStorageError, + list_rule_profiles, + load_rules, + new_rule_profile_path, + safe_rule_profile_stem, + save_rules, +) def make_rule_set() -> RuleSet: @@ -78,3 +85,67 @@ def test_load_rules_rejects_invalid_rule_data(tmp_path): def test_save_rules_rejects_non_rule_set(tmp_path): with pytest.raises(RuleStorageError, match="RuleSet"): save_rules(tmp_path / "rules.json", object()) # type: ignore[arg-type] + + +def test_list_rule_profiles_returns_json_files_from_rules_directory(tmp_path): + rules_dir = tmp_path / "rules" + rules_dir.mkdir() + (rules_dir / "beta.json").write_text("{}", encoding="utf-8") + (rules_dir / "alpha.json").write_text("{}", encoding="utf-8") + (rules_dir / "memo.txt").write_text("ignore", encoding="utf-8") + + profiles = list_rule_profiles(tmp_path) + + assert [profile.title for profile in profiles] == ["alpha", "beta"] + assert [profile.path for profile in profiles] == [ + rules_dir / "alpha.json", + rules_dir / "beta.json", + ] + + +def test_list_rule_profiles_includes_root_rules_json(tmp_path): + rules_path = tmp_path / "rules.json" + rules_path.write_text("{}", encoding="utf-8") + + profiles = list_rule_profiles(tmp_path) + + assert [profile.title for profile in profiles] == ["rules"] + assert [profile.path for profile in profiles] == [rules_path] + + +def test_list_rule_profiles_disambiguates_duplicate_rules_title(tmp_path): + root_rules_path = tmp_path / "rules.json" + rules_dir = tmp_path / "rules" + nested_rules_path = rules_dir / "rules.json" + rules_dir.mkdir() + root_rules_path.write_text("{}", encoding="utf-8") + nested_rules_path.write_text("{}", encoding="utf-8") + + profiles = list_rule_profiles(tmp_path) + + assert [profile.title for profile in profiles] == ["rules", "rules (1)"] + assert [profile.path for profile in profiles] == [root_rules_path, nested_rules_path] + + +def test_list_rule_profiles_returns_empty_list_without_rules_directory(tmp_path): + assert list_rule_profiles(tmp_path) == [] + + +def test_safe_rule_profile_stem_removes_unsafe_characters(): + assert safe_rule_profile_stem("Tower Profile!?") == "Tower_Profile" + + +def test_safe_rule_profile_stem_uses_fallback_for_blank_name(): + assert safe_rule_profile_stem(" ") == "rules" + + +def test_new_rule_profile_path_returns_rules_directory_path(tmp_path): + assert new_rule_profile_path(tmp_path, "Tower Profile") == tmp_path / "rules" / "Tower_Profile.json" + + +def test_new_rule_profile_path_avoids_existing_file(tmp_path): + rules_dir = tmp_path / "rules" + rules_dir.mkdir() + (rules_dir / "tower.json").write_text("{}", encoding="utf-8") + + assert new_rule_profile_path(tmp_path, "tower") == rules_dir / "tower_1.json" diff --git a/tests/test_ui_main_window.py b/tests/test_ui_main_window.py index 57ae185..129bf08 100644 --- a/tests/test_ui_main_window.py +++ b/tests/test_ui_main_window.py @@ -1,5 +1,20 @@ +from pathlib import Path + from app.models import RuleSet -from app.ui.main_window import UiDependencyError, is_check_area_position, rectangles_overlap +from app.detector import MatchResult +from app.runner import RuleRunResult +from app.ui.main_window import ( + UiDependencyError, + format_rule_list_text, + format_rule_test_result, + format_score_percent, + is_check_area_position, + is_escape_key, + is_valid_rule_row, + rectangles_overlap, + rule_profile_base_dir, + resolve_rule_image_path, +) import pytest @@ -23,10 +38,14 @@ def test_import_qt_widgets_returns_required_widget_names_when_pyside6_is_availab qt = import_qt_widgets() + assert "QComboBox" in qt assert "QDialog" in qt + assert "QInputDialog" in qt assert "QMainWindow" in qt assert "QListWidget" in qt assert "QPlainTextEdit" in qt + assert "QImage" in qt + assert "QPixmap" in qt assert "QTimer" in qt @@ -41,3 +60,113 @@ def test_rectangles_overlap_returns_false_when_areas_do_not_intersect(): def test_is_check_area_position_detects_left_edge(): assert is_check_area_position(12) is True assert is_check_area_position(40) is False + + +def test_is_escape_key_matches_escape_only(): + assert is_escape_key(27, 27) is True + assert is_escape_key(65, 27) is False + + +def test_is_valid_rule_row_rejects_out_of_range_rows(): + assert is_valid_rule_row(0, 1) is True + assert is_valid_rule_row(-1, 1) is False + assert is_valid_rule_row(1, 1) is False + assert is_valid_rule_row(0, 0) is False + + +def test_resolve_rule_image_path_uses_base_dir_for_relative_path(tmp_path): + assert resolve_rule_image_path("image/button.png", tmp_path) == tmp_path / "image" / "button.png" + + +def test_resolve_rule_image_path_leaves_absolute_path(tmp_path): + image_path = tmp_path / "button.png" + + assert resolve_rule_image_path(str(image_path), tmp_path) == image_path + + +def test_rule_profile_base_dir_uses_rules_parent_for_profile_path(): + assert rule_profile_base_dir("rules/tower.json") == Path(".") + + +def test_rule_profile_base_dir_uses_rules_file_parent_for_legacy_path(): + assert rule_profile_base_dir("rules.json") == Path(".") + + +def test_format_score_percent(): + assert format_score_percent(0.842) == "84.2%" + + +def test_format_rule_test_result_returns_not_run_without_result(): + assert format_rule_test_result(None, confidence=0.85) == ["Last Test: not run"] + + +def test_format_rule_test_result_returns_matched_summary(): + result = RuleRunResult( + rule_name="Rule", + matched=True, + score=0.95, + match=MatchResult( + rule_name="Rule", + score=0.95, + x=10, + y=20, + width=10, + height=10, + ), + ) + + assert format_rule_test_result(result, confidence=0.85) == [ + "Last Test: matched (95.0% >= 85.0%)", + "Match: x=10, y=20, center=(15, 25)", + ] + + +def test_format_rule_test_result_returns_below_threshold_summary(): + result = RuleRunResult(rule_name="Rule", score=0.842) + + assert format_rule_test_result(result, confidence=0.85) == [ + "Last Test: below threshold (84.2% < 85.0%)" + ] + + +def test_format_rule_test_result_returns_error_summary(): + result = RuleRunResult(rule_name="Rule", error="template missing") + + assert format_rule_test_result(result, confidence=0.85) == [ + "Last Test: error", + "Error: template missing", + ] + + +def test_format_rule_list_text_returns_name_without_result(): + assert format_rule_list_text("Rule") == "Rule" + + +def test_format_rule_list_text_returns_clicked_score(): + result = RuleRunResult(rule_name="Rule", triggered=True, score=0.95) + + assert format_rule_list_text("Rule", result) == "Rule [clicked 95.0%]" + + +def test_format_rule_list_text_returns_matched_score(): + result = RuleRunResult(rule_name="Rule", matched=True, score=0.91) + + assert format_rule_list_text("Rule", result) == "Rule [matched 91.0%]" + + +def test_format_rule_list_text_returns_below_score(): + result = RuleRunResult(rule_name="Rule", score=0.842) + + assert format_rule_list_text("Rule", result) == "Rule [below 84.2%]" + + +def test_format_rule_list_text_returns_cooldown(): + result = RuleRunResult(rule_name="Rule", skipped_cooldown=True) + + assert format_rule_list_text("Rule", result) == "Rule [cooldown]" + + +def test_format_rule_list_text_returns_error(): + result = RuleRunResult(rule_name="Rule", error="failed") + + assert format_rule_list_text("Rule", result) == "Rule [error]"