diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 15f0f62..edd826f 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -70,8 +70,16 @@ jobs: uses: actions/download-artifact@v4 with: name: coverage-xml + - name: Read package version + # Without a version, SonarCloud's "previous version" new-code baseline has + # nothing to anchor to and counts the whole history as new -- which is how + # new_lines came to exceed the project's total line count. + id: version + run: echo "value=$(grep -m1 '^version = ' pyproject.toml | cut -d'"' -f2)" >> "$GITHUB_OUTPUT" - name: SonarQube Cloud scan uses: SonarSource/sonarqube-scan-action@v8.2.1 + with: + args: -Dsonar.projectVersion=${{ steps.version.outputs.value }} env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_HOST_URL: https://sonarcloud.io diff --git a/.github/workflows/stable.yml b/.github/workflows/stable.yml index 00c0997..1c64b31 100644 --- a/.github/workflows/stable.yml +++ b/.github/workflows/stable.yml @@ -70,8 +70,16 @@ jobs: uses: actions/download-artifact@v4 with: name: coverage-xml + - name: Read package version + # Without a version, SonarCloud's "previous version" new-code baseline has + # nothing to anchor to and counts the whole history as new -- which is how + # new_lines came to exceed the project's total line count. + id: version + run: echo "value=$(grep -m1 '^version = ' pyproject.toml | cut -d'"' -f2)" >> "$GITHUB_OUTPUT" - name: SonarQube Cloud scan uses: SonarSource/sonarqube-scan-action@v8.2.1 + with: + args: -Dsonar.projectVersion=${{ steps.version.outputs.value }} env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_HOST_URL: https://sonarcloud.io diff --git a/architecture_explore.md b/architecture_explore.md index a5a2d8f..313f66e 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -243,20 +243,33 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) ``` extend_ai_gui/ -├── ai_gui_global_variable.py 模板檔名清單 + 檔名→模板內容對照表 +├── ai_gui_global_variable.py 模板檔名清單 + 檔名→內建模板內容對照表 +├── prompt_store.py 編輯過的 prompt 檔案解析(~/.pybreeze/prompts/) ├── code_review/ -│ ├── code_review_thread.py SenderThread(QThread):CoT 多階段審查 +│ ├── cot_chain.py 接線表(純邏輯,無 Qt):哪步引用哪步 +│ ├── code_review_thread.py SenderThread(QThread):跑八步審查鏈 │ └── cot_code_review_gui.py UI ├── prompt_edit_gui/ -│ ├── cot_prompt_editor_widget.py 編輯 5 個 CoT 模板(QFileSystemWatcher 熱更新) -│ ├── skills_prompt_editor_widget.py 編輯 2 個 Skill 模板 +│ ├── prompt_editor_widget.py 共用編輯器(QFileSystemWatcher 熱更新) +│ ├── cot_prompt_editor_widget.py 8 個 CoT 模板的檔案清單+語言鍵 +│ ├── skills_prompt_editor_widget.py 2 個 Skill 模板的檔案清單+語言鍵 │ ├── prompt_file_io.py 共用存檔(失敗跳警告對話框) -│ ├── cot_code_review_prompt_templates/ 7 個模板常數 +│ ├── cot_code_review_prompt_templates/ 8 個模板常數+global_rule │ └── skills_prompt_templates/ 2 個模板常數 └── skills/skills_send_gui.py 單次 prompt 發送(RequestThread) ``` -**CoT 審查鏈**(`code_review_thread.py`):`first_summary` → `first_code_review` → `linter` → `code_smell_detector` → `total_summary`。前四階段的結果被收集起來餵給最後的 total summary。每階段都套 `build_global_rule_template()` 包一層全域規則。 +**CoT 審查鏈**(`cot_chain.py` 定義接線,`code_review_thread.py` 執行)八個步驟: + +``` +first_summary → first_code_review → judge_single_review ┐(評分前一步的審查) + → linter → code_smell_detector → step_by_step_analysis ┐(走過每條發現) + → total_summary → judge(帶 linter/code smell 脈絡評分總結) +``` + +**編輯過的 prompt 會生效**(`prompt_store.py`):每個模板都以程式碼常數出貨,`~/.pybreeze/prompts/<名稱>.md` 存在且非空時覆寫它。編輯器讀寫的就是這個位置,所以在編輯器裡改 prompt 會改變審查實際送出的內容 —— 這正是編輯器存在的理由。檔案缺失、空白、讀不到都退回內建版本;編輯過的 prompt 若含有鏈填不了的 placeholder,記 log 後退回內建,不讓整次審查倒在使用者無法從 UI 診斷的 `KeyError` 上。讀取不會建目錄,只有存檔才會。 + +`cot_chain.py` 用兩張表描述接線:`STEP_RESULT_KEY`(每步答案存在哪個 key)與 `STEP_ARGUMENTS`(每步的 placeholder 由哪個 key 填)。**順序即相依順序** —— 每步只能引用它上面的步驟,`test_cot_chain.py` 有結構性測試守住這件事。步驟失敗時錯誤訊息只顯示給使用者、不會被存進 results,避免後續步驟把「傳送失敗」當成審查內容引用。每步都套 `build_global_rule_template()` 包一層全域規則。 安全處理:送出前 `validate_url()`、`allow_redirects=False`、`stream=True` 搭配 `read_capped_text()` 限制回應大小、單一 `requests.Session` 重用 TCP/TLS 連線、`isInterruptionRequested()` 讓 widget 關閉時能中止。 @@ -387,6 +400,7 @@ extend_ai_gui/ |---|---| | `ssh_known_hosts` | TOFU 確認過的 SSH host key | | `prthinker_setting.json` | prthinker 後端/平台/金鑰設定 | +| `prompts/*.md` | 編輯過的 CoT / Skill prompt,覆寫內建模板 | | `response_stats.txt` | AI 審查接受/拒絕統計 | | `urls.txt` | AI 審查端點歷史 | @@ -396,10 +410,10 @@ extend_ai_gui/ ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 60 個 `test_*.py`。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 65 個 `test_*.py`、985 個測試。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 -- **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 57%(`utils/` 與 `tools_gui` 95–100%,UI 層 20–45% 拉低) +- **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) - **靜態分析** SonarCloud(`sonar-project.properties`,CI-based analysis;Automatic Analysis 已關閉且必須維持關閉,兩種模式互斥)+ Codacy(`.codacy.yml`)+ Bandit(`pyproject.toml` 中排除 test、skip B101/B404) - **SonarCloud 方案限制** 該組織的方案只開放 `main` 與 PR 的分析結果。非 main 分支的分析送得出去、CE 任務也會成功,但結果讀回來是 403(組織內每個專案都只有 `main` 一條分支)。因此 `dev.yml` 只在 PR 時掃描,`stable.yml` 另外掃 push to `main` diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index 9258609..3c11025 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -134,6 +134,8 @@ "prthinker_setting_extra_arguments_label": "Extra command-line arguments", "prthinker_setting_source_path_label": "prthinker source folder", "prthinker_setting_stored_at_label": "Stored at:", + # Prompt editors — where the edited prompts override the built-in ones from + "prompt_editor_stored_at_label": "Prompt files (these override the built-in prompts):", "prthinker_choose_source_path_label": "Choose the prthinker source folder", "prthinker_need_source_path_message": "prthinker is installed from source. Choose the folder holding its " diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index b6fd5f7..fb41649 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -134,6 +134,8 @@ "prthinker_setting_extra_arguments_label": "額外的命令列參數", "prthinker_setting_source_path_label": "prthinker 原始碼資料夾", "prthinker_setting_stored_at_label": "設定檔位置:", + # Prompt 編輯器 —— 編輯過的 prompt 會覆寫內建版本 + "prompt_editor_stored_at_label": "Prompt 檔案位置(會覆寫內建 prompt):", "prthinker_choose_source_path_label": "選擇 prthinker 原始碼資料夾", "prthinker_need_source_path_message": "prthinker 是從原始碼安裝的。請選擇含有 pyproject.toml 的資料夾," diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/ai_gui_global_variable.py b/pybreeze/pybreeze_ui/extend_ai_gui/ai_gui_global_variable.py index b4984d3..1c78dd8 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/ai_gui_global_variable.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/ai_gui_global_variable.py @@ -6,8 +6,14 @@ FIRST_CODE_REVIEW_TEMPLATE from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.cot_code_review_prompt_templates.first_summary_prompt import \ FIRST_SUMMARY_TEMPLATE +from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.cot_code_review_prompt_templates.judge import \ + JUDGE_TEMPLATE +from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.cot_code_review_prompt_templates.judge_single_review import \ + JUDGE_SINGLE_REVIEW_TEMPLATE from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.cot_code_review_prompt_templates.linter import \ LINTER_TEMPLATE +from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.cot_code_review_prompt_templates.step_by_step_analysis import \ + STEP_BY_STEP_ANALYSIS_TEMPLATE from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.cot_code_review_prompt_templates.total_summary import \ TOTAL_SUMMARY_TEMPLATE from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.skills_prompt_templates.code_explainer import \ @@ -15,20 +21,30 @@ from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.skills_prompt_templates.code_review import \ CODE_REVIEW_SKILL_TEMPLATE +# The order the chain runs in. Each step may only quote steps above it, so the +# order is a dependency order, not a preference: judge_single_review scores the +# review written just before it, step_by_step_analysis walks the linter and code +# smell findings, and judge scores the finished summary. COT_TEMPLATE_FILES = [ "first_summary_prompt.md", "first_code_review.md", + "judge_single_review.md", "linter.md", "code_smell_detector.md", + "step_by_step_analysis.md", "total_summary.md", + "judge.md", ] COT_TEMPLATE_RELATION = { "first_summary_prompt.md": FIRST_SUMMARY_TEMPLATE, "first_code_review.md": FIRST_CODE_REVIEW_TEMPLATE, + "judge_single_review.md": JUDGE_SINGLE_REVIEW_TEMPLATE, "linter.md": LINTER_TEMPLATE, "code_smell_detector.md": CODE_SMELL_DETECTOR_TEMPLATE, + "step_by_step_analysis.md": STEP_BY_STEP_ANALYSIS_TEMPLATE, "total_summary.md": TOTAL_SUMMARY_TEMPLATE, + "judge.md": JUDGE_TEMPLATE, } SKILLS_TEMPLATE_FILES = [ diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/code_review/code_review_thread.py b/pybreeze/pybreeze_ui/extend_ai_gui/code_review/code_review_thread.py index a8e48af..280df72 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/code_review/code_review_thread.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/code_review/code_review_thread.py @@ -5,9 +5,9 @@ from PySide6.QtCore import QThread, Signal from je_editor import language_wrapper -from pybreeze.pybreeze_ui.extend_ai_gui.ai_gui_global_variable import COT_TEMPLATE_RELATION -from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.cot_code_review_prompt_templates.global_rule import \ - build_global_rule_template +from pybreeze.pybreeze_ui.extend_ai_gui.code_review.cot_chain import ( + CODE_DIFF, STEP_RESULT_KEY, build_prompt +) from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.utils.network.http_client import ( ResponseTooLargeError, read_capped_text, CONNECT_TIMEOUT, @@ -40,74 +40,36 @@ def run(self): session.close() def _run_templates(self, session: requests.Session, code: str) -> None: - first_code_review_result = None - first_summary_result = None - linter_result = None - code_smell_result = None + # Answers accumulate here as the chain runs; a later step quotes whichever + # of them its template asks for. See cot_chain for the wiring. + results: dict[str, str] = {CODE_DIFF: code} for file in self.files: # Stop promptly if the widget is closing instead of firing off the # remaining per-template POSTs. if self.isInterruptionRequested(): return - match file: - case "first_summary_prompt.md": - first_summary_prompt = COT_TEMPLATE_RELATION["first_summary_prompt.md"] - prompt = build_global_rule_template( - prompt=first_summary_prompt.format(code_diff=code) - ) - case "first_code_review.md": - first_code_review_prompt = COT_TEMPLATE_RELATION["first_code_review.md"] - prompt = build_global_rule_template( - prompt=first_code_review_prompt.format(code_diff=code) - ) - case "linter.md": - linter_prompt = COT_TEMPLATE_RELATION["linter.md"] - prompt = build_global_rule_template( - prompt=linter_prompt.format(code_diff=code) - ) - case "code_smell_detector.md": - code_smell_detector_prompt = COT_TEMPLATE_RELATION["code_smell_detector.md"] - prompt = build_global_rule_template( - prompt=code_smell_detector_prompt.format(code_diff=code) - ) - case "total_summary.md": - total_summary_prompt = COT_TEMPLATE_RELATION["total_summary.md"] - prompt = build_global_rule_template( - prompt=total_summary_prompt.format( - first_code_review=first_code_review_result, - first_summary=first_summary_result, - linter_result=linter_result, - code_smell_result=code_smell_result, - code_diff=code, - ) - ) - case _: - continue - - try: - # 傳送到指定 URL(重用 session 連線) - resp = session.post( - self.url, json={"prompt": prompt}, - timeout=(CONNECT_TIMEOUT, 60), allow_redirects=False, stream=True, - ) - reply_text = read_capped_text(resp) - match file: - case "first_summary_prompt.md": - first_summary_result = reply_text - case "first_code_review.md": - first_code_review_result = reply_text - case "linter.md": - linter_result = reply_text - case "code_smell_detector.md": - code_smell_result = reply_text - case _: - # total_summary.md has no intermediate result to store but - # must still be emitted — `continue` here previously - # dropped the final summary before it reached the UI. - pass - except (requests.RequestException, ResponseTooLargeError) as e: - pybreeze_logger.error("CoT code review send failed for %s: %r", file, e) - reply_text = f"{language_wrapper.language_word_dict.get('cot_gui_error_sending')} {file} {e}" - + prompt = build_prompt(file, results) + if prompt is None: + continue + reply_text, answered = self._ask(session, file, prompt) + result_key = STEP_RESULT_KEY.get(file) + # A failure message is shown but never stored: a later step must not + # quote "could not send" back to the model as if it were a review. + if answered and result_key is not None: + results[result_key] = reply_text # 發送訊號更新 UI self.update_response.emit(file, reply_text) + + def _ask(self, session: requests.Session, file: str, prompt: str) -> tuple[str, bool]: + """Send one step's prompt; return its answer and whether it arrived.""" + try: + # 傳送到指定 URL(重用 session 連線) + resp = session.post( + self.url, json={"prompt": prompt}, + timeout=(CONNECT_TIMEOUT, 60), allow_redirects=False, stream=True, + ) + return read_capped_text(resp), True + except (requests.RequestException, ResponseTooLargeError) as error: + pybreeze_logger.error("CoT code review send failed for %s: %r", file, error) + word = language_wrapper.language_word_dict + return f"{word.get('cot_gui_error_sending')} {file} {error}", False diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/code_review/cot_chain.py b/pybreeze/pybreeze_ui/extend_ai_gui/code_review/cot_chain.py new file mode 100644 index 0000000..4b64ce3 --- /dev/null +++ b/pybreeze/pybreeze_ui/extend_ai_gui/code_review/cot_chain.py @@ -0,0 +1,100 @@ +"""How a chain-of-thought review is wired: what each step quotes from the ones before it. + +A step is one prompt sent to the review endpoint. Its answer is kept under a short +result key so a later step can quote it, and that quoting is what decides the order +the steps run in: a judge cannot score a review that has not been written yet, and +the step-by-step analysis has nothing to walk through until the linter and the code +smell detector have reported. + +Pure logic with no Qt and no network, so the wiring can be tested on its own. +""" +from __future__ import annotations + +from collections.abc import Mapping + +from pybreeze.pybreeze_ui.extend_ai_gui.ai_gui_global_variable import COT_TEMPLATE_RELATION +from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.cot_code_review_prompt_templates.global_rule import ( + build_global_rule_template +) +from pybreeze.pybreeze_ui.extend_ai_gui.prompt_store import load_prompt +from pybreeze.utils.logging.logger import pybreeze_logger + +# The key the code under review is seeded into the results under. +CODE_DIFF = "code_diff" + +# The result key each step's answer is stored under. +STEP_RESULT_KEY: dict[str, str] = { + "first_summary_prompt.md": "first_summary", + "first_code_review.md": "first_code_review", + "judge_single_review.md": "judge_single_review", + "linter.md": "linter", + "code_smell_detector.md": "code_smell", + "step_by_step_analysis.md": "step_by_step", + "total_summary.md": "total_summary", + "judge.md": "judge", +} + +# Each step's template placeholders, and the result key that fills each one. +STEP_ARGUMENTS: dict[str, dict[str, str]] = { + "first_summary_prompt.md": {"code_diff": CODE_DIFF}, + "first_code_review.md": {"code_diff": CODE_DIFF}, + # Scores the one review just written, against the code it was written about. + "judge_single_review.md": { + "review_comment": "first_code_review", + "code_diff": CODE_DIFF, + }, + "linter.md": {"code_diff": CODE_DIFF}, + "code_smell_detector.md": {"code_diff": CODE_DIFF}, + # Walks every lint message and code smell through cause, impact and fix. + "step_by_step_analysis.md": { + "linter_result": "linter", + "code_smell_result": "code_smell", + }, + "total_summary.md": { + "first_code_review": "first_code_review", + "first_summary": "first_summary", + "linter_result": "linter", + "code_smell_result": "code_smell", + "code_diff": CODE_DIFF, + }, + # Scores the finished summary with the findings it was meant to cover in hand. + "judge.md": { + "review_comment": "total_summary", + "code_smell_detector_messages": "code_smell", + "linter_messages": "linter", + "code_diff": CODE_DIFF, + }, +} + + +def build_prompt(step: str, results: Mapping[str, str]) -> str | None: + """Return the prompt for *step*, wrapped in the global review rules. + + :param step: the template file name the step is known by + :param results: the answers collected so far, keyed as in + :data:`STEP_RESULT_KEY`, with the code under review under + :data:`CODE_DIFF` + :return: the prompt to send, or ``None`` when *step* is not part of the chain + """ + built_in = COT_TEMPLATE_RELATION.get(step) + arguments = STEP_ARGUMENTS.get(step) + if built_in is None or arguments is None: + return None + # A step whose input never ran quotes an empty section rather than the word + # "None": the model should see that there is nothing there, not read a value. + filled = { + placeholder: results.get(key, "") + for placeholder, key in arguments.items() + } + template = load_prompt(step, built_in) + try: + body = template.format(**filled) + except (KeyError, IndexError, ValueError) as error: + # An edited prompt that names a placeholder the chain cannot fill would + # otherwise take the whole review down. Fall back to the built-in and say + # so, rather than failing a step the user cannot debug from the UI. + pybreeze_logger.error( + "Edited prompt %s could not be filled in (%r); using the built-in", + step, error) + body = built_in.format(**filled) + return build_global_rule_template(prompt=body) diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_prompt_editor_widget.py b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_prompt_editor_widget.py index 84d79ce..c74b833 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_prompt_editor_widget.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_prompt_editor_widget.py @@ -1,145 +1,34 @@ +"""The editor for the chain-of-thought review prompts.""" from __future__ import annotations -import os - -from PySide6.QtCore import QFileSystemWatcher -from PySide6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, - QComboBox, QTextEdit, QPushButton, QGroupBox, QMessageBox +from pybreeze.pybreeze_ui.extend_ai_gui.ai_gui_global_variable import ( + COT_TEMPLATE_FILES, COT_TEMPLATE_RELATION +) +from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.prompt_editor_widget import ( + PromptEditorLabels, PromptEditorWidget ) -from je_editor import language_wrapper - -from pybreeze.pybreeze_ui.extend_ai_gui.ai_gui_global_variable import COT_TEMPLATE_FILES, \ - COT_TEMPLATE_RELATION -from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.prompt_file_io import save_prompt_text - - -class CoTPromptEditor(QWidget): - def __init__(self, prompt_files=None, parent=None): - super().__init__(parent) - self.prompt_files = prompt_files or COT_TEMPLATE_FILES - - # 對應檔案名稱與模板內容 - self.templates = COT_TEMPLATE_RELATION - - self.setWindowTitle(language_wrapper.language_word_dict.get( - "cot_prompt_editor_window_title" - )) # 視窗標題:Prompt 編輯器 - - # --- Layouts (版面配置) --- - main_layout = QVBoxLayout(self) - top_layout = QHBoxLayout() - editor_layout = QHBoxLayout() - bottom_layout = QHBoxLayout() - - # --- ComboBox for selecting files (下拉選單選擇檔案) --- - self.file_selector = QComboBox() - self.file_selector.addItems(self.prompt_files) - self.file_selector.currentIndexChanged.connect(self.load_file_content) - - # --- Left Editable panel (左邊編輯區塊) --- - self.middle_editor = QTextEdit() - prompt_group = QGroupBox(language_wrapper.language_word_dict.get( - "cot_prompt_editor_groupbox_edit_file_content" - )) # 左邊編輯檔案內容 - middle_layout = QVBoxLayout() - middle_layout.addWidget(self.middle_editor) - prompt_group.setLayout(middle_layout) - - editor_layout.addWidget(prompt_group, 1) - - # --- Buttons --- - self.create_button = QPushButton(language_wrapper.language_word_dict.get( - "cot_prompt_editor_button_create_file" - )) - self.create_button.clicked.connect(self.create_file) - - self.save_button = QPushButton(language_wrapper.language_word_dict.get( - "cot_prompt_editor_button_save_file" - )) - self.save_button.clicked.connect(self.save_file) - - self.reload_button = QPushButton(language_wrapper.language_word_dict.get( - "cot_prompt_editor_button_reload_file" - )) - self.reload_button.clicked.connect(lambda: self.load_file_content(self.file_selector.currentIndex())) - - bottom_layout.addWidget(self.file_selector) - bottom_layout.addStretch() - bottom_layout.addWidget(self.reload_button) - bottom_layout.addWidget(self.save_button) - bottom_layout.addWidget(self.create_button) - - # --- Combine layouts (組合版面配置) --- - main_layout.addLayout(top_layout) - main_layout.addLayout(editor_layout) - main_layout.addLayout(bottom_layout) - - # --- FileSystemWatcher (檔案監控器) --- - self.watcher = QFileSystemWatcher(self.prompt_files) - self.watcher.fileChanged.connect(self.on_file_changed) - - # 預設載入第一個檔案 - self.load_file_content(0) - - def load_file_content(self, index): - """載入選擇的檔案內容到左邊編輯區""" - filename = self.prompt_files[index] - self.current_file = filename - if os.path.exists(filename): - with open(filename, encoding="utf-8") as f: - content = f.read() - self.middle_editor.setPlainText(content) - else: - self.middle_editor.setPlainText(language_wrapper.language_word_dict.get( - "cot_prompt_editor_file_not_exist" - ).format(filename=filename)) - - def create_file(self): - """建立目前選擇的檔案,若不存在則用模板內容建立""" - filename = self.current_file - if os.path.exists(filename): - QMessageBox.information( - self, - language_wrapper.language_word_dict.get("cot_prompt_editor_msgbox_info_title"), - language_wrapper.language_word_dict.get("cot_prompt_editor_msgbox_file_exists").format(filename=filename)) - return - - template_content = self.templates.get(filename, "") - if not save_prompt_text( - self, filename, template_content, - language_wrapper.language_word_dict.get("cot_prompt_editor_msgbox_error_title"), - ): - return - QMessageBox.information( - self, - language_wrapper.language_word_dict.get("cot_prompt_editor_msgbox_success_title"), - language_wrapper.language_word_dict.get("cot_prompt_editor_msgbox_file_created").format(filename=filename)) - self.load_file_content(self.file_selector.currentIndex()) +COT_LABELS = PromptEditorLabels( + window_title="cot_prompt_editor_window_title", + edit_group="cot_prompt_editor_groupbox_edit_file_content", + create_button="cot_prompt_editor_button_create_file", + save_button="cot_prompt_editor_button_save_file", + reload_button="cot_prompt_editor_button_reload_file", + file_not_exist="cot_prompt_editor_file_not_exist", + info_title="cot_prompt_editor_msgbox_info_title", + error_title="cot_prompt_editor_msgbox_error_title", + success_title="cot_prompt_editor_msgbox_success_title", + file_exists="cot_prompt_editor_msgbox_file_exists", + file_created="cot_prompt_editor_msgbox_file_created", + file_saved="cot_prompt_editor_msgbox_file_saved", + no_file_selected="cot_prompt_editor_msgbox_no_file_selected", +) - def on_file_changed(self, path): - """當檔案被外部修改時即時更新""" - if path == self.current_file: - self.load_file_content(self.file_selector.currentIndex()) - def save_file(self): - """將左邊編輯區內容儲存到目前檔案""" - if not hasattr(self, "current_file"): - QMessageBox.warning( - self, - language_wrapper.language_word_dict.get("cot_prompt_editor_msgbox_error_title"), - language_wrapper.language_word_dict.get("cot_prompt_editor_msgbox_no_file_selected")) - return +class CoTPromptEditor(PromptEditorWidget): + """Edit the prompts the chain-of-thought review sends, step by step.""" - content = self.middle_editor.toPlainText() - if not save_prompt_text( - self, self.current_file, content, - language_wrapper.language_word_dict.get("cot_prompt_editor_msgbox_error_title"), - ): - return - QMessageBox.information( - self, - language_wrapper.language_word_dict.get("cot_prompt_editor_msgbox_success_title"), - language_wrapper.language_word_dict.get("cot_prompt_editor_msgbox_file_saved").format( - filename=self.current_file)) + def __init__(self, prompt_files=None, parent=None) -> None: + super().__init__( + prompt_files or COT_TEMPLATE_FILES, COT_TEMPLATE_RELATION, + COT_LABELS, parent) diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py new file mode 100644 index 0000000..bb40054 --- /dev/null +++ b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py @@ -0,0 +1,165 @@ +"""One prompt editor, shared by the CoT and the Skill template editors. + +The two differ in exactly two things: which templates they list, and which +language keys label them. Everything else — loading the selected file, creating +it from its built-in template, saving, and picking up an external edit — was +written twice, line for line. It lives here once instead. + +The language keys arrive as a :class:`PromptEditorLabels` of literal strings +rather than being built from a prefix, so a key that does not exist can still be +caught by a test reading the source rather than only at runtime. +""" +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from PySide6.QtCore import QFileSystemWatcher +from PySide6.QtWidgets import ( + QComboBox, QGroupBox, QHBoxLayout, QLabel, QMessageBox, QPushButton, + QTextEdit, QVBoxLayout, QWidget +) +from je_editor import language_wrapper + +from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.prompt_file_io import save_prompt_text +from pybreeze.pybreeze_ui.extend_ai_gui.prompt_store import prompt_dir, prompt_path + + +@dataclass(frozen=True) +class PromptEditorLabels: + """The language keys one editor labels itself with.""" + + window_title: str + edit_group: str + create_button: str + save_button: str + reload_button: str + file_not_exist: str + info_title: str + error_title: str + success_title: str + file_exists: str + file_created: str + file_saved: str + no_file_selected: str + + +class PromptEditorWidget(QWidget): + """Edit the prompt files that override the built-in templates.""" + + def __init__(self, files: list[str], templates: dict[str, str], + labels: PromptEditorLabels, parent=None) -> None: + """ + :param files: the template file names this editor offers, in order + :param templates: file name -> the template compiled into the source tree + :param labels: the language keys this editor is labelled with + """ + super().__init__(parent) + self.prompt_files = files + self.templates = templates + self._labels = labels + self.current_file: str | None = None + + word = language_wrapper.language_word_dict + self.setWindowTitle(word.get(labels.window_title)) + + self.file_selector = QComboBox() + self.file_selector.addItems(self.prompt_files) + self.file_selector.currentIndexChanged.connect(self.load_file_content) + + self.middle_editor = QTextEdit() + group = QGroupBox(word.get(labels.edit_group)) + group_layout = QVBoxLayout() + group_layout.addWidget(self.middle_editor) + group.setLayout(group_layout) + + self.reload_button = QPushButton(word.get(labels.reload_button)) + self.reload_button.clicked.connect( + lambda: self.load_file_content(self.file_selector.currentIndex())) + self.save_button = QPushButton(word.get(labels.save_button)) + self.save_button.clicked.connect(self.save_file) + self.create_button = QPushButton(word.get(labels.create_button)) + self.create_button.clicked.connect(self.create_file) + + bottom_layout = QHBoxLayout() + bottom_layout.addWidget(self.file_selector) + bottom_layout.addStretch() + for button in (self.reload_button, self.save_button, self.create_button): + bottom_layout.addWidget(button) + + # 這些檔案覆寫實際送出的 prompt,位置要讓人找得到 + # These files override the prompt that is sent, so say where they are + where = QLabel(f"{word.get('prompt_editor_stored_at_label')} {prompt_dir()}") + where.setWordWrap(True) + + main_layout = QVBoxLayout(self) + main_layout.addWidget(group) + main_layout.addWidget(where) + main_layout.addLayout(bottom_layout) + + # 檔案監控器:外部改動即時反映 / Pick up edits made outside the editor + self.watcher = QFileSystemWatcher( + [str(prompt_path(name)) for name in self.prompt_files]) + self.watcher.fileChanged.connect(self.on_file_changed) + + if self.prompt_files: + self.load_file_content(0) + + def load_file_content(self, index: int) -> None: + """載入選擇的檔案內容 / Show the selected file, or say it is not there yet.""" + name = self.prompt_files[index] + self.current_file = str(prompt_path(name)) + path = Path(self.current_file) + if path.is_file(): + self.middle_editor.setPlainText(path.read_text(encoding="utf-8")) + return + self.middle_editor.setPlainText( + language_wrapper.language_word_dict.get( + self._labels.file_not_exist).format(filename=name)) + + def create_file(self) -> None: + """用內建模板建立目前選擇的檔案 / Create the selected file from its built-in template.""" + word = language_wrapper.language_word_dict + if self.current_file is None: + return + if Path(self.current_file).is_file(): + QMessageBox.information( + self, word.get(self._labels.info_title), + word.get(self._labels.file_exists).format(filename=self.current_file)) + return + + content = self.templates.get(Path(self.current_file).name, "") + if not save_prompt_text( + self, self.current_file, content, word.get(self._labels.error_title)): + return + + # Only now does the file exist, so only now can it be watched for the + # external edits this editor promises to pick up. + self.watcher.addPath(self.current_file) + QMessageBox.information( + self, word.get(self._labels.success_title), + word.get(self._labels.file_created).format(filename=self.current_file)) + self.load_file_content(self.file_selector.currentIndex()) + + def on_file_changed(self, path: str) -> None: + """外部改動時重新載入 / Reload when the file changes underneath us.""" + if path == self.current_file: + self.load_file_content(self.file_selector.currentIndex()) + + def save_file(self) -> None: + """把編輯區內容存回檔案 / Write the edit area back to the file.""" + word = language_wrapper.language_word_dict + if self.current_file is None: + QMessageBox.warning( + self, word.get(self._labels.error_title), + word.get(self._labels.no_file_selected)) + return + + if not save_prompt_text( + self, self.current_file, self.middle_editor.toPlainText(), + word.get(self._labels.error_title)): + return + self.watcher.addPath(self.current_file) + QMessageBox.information( + self, word.get(self._labels.success_title), + word.get(self._labels.file_saved).format(filename=self.current_file)) diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_file_io.py b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_file_io.py index 9e1623a..9627a37 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_file_io.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_file_io.py @@ -6,6 +6,8 @@ """ from __future__ import annotations +from pathlib import Path + from PySide6.QtWidgets import QMessageBox, QWidget from pybreeze.utils.logging.logger import pybreeze_logger @@ -18,6 +20,9 @@ def save_prompt_text(parent: QWidget, path: str, content: str, error_title: str) caller can skip its success message). """ try: + # The directory is made here rather than on every read, so a session that + # only looks at the built-in prompts leaves nothing behind. + Path(path).parent.mkdir(parents=True, exist_ok=True) with open(path, "w", encoding="utf-8") as file_handle: file_handle.write(content) return True diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/skills_prompt_editor_widget.py b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/skills_prompt_editor_widget.py index d266995..502ae86 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/skills_prompt_editor_widget.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/skills_prompt_editor_widget.py @@ -1,145 +1,34 @@ +"""The editor for the reusable skill prompts.""" from __future__ import annotations -import os - -from PySide6.QtCore import QFileSystemWatcher -from PySide6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, - QComboBox, QTextEdit, QPushButton, QGroupBox, QMessageBox +from pybreeze.pybreeze_ui.extend_ai_gui.ai_gui_global_variable import ( + SKILLS_TEMPLATE_FILES, SKILLS_TEMPLATE_RELATION +) +from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.prompt_editor_widget import ( + PromptEditorLabels, PromptEditorWidget ) -from je_editor import language_wrapper - -from pybreeze.pybreeze_ui.extend_ai_gui.ai_gui_global_variable import SKILLS_TEMPLATE_FILES, \ - SKILLS_TEMPLATE_RELATION -from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.prompt_file_io import save_prompt_text - - -class SkillPromptEditor(QWidget): - def __init__(self, skill_files=None, parent=None): - super().__init__(parent) - self.skill_files = skill_files or SKILLS_TEMPLATE_FILES - - # 對應檔案名稱與模板內容 - self.templates = SKILLS_TEMPLATE_RELATION - - self.setWindowTitle(language_wrapper.language_word_dict.get( - "skill_prompt_editor_window_title" - )) # 視窗標題:Skill Prompt 編輯器 - - # --- Layouts --- - main_layout = QVBoxLayout(self) - editor_layout = QHBoxLayout() - bottom_layout = QHBoxLayout() - - # --- ComboBox for selecting files --- - self.file_selector = QComboBox() - self.file_selector.addItems(self.skill_files) - self.file_selector.currentIndexChanged.connect(self.load_file_content) - - # --- Editable panel --- - self.middle_editor = QTextEdit() - skill_group = QGroupBox(language_wrapper.language_word_dict.get( - "skill_prompt_editor_groupbox_edit_file_content" - )) - middle_layout = QVBoxLayout() - middle_layout.addWidget(self.middle_editor) - skill_group.setLayout(middle_layout) - - editor_layout.addWidget(skill_group, 1) - - # --- Buttons --- - self.create_button = QPushButton(language_wrapper.language_word_dict.get( - "skill_prompt_editor_button_create_file" - )) - self.create_button.clicked.connect(self.create_file) - - self.save_button = QPushButton(language_wrapper.language_word_dict.get( - "skill_prompt_editor_button_save_file" - )) - self.save_button.clicked.connect(self.save_file) - - self.reload_button = QPushButton(language_wrapper.language_word_dict.get( - "skill_prompt_editor_button_reload_file" - )) - self.reload_button.clicked.connect(lambda: self.load_file_content(self.file_selector.currentIndex())) - - bottom_layout.addWidget(self.file_selector) - bottom_layout.addStretch() - bottom_layout.addWidget(self.reload_button) - bottom_layout.addWidget(self.save_button) - bottom_layout.addWidget(self.create_button) - - # --- Combine layouts --- - main_layout.addLayout(editor_layout) - main_layout.addLayout(bottom_layout) - - # --- FileSystemWatcher --- - self.watcher = QFileSystemWatcher(self.skill_files) - self.watcher.fileChanged.connect(self.on_file_changed) - - # 預設載入第一個檔案 - self.load_file_content(0) - - def load_file_content(self, index): - """載入選擇的檔案內容到編輯區""" - filename = self.skill_files[index] - self.current_file = filename - if os.path.exists(filename): - with open(filename, encoding="utf-8") as f: - content = f.read() - self.middle_editor.setPlainText(content) - else: - self.middle_editor.setPlainText(language_wrapper.language_word_dict.get( - "skill_prompt_editor_file_not_exist" - ).format(filename=filename)) - - def create_file(self): - """建立目前選擇的檔案,若不存在則用模板內容建立""" - filename = self.current_file - if os.path.exists(filename): - QMessageBox.information( - self, - language_wrapper.language_word_dict.get("skill_prompt_editor_msgbox_info_title"), - language_wrapper.language_word_dict.get("skill_prompt_editor_msgbox_file_exists").format( - filename=filename)) - return - - template_content = self.templates.get(filename, "") - if not save_prompt_text( - self, filename, template_content, - language_wrapper.language_word_dict.get("skill_prompt_editor_msgbox_error_title"), - ): - return - QMessageBox.information( - self, - language_wrapper.language_word_dict.get("skill_prompt_editor_msgbox_success_title"), - language_wrapper.language_word_dict.get("skill_prompt_editor_msgbox_file_created").format( - filename=filename)) - self.load_file_content(self.file_selector.currentIndex()) +SKILL_LABELS = PromptEditorLabels( + window_title="skill_prompt_editor_window_title", + edit_group="skill_prompt_editor_groupbox_edit_file_content", + create_button="skill_prompt_editor_button_create_file", + save_button="skill_prompt_editor_button_save_file", + reload_button="skill_prompt_editor_button_reload_file", + file_not_exist="skill_prompt_editor_file_not_exist", + info_title="skill_prompt_editor_msgbox_info_title", + error_title="skill_prompt_editor_msgbox_error_title", + success_title="skill_prompt_editor_msgbox_success_title", + file_exists="skill_prompt_editor_msgbox_file_exists", + file_created="skill_prompt_editor_msgbox_file_created", + file_saved="skill_prompt_editor_msgbox_file_saved", + no_file_selected="skill_prompt_editor_msgbox_no_file_selected", +) - def on_file_changed(self, path): - """當檔案被外部修改時即時更新""" - if path == self.current_file: - self.load_file_content(self.file_selector.currentIndex()) - def save_file(self): - """將編輯區內容儲存到目前檔案""" - if not hasattr(self, "current_file"): - QMessageBox.warning( - self, - language_wrapper.language_word_dict.get("skill_prompt_editor_msgbox_error_title"), - language_wrapper.language_word_dict.get("skill_prompt_editor_msgbox_no_file_selected")) - return +class SkillPromptEditor(PromptEditorWidget): + """Edit the reusable skill prompts the send window offers.""" - content = self.middle_editor.toPlainText() - if not save_prompt_text( - self, self.current_file, content, - language_wrapper.language_word_dict.get("skill_prompt_editor_msgbox_error_title"), - ): - return - QMessageBox.information( - self, - language_wrapper.language_word_dict.get("skill_prompt_editor_msgbox_success_title"), - language_wrapper.language_word_dict.get("skill_prompt_editor_msgbox_file_saved").format( - filename=self.current_file)) + def __init__(self, skill_files=None, parent=None) -> None: + super().__init__( + skill_files or SKILLS_TEMPLATE_FILES, SKILLS_TEMPLATE_RELATION, + SKILL_LABELS, parent) diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_store.py b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_store.py new file mode 100644 index 0000000..ed28cb1 --- /dev/null +++ b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_store.py @@ -0,0 +1,56 @@ +"""Where the editable prompt templates live, and how one is resolved. + +Every template ships as a constant in the source tree and may be overridden by a +file the user edits. The file wins when it has content, so editing a prompt in the +editor changes what the review actually sends — which is the only reason the +editor is there. + +The files sit under the user's own directory rather than the working one, so the +prompts someone has written are the same whichever folder the IDE was started +from, matching where the SSH known hosts and the prthinker settings are kept. +""" +from __future__ import annotations + +from pathlib import Path + +from pybreeze.utils.app_dirs import pybreeze_data_dir +from pybreeze.utils.logging.logger import pybreeze_logger + +_PROMPT_DIR_NAME = "prompts" + + +def prompt_dir() -> Path: + """Return the directory the editable prompt files live in. + + Reading and naming a prompt must not create anything: opening the editor to + look at a built-in prompt should leave no directory behind. ``save_prompt_text`` + makes the directory when there is finally something to put in it. + """ + return pybreeze_data_dir() / _PROMPT_DIR_NAME + + +def prompt_path(name: str) -> Path: + """Return the file the prompt called *name* is edited in.""" + return prompt_dir() / name + + +def load_prompt(name: str, default: str) -> str: + """Return the edited prompt *name*, or the built-in *default*. + + A file that is missing, empty or unreadable falls back to the built-in: an + override that says nothing must not leave a review step with nothing to ask, + and a file that cannot be read must not stop the review. + + :param name: the prompt's file name, e.g. ``linter.md`` + :param default: the template compiled into the source tree + :return: the prompt text to use + """ + path = prompt_path(name) + if not path.is_file(): + return default + try: + edited = path.read_text(encoding="utf-8") + except OSError as error: + pybreeze_logger.error("Prompt %s could not be read: %r", name, error) + return default + return edited if edited.strip() else default diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/skills/skills_send_gui.py b/pybreeze/pybreeze_ui/extend_ai_gui/skills/skills_send_gui.py index fc01c6a..3838706 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/skills/skills_send_gui.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/skills/skills_send_gui.py @@ -8,7 +8,10 @@ from PySide6.QtCore import QThread, Signal from je_editor import language_wrapper -from pybreeze.pybreeze_ui.extend_ai_gui.ai_gui_global_variable import SKILLS_TEMPLATE_FILES +from pybreeze.pybreeze_ui.extend_ai_gui.ai_gui_global_variable import ( + SKILLS_TEMPLATE_FILES, SKILLS_TEMPLATE_RELATION +) +from pybreeze.pybreeze_ui.extend_ai_gui.prompt_store import load_prompt from pybreeze.utils.logging.logger import pybreeze_logger from pybreeze.utils.network.http_client import ( ResponseTooLargeError, read_capped_text, CONNECT_TIMEOUT, truncate_for_display, @@ -80,6 +83,7 @@ def __init__(self): self.prompt_select_label = QLabel(language_wrapper.language_word_dict.get("skills_prompt_select_label")) self.prompt_select = QComboBox() self.prompt_select.addItems(SKILLS_TEMPLATE_FILES) + self.prompt_select.currentTextChanged.connect(self.load_selected_prompt) layout.addWidget(self.prompt_select_label) layout.addWidget(self.prompt_select) @@ -104,6 +108,25 @@ def __init__(self): self.setLayout(layout) self.thread = None # 保存執行緒 + # 開啟時就把選到的那個模板載進來,選單才不是擺著好看 + # Load the selected template on open, so the selector does something + self.load_selected_prompt(self.prompt_select.currentText()) + + def load_selected_prompt(self, name: str) -> None: + """ + 把選到的 skill 模板載進編輯區 + Put the selected skill template in the edit area. + + 以編輯過的檔案為準,沒有就用內建的;載進來以後仍然可以改,送出的是編輯區的內容。 + The edited file wins over the built-in one. What lands here stays + editable: what is sent is whatever the edit area holds. + + :param name: 模板檔名 / the template's file name + """ + built_in = SKILLS_TEMPLATE_RELATION.get(name) + if built_in is None: + return + self.prompt_input.setPlainText(load_prompt(name, built_in)) def send_prompt(self): # Ignore re-submits while a request is in flight: reassigning self.thread diff --git a/test/test_utils/test_cot_chain.py b/test/test_utils/test_cot_chain.py new file mode 100644 index 0000000..c79f7ff --- /dev/null +++ b/test/test_utils/test_cot_chain.py @@ -0,0 +1,124 @@ +"""The chain-of-thought wiring: what each step quotes, and that the order allows it.""" +from __future__ import annotations + +import pytest + +from pybreeze.pybreeze_ui.extend_ai_gui.ai_gui_global_variable import ( + COT_TEMPLATE_FILES, COT_TEMPLATE_RELATION +) +from pybreeze.pybreeze_ui.extend_ai_gui.code_review.cot_chain import ( + CODE_DIFF, STEP_ARGUMENTS, STEP_RESULT_KEY, build_prompt +) + +CODE = "def apply_discount(total, percent):\n return total - total * percent / 100\n" + + +def run_chain(steps=None, answers=None) -> dict[str, str]: + """Walk the chain, recording the prompt each step would send. + + :param steps: the steps to run, defaulting to the whole chain + :param answers: answer text per step, defaulting to a recognisable marker + :return: step name -> the prompt built for it + """ + steps = COT_TEMPLATE_FILES if steps is None else steps + answers = answers or {} + results = {CODE_DIFF: CODE} + prompts: dict[str, str] = {} + for step in steps: + prompts[step] = build_prompt(step, results) + results[STEP_RESULT_KEY[step]] = answers.get(step, f"<>") + return prompts + + +class TestTheChainIsCompletelyWired: + def test_every_step_has_a_template(self): + assert set(COT_TEMPLATE_FILES) <= set(COT_TEMPLATE_RELATION) + + def test_every_step_has_arguments_and_a_result_key(self): + for step in COT_TEMPLATE_FILES: + assert step in STEP_ARGUMENTS, step + assert step in STEP_RESULT_KEY, step + + def test_no_template_is_left_out_of_the_chain(self): + # A template nobody runs is dead weight: this is the guard that caught + # judge, judge_single_review and step_by_step_analysis sitting unused. + assert set(COT_TEMPLATE_RELATION) == set(COT_TEMPLATE_FILES) + + def test_result_keys_are_distinct(self): + keys = [STEP_RESULT_KEY[step] for step in COT_TEMPLATE_FILES] + assert len(keys) == len(set(keys)) + + def test_arguments_match_the_template_placeholders(self): + for step in COT_TEMPLATE_FILES: + # format() raises KeyError for a placeholder the wiring forgot, and + # the wiring naming one the template lacks is caught by the diff below. + filled = {name: "x" for name in STEP_ARGUMENTS[step]} + assert COT_TEMPLATE_RELATION[step].format(**filled) + + +class TestTheOrderIsADependencyOrder: + def test_no_step_quotes_a_result_that_has_not_been_produced_yet(self): + produced = {CODE_DIFF} + for step in COT_TEMPLATE_FILES: + needed = set(STEP_ARGUMENTS[step].values()) + assert needed <= produced, ( + f"{step} quotes {needed - produced}, which no earlier step produces") + produced.add(STEP_RESULT_KEY[step]) + + def test_every_step_builds_a_prompt_when_run_in_order(self): + assert all(prompt for prompt in run_chain().values()) + + +class TestWhatEachStepQuotes: + def test_the_code_reaches_the_first_review(self): + assert CODE in run_chain()["first_code_review.md"] + + def test_the_single_review_judge_quotes_the_review_it_scores(self): + prompts = run_chain() + assert "<>" in prompts["judge_single_review.md"] + + def test_the_single_review_judge_also_sees_the_original_code(self): + assert CODE in run_chain()["judge_single_review.md"] + + def test_the_step_by_step_analysis_walks_the_linter_and_the_smells(self): + prompt = run_chain()["step_by_step_analysis.md"] + assert "<>" in prompt + assert "<>" in prompt + + def test_the_total_summary_gathers_the_four_earlier_answers(self): + prompt = run_chain()["total_summary.md"] + for earlier in ("first_summary_prompt.md", "first_code_review.md", + "linter.md", "code_smell_detector.md"): + assert f"<>" in prompt, earlier + + def test_the_final_judge_scores_the_summary_with_the_findings_in_hand(self): + prompt = run_chain()["judge.md"] + assert "<>" in prompt + assert "<>" in prompt + assert "<>" in prompt + + def test_every_prompt_carries_the_global_rules(self): + for step, prompt in run_chain().items(): + assert "conduct a code review according to the following global rules" in prompt, step + + +class TestRunningPartOfTheChain: + def test_a_step_whose_input_never_ran_quotes_nothing_rather_than_none(self): + # Selecting only the judge leaves it with no review to score. It must see + # an empty section, not the literal word "None" read as a review. + prompt = build_prompt("judge.md", {CODE_DIFF: CODE}) + assert prompt is not None + assert "None" not in prompt.split("## Review Comment:")[1].split("##")[0] + + def test_an_unknown_step_is_skipped(self): + assert build_prompt("nonsense.md", {CODE_DIFF: CODE}) is None + + def test_a_step_can_run_on_its_own(self): + assert CODE in build_prompt("linter.md", {CODE_DIFF: CODE}) + + +@pytest.mark.parametrize("step", COT_TEMPLATE_FILES) +def test_a_step_never_leaves_an_unfilled_placeholder(step): + prompt = run_chain()[step] + for placeholder in STEP_ARGUMENTS[step]: + assert "{" + placeholder + "}" not in prompt diff --git a/test/test_utils/test_cot_session_reuse.py b/test/test_utils/test_cot_session_reuse.py index d05d607..b331bf3 100644 --- a/test/test_utils/test_cot_session_reuse.py +++ b/test/test_utils/test_cot_session_reuse.py @@ -63,6 +63,84 @@ def test_one_session_serves_all_templates(): assert "total_summary.md" in {name for name, _ in received} +class _FlakySession(_FakeSession): + """Answers every step but the *fail_on*-th, which raises.""" + + def __init__(self, fail_on: int): + super().__init__() + self.fail_on = fail_on + + def post(self, url, **kwargs): + import requests + + self.post_calls.append((url, kwargs)) + if len(self.post_calls) == self.fail_on: + raise requests.RequestException("endpoint down") + return _FakeResponse(f"answer {len(self.post_calls)}".encode()) + + +def _prompts(session) -> list[str]: + return [kwargs["json"]["prompt"] for _url, kwargs in session.post_calls] + + +def test_a_failed_step_is_shown_but_never_quoted_back(): + # The linter step fails. The step-by-step analysis quotes the linter, and must + # quote nothing rather than feed "could not send" back as if it were findings. + _qt_app() + from pybreeze.pybreeze_ui.extend_ai_gui.ai_gui_global_variable import COT_TEMPLATE_FILES + from pybreeze.pybreeze_ui.extend_ai_gui.code_review.code_review_thread import SenderThread + + linter_step = COT_TEMPLATE_FILES.index("linter.md") + thread = SenderThread(files=list(COT_TEMPLATE_FILES), code="print('x')", + url="https://example.com/api") + received = {} + thread.update_response.connect(lambda name, resp: received.__setitem__(name, resp)) + + session = _FlakySession(fail_on=linter_step + 1) + thread._run_templates(session, "print('x')") + + # The failure still reaches the user ... + assert "endpoint down" in received["linter.md"] + # ... every later step still runs ... + assert len(session.post_calls) == len(COT_TEMPLATE_FILES) + # ... and none of them carries the failure text into the model. + later = _prompts(session)[linter_step + 1:] + assert not any("endpoint down" in prompt for prompt in later) + + +def test_an_answered_step_is_quoted_by_the_step_that_needs_it(): + _qt_app() + from pybreeze.pybreeze_ui.extend_ai_gui.ai_gui_global_variable import COT_TEMPLATE_FILES + from pybreeze.pybreeze_ui.extend_ai_gui.code_review.code_review_thread import SenderThread + + thread = SenderThread(files=list(COT_TEMPLATE_FILES), code="print('x')", + url="https://example.com/api") + session = _FlakySession(fail_on=0) # nothing fails + thread._run_templates(session, "print('x')") + + prompts = _prompts(session) + # judge_single_review is third and quotes the second step's answer. + assert "answer 2" in prompts[COT_TEMPLATE_FILES.index("judge_single_review.md")] + # judge is last and quotes the total summary written just before it. + assert "answer 7" in prompts[COT_TEMPLATE_FILES.index("judge.md")] + + +def test_an_interrupted_run_stops_sending(): + _qt_app() + from pybreeze.pybreeze_ui.extend_ai_gui.ai_gui_global_variable import COT_TEMPLATE_FILES + from pybreeze.pybreeze_ui.extend_ai_gui.code_review.code_review_thread import SenderThread + + thread = SenderThread(files=list(COT_TEMPLATE_FILES), code="print('x')", + url="https://example.com/api") + session = _FlakySession(fail_on=0) + # Interrupt once two steps have gone out, as closing the widget would. + thread.isInterruptionRequested = lambda: len(session.post_calls) >= 2 + + thread._run_templates(session, "print('x')") + + assert len(session.post_calls) == 2 + + def test_run_closes_session_even_on_error(monkeypatch): _qt_app() from pybreeze.pybreeze_ui.extend_ai_gui.code_review import code_review_thread as mod diff --git a/test/test_utils/test_file_tree_context_menu.py b/test/test_utils/test_file_tree_context_menu.py new file mode 100644 index 0000000..69de26e --- /dev/null +++ b/test/test_utils/test_file_tree_context_menu.py @@ -0,0 +1,328 @@ +"""The project tree's right-click actions: creating, renaming, deleting, copying. + +Every action is driven through a real ``QTreeView`` over a real ``QFileSystemModel`` +rooted in a temporary directory, with the modal dialogs stubbed out so the answer +the user would have given is supplied directly. +""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtCore import QPoint, Qt +from PySide6.QtWidgets import ( + QApplication, QFileSystemModel, QMessageBox, QTabWidget, QTreeView, QWidget +) + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.editor_main import file_tree_context_menu as ctx +from pybreeze.pybreeze_ui.editor_main.file_tree_context_menu import ( + _action_copy_path, _action_delete, _action_new_file, _action_new_folder, + _action_rename, _attach_context_menu, _find_editor_for_file, _get_tree_root_path, + _perform_file_op, _resolve_parent_dir, setup_file_tree_context_menu +) + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +@pytest.fixture() +def tree(app, tmp_path): + """A tree view rooted at *tmp_path*, as the project tree would be.""" + view = QTreeView() + model = QFileSystemModel() + model.setRootPath(str(tmp_path)) + view.setModel(model) + view.setRootIndex(model.index(str(tmp_path))) + yield view + view.deleteLater() + + +def answer(monkeypatch, text: str, accepted: bool = True) -> None: + """Stub the name prompt with what the user would have typed.""" + monkeypatch.setattr( + ctx.QInputDialog, "getText", + staticmethod(lambda *a, **k: (text, accepted))) + + +def confirm(monkeypatch, yes: bool) -> None: + """Stub the delete confirmation.""" + button = (QMessageBox.StandardButton.Yes if yes + else QMessageBox.StandardButton.No) + monkeypatch.setattr( + ctx.QMessageBox, "question", staticmethod(lambda *a, **k: button)) + + +@pytest.fixture() +def warnings(monkeypatch): + """Collect the warning dialogs an action raises instead of showing them.""" + shown: list[str] = [] + monkeypatch.setattr( + ctx.QMessageBox, "warning", + staticmethod(lambda _p, _t, message, *a, **k: shown.append(message))) + return shown + + +class FakeEditor(QWidget): + """Stands in for an EditorWidget holding one open file. + + A real QWidget, because the delete path looks the editor up with + ``tab_widget.indexOf`` and removes its tab. + """ + + def __init__(self, path: str) -> None: + super().__init__() + self.current_file = path + self.code_edit = type("Edit", (), {"current_file": path})() + self.renamed = False + self.closed = False + + def rename_self_tab(self) -> None: + self.renamed = True + + def close(self) -> bool: + self.closed = True + return super().close() + + +class FakeWindow: + """A main window with just the tab widget the actions reach for.""" + + def __init__(self) -> None: + self.tab_widget = QTabWidget() + + +class TestWhereANewItemGoes: + def test_a_directory_receives_the_new_item(self, tree, tmp_path): + folder = tmp_path / "pkg" + folder.mkdir() + assert _resolve_parent_dir(tree, folder) == folder + + def test_a_file_puts_it_beside_itself(self, tree, tmp_path): + target = tmp_path / "module.py" + target.touch() + assert _resolve_parent_dir(tree, target) == tmp_path + + def test_no_selection_falls_back_to_the_tree_root(self, tree, tmp_path): + assert _resolve_parent_dir(tree, None) == tmp_path + + def test_the_root_is_what_the_view_is_rooted_at(self, tree, tmp_path): + assert _get_tree_root_path(tree) == tmp_path + + +class TestSurfacingFailures: + def test_a_successful_operation_reports_success(self, tree): + assert _perform_file_op(tree, lambda: None) is True + + def test_an_os_error_becomes_a_dialog_not_a_traceback(self, tree, warnings): + def explode() -> None: + raise OSError("disk is full") + + assert _perform_file_op(tree, explode) is False + assert "disk is full" in warnings[0] + + +class TestCreating: + def test_a_new_file_appears(self, tree, tmp_path, monkeypatch): + answer(monkeypatch, "notes.txt") + _action_new_file(tree, None) + assert (tmp_path / "notes.txt").is_file() + + def test_a_cancelled_prompt_creates_nothing(self, tree, tmp_path, monkeypatch): + answer(monkeypatch, "notes.txt", accepted=False) + _action_new_file(tree, None) + assert not (tmp_path / "notes.txt").exists() + + def test_a_blank_name_creates_nothing(self, tree, tmp_path, monkeypatch): + answer(monkeypatch, " ") + _action_new_file(tree, None) + assert list(tmp_path.iterdir()) == [] + + def test_the_name_is_trimmed(self, tree, tmp_path, monkeypatch): + answer(monkeypatch, " notes.txt ") + _action_new_file(tree, None) + assert (tmp_path / "notes.txt").is_file() + + def test_an_existing_name_is_refused_rather_than_overwritten( + self, tree, tmp_path, monkeypatch, warnings): + existing = tmp_path / "notes.txt" + existing.write_text("keep me", encoding="utf-8") + answer(monkeypatch, "notes.txt") + _action_new_file(tree, None) + assert existing.read_text(encoding="utf-8") == "keep me" + assert warnings + + def test_a_new_folder_appears(self, tree, tmp_path, monkeypatch): + answer(monkeypatch, "package") + _action_new_folder(tree, None) + assert (tmp_path / "package").is_dir() + + def test_a_new_folder_lands_inside_the_selected_directory( + self, tree, tmp_path, monkeypatch): + parent = tmp_path / "outer" + parent.mkdir() + answer(monkeypatch, "inner") + _action_new_folder(tree, parent) + assert (parent / "inner").is_dir() + + def test_an_existing_folder_name_is_refused( + self, tree, tmp_path, monkeypatch, warnings): + (tmp_path / "package").mkdir() + answer(monkeypatch, "package") + _action_new_folder(tree, None) + assert warnings + + +class TestRenaming: + def test_the_file_moves_to_the_new_name(self, tree, tmp_path, monkeypatch): + original = tmp_path / "old.py" + original.write_text("body", encoding="utf-8") + answer(monkeypatch, "new.py") + _action_rename(tree, FakeWindow(), original) + assert not original.exists() + assert (tmp_path / "new.py").read_text(encoding="utf-8") == "body" + + def test_nothing_selected_does_nothing(self, tree, tmp_path, monkeypatch): + answer(monkeypatch, "new.py") + _action_rename(tree, FakeWindow(), None) + assert list(tmp_path.iterdir()) == [] + + def test_the_same_name_is_a_no_op(self, tree, tmp_path, monkeypatch): + original = tmp_path / "same.py" + original.touch() + answer(monkeypatch, "same.py") + _action_rename(tree, FakeWindow(), original) + assert original.exists() + + def test_renaming_onto_an_existing_file_is_refused( + self, tree, tmp_path, monkeypatch, warnings): + original = tmp_path / "old.py" + original.touch() + occupied = tmp_path / "taken.py" + occupied.write_text("keep me", encoding="utf-8") + answer(monkeypatch, "taken.py") + _action_rename(tree, FakeWindow(), original) + assert original.exists() + assert occupied.read_text(encoding="utf-8") == "keep me" + assert warnings + + def test_an_open_tab_follows_the_rename(self, tree, tmp_path, monkeypatch): + original = tmp_path / "open.py" + original.touch() + window = FakeWindow() + editor = FakeEditor(str(original)) + monkeypatch.setattr( + ctx, "_find_editor_for_file", lambda _w, _p: editor) + answer(monkeypatch, "renamed.py") + _action_rename(tree, window, original) + assert editor.current_file == str(tmp_path / "renamed.py") + assert editor.code_edit.current_file == str(tmp_path / "renamed.py") + assert editor.renamed + + +class TestDeleting: + def test_a_confirmed_delete_removes_the_file(self, tree, tmp_path, monkeypatch): + target = tmp_path / "gone.py" + target.touch() + confirm(monkeypatch, yes=True) + _action_delete(tree, FakeWindow(), target) + assert not target.exists() + + def test_declining_keeps_the_file(self, tree, tmp_path, monkeypatch): + target = tmp_path / "kept.py" + target.touch() + confirm(monkeypatch, yes=False) + _action_delete(tree, FakeWindow(), target) + assert target.exists() + + def test_a_directory_goes_with_its_contents(self, tree, tmp_path, monkeypatch): + folder = tmp_path / "pkg" + folder.mkdir() + (folder / "inner.py").touch() + confirm(monkeypatch, yes=True) + _action_delete(tree, FakeWindow(), folder) + assert not folder.exists() + + def test_nothing_selected_does_nothing(self, tree, tmp_path, monkeypatch): + confirm(monkeypatch, yes=True) + _action_delete(tree, FakeWindow(), None) + + def test_an_open_tab_is_closed_with_the_file(self, tree, tmp_path, monkeypatch): + target = tmp_path / "open.py" + target.touch() + window = FakeWindow() + editor = FakeEditor(str(target)) + window.tab_widget.addTab(editor, "open.py") + monkeypatch.setattr(ctx, "_find_editor_for_file", lambda _w, _p: editor) + confirm(monkeypatch, yes=True) + _action_delete(tree, window, target) + assert editor.closed + assert window.tab_widget.count() == 0 + assert not target.exists() + + +class TestCopyingThePath: + def test_the_absolute_path_reaches_the_clipboard(self, tree, tmp_path): + target = tmp_path / "module.py" + target.touch() + _action_copy_path(tree, target, relative=False) + assert QApplication.clipboard().text() == str(target) + + def test_the_relative_path_is_relative_to_the_tree_root(self, tree, tmp_path): + nested = tmp_path / "pkg" + nested.mkdir() + target = nested / "module.py" + target.touch() + _action_copy_path(tree, target, relative=True) + assert QApplication.clipboard().text() == os.path.join("pkg", "module.py") + + def test_a_path_outside_the_root_falls_back_to_absolute(self, tree, tmp_path): + outside = tmp_path.parent / "elsewhere.py" + _action_copy_path(tree, outside, relative=True) + assert QApplication.clipboard().text() == str(outside) + + def test_nothing_selected_leaves_the_clipboard_alone(self, tree): + QApplication.clipboard().setText("untouched") + _action_copy_path(tree, None) + assert QApplication.clipboard().text() == "untouched" + + +class TestFindingTheOpenEditor: + def test_a_window_with_no_editor_tabs_finds_nothing(self, app, tmp_path): + assert _find_editor_for_file(FakeWindow(), tmp_path / "any.py") is None + + +class TestAttachingTheMenu: + def test_the_view_switches_to_a_custom_menu(self, tree): + _attach_context_menu(tree, FakeWindow()) + assert tree.contextMenuPolicy() == Qt.ContextMenuPolicy.CustomContextMenu + + def test_attaching_twice_still_opens_one_menu(self, tree, monkeypatch): + # A second attach must not connect the signal again: two handlers would + # pop the context menu twice for a single right-click. + opened: list[object] = [] + monkeypatch.setattr( + ctx, "_show_context_menu", + lambda pos, tv, mw: opened.append(pos)) + window = FakeWindow() + _attach_context_menu(tree, window) + _attach_context_menu(tree, window) + tree.customContextMenuRequested.emit(QPoint(1, 1)) + assert len(opened) == 1 + + def test_setup_leaves_later_tabs_working(self, app): + # setup wraps addTab so future editor tabs also get the menu; the wrapper + # must still add the tab and return the index addTab promises. + window = FakeWindow() + setup_file_tree_context_menu(window) + placeholder = QTreeView() + index = window.tab_widget.addTab(placeholder, "tab") + assert index == 0 + assert window.tab_widget.count() == 1 + placeholder.deleteLater() diff --git a/test/test_utils/test_language_parity.py b/test/test_utils/test_language_parity.py index 26a206f..0cbaf13 100644 --- a/test/test_utils/test_language_parity.py +++ b/test/test_utils/test_language_parity.py @@ -61,3 +61,24 @@ def test_every_get_key_exists_in_dict(self): used = _code_used_keys() missing = {k: src for k, src in used.items() if k not in EN} assert not missing, f"language_word_dict.get() keys missing from the dict: {missing}" + + def test_every_prompt_editor_label_key_exists(self): + # The prompt editors pass their keys through a dataclass, so the regex + # above cannot see them. Check the declared keys directly instead, or a + # renamed key would show up as a blank button with nothing to catch it. + import dataclasses + + from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.cot_prompt_editor_widget import ( + COT_LABELS + ) + from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.skills_prompt_editor_widget import ( + SKILL_LABELS + ) + + missing = [ + key + for labels in (COT_LABELS, SKILL_LABELS) + for key in dataclasses.astuple(labels) + if key not in EN + ] + assert not missing, f"Prompt editor label keys missing from the dict: {missing}" diff --git a/test/test_utils/test_plugin_menu.py b/test/test_utils/test_plugin_menu.py new file mode 100644 index 0000000..67612cc --- /dev/null +++ b/test/test_utils/test_plugin_menu.py @@ -0,0 +1,246 @@ +"""The plugin menus: what they build from a plugin registry, and what "Run with" refuses. + +Both menus read je_editor's plugin registry, so each test supplies its own +registry contents rather than depending on whichever plugins happen to be +installed. +""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import ( + QApplication, QMainWindow, QMenuBar, QTabWidget, QWidget +) + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.menu.plugin_menu import build_plugin_menu as plugin_menu +from pybreeze.pybreeze_ui.menu.plugin_menu import build_run_with_menu as run_with +from pybreeze.pybreeze_ui.menu.plugin_menu.build_plugin_menu import set_plugin_menu +from pybreeze.pybreeze_ui.menu.plugin_menu.build_run_with_menu import ( + _get_current_file, _run_with, set_run_with_menu +) + +GO_CONFIG = {"name": "Go", "compiler": "go", "args": ("run",), "suffixes": (".go",)} +RUST_CONFIG = {"name": "Rust", "compiler": "rustc", "suffixes": (".rs",), + "compile_then_run": True, "output_flag": "-o"} +MULTI_CONFIG = {"name": "C++", "compiler": "g++", "suffixes": (".cpp", ".hpp")} + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +class FakeWindow(QMainWindow): + """A main window with the handful of members the menu builders touch.""" + + def __init__(self) -> None: + super().__init__() + self.menu = QMenuBar(self) + self.run_menu = self.menu.addMenu("Run") + self.tab_widget = QTabWidget() + self.current_run_code_window: list[QWidget] = [] + self.encoding = "utf-8" + + +@pytest.fixture() +def window(app): + made = FakeWindow() + yield made + made.deleteLater() + + +def labels(menu) -> list[str]: + return [action.text() for action in menu.actions()] + + +def submenu_action_texts(menu) -> list[list[str]]: + """Return the action texts of each submenu of *menu*, submenu by submenu. + + The texts are read while the owning ``QAction`` is still referenced rather + than handed back as ``QMenu`` objects: a submenu built by ``addMenu(title)`` + is owned through its action, and under pytest the wrapper is collected + eagerly enough that a returned ``QMenu`` can be dead before the caller + touches it. + """ + collected: list[list[str]] = [] + for action in menu.actions(): + sub = action.menu() + if sub is not None: + collected.append([entry.text() for entry in sub.actions()]) + del sub + return collected + + +class TestTheRunWithMenu: + def test_no_plugins_means_no_menu(self, window, monkeypatch): + monkeypatch.setattr(run_with, "get_all_plugin_run_configs", lambda: []) + set_run_with_menu(window) + assert not hasattr(window, "run_with_menu") + + def test_one_entry_per_run_config(self, window, monkeypatch): + monkeypatch.setattr( + run_with, "get_all_plugin_run_configs", lambda: [GO_CONFIG, RUST_CONFIG]) + set_run_with_menu(window) + assert len(window.run_with_menu.actions()) == 2 + + def test_entries_are_sorted_by_name(self, window, monkeypatch): + monkeypatch.setattr( + run_with, "get_all_plugin_run_configs", + lambda: [RUST_CONFIG, GO_CONFIG, MULTI_CONFIG]) + set_run_with_menu(window) + names = [text.split(" ")[0] for text in labels(window.run_with_menu)] + assert names == ["C++", "Go", "Rust"] + + def test_the_label_lists_the_suffixes(self, window, monkeypatch): + monkeypatch.setattr( + run_with, "get_all_plugin_run_configs", lambda: [MULTI_CONFIG]) + set_run_with_menu(window) + assert ".cpp, .hpp" in labels(window.run_with_menu)[0] + + def test_a_config_without_suffixes_is_labelled_by_name_alone( + self, window, monkeypatch): + monkeypatch.setattr( + run_with, "get_all_plugin_run_configs", lambda: [{"name": "Bare"}]) + set_run_with_menu(window) + assert labels(window.run_with_menu) == ["Bare"] + + +class TestFindingTheFileToRun: + def test_a_non_editor_tab_offers_no_file(self, window): + window.tab_widget.addTab(QWidget(), "not an editor") + window.tab_widget.setCurrentIndex(0) + assert _get_current_file(window) is None + + def test_no_tabs_at_all_offers_no_file(self, window): + assert _get_current_file(window) is None + + +class TestRefusingToRunTheWrongFile: + def test_a_suffix_mismatch_warns_and_runs_nothing( + self, window, tmp_path, monkeypatch): + script = tmp_path / "script.py" + script.touch() + monkeypatch.setattr(run_with, "_get_current_file", lambda _w: str(script)) + shown: list[str] = [] + monkeypatch.setattr( + run_with.QMessageBox, "exec", lambda self: shown.append(self.text())) + started: list[object] = [] + monkeypatch.setattr( + run_with, "FileRunnerProcess", + lambda **kwargs: started.append(kwargs)) + + _run_with(window, GO_CONFIG) + + assert shown and ".py" in shown[0] + assert not started + + def test_no_file_means_nothing_runs(self, window, monkeypatch): + monkeypatch.setattr(run_with, "_get_current_file", lambda _w: None) + started: list[object] = [] + monkeypatch.setattr( + run_with, "FileRunnerProcess", lambda **kwargs: started.append(kwargs)) + _run_with(window, GO_CONFIG) + assert not started + + def test_a_matching_suffix_opens_a_run_window_and_starts( + self, window, tmp_path, monkeypatch): + script = tmp_path / "main.go" + script.touch() + monkeypatch.setattr(run_with, "_get_current_file", lambda _w: str(script)) + + class Runner: + def __init__(self, **kwargs) -> None: + self.kwargs = kwargs + started.append(self) + + def run_file(self, config, path) -> None: + self.ran = (config, path) + + started: list[Runner] = [] + monkeypatch.setattr(run_with, "FileRunnerProcess", Runner) + + _run_with(window, GO_CONFIG) + + assert len(started) == 1 + assert started[0].ran == (GO_CONFIG, str(script)) + assert len(window.current_run_code_window) == 1 + + def test_a_config_without_suffixes_accepts_any_file( + self, window, tmp_path, monkeypatch): + script = tmp_path / "anything.xyz" + script.touch() + monkeypatch.setattr(run_with, "_get_current_file", lambda _w: str(script)) + started: list[object] = [] + monkeypatch.setattr( + run_with, "FileRunnerProcess", + lambda **kwargs: type("R", (), {"run_file": lambda *a: started.append(a)})()) + _run_with(window, {"name": "Anything", "compiler": "cat"}) + assert started + + +class TestThePluginMenu: + def test_no_plugins_means_no_menu(self, window, monkeypatch): + monkeypatch.setattr(plugin_menu, "get_all_plugin_metadata", lambda: []) + set_plugin_menu(window) + assert not hasattr(window, "plugin_menu") + + def test_a_plugin_without_a_run_config_gets_a_bare_entry( + self, window, monkeypatch): + monkeypatch.setattr( + plugin_menu, "get_all_plugin_metadata", + lambda: [{"name": "French", "version": "1.0", "author": "someone"}]) + set_plugin_menu(window) + assert "French" in labels(window.plugin_menu) + + def test_a_plugin_with_a_run_config_gets_a_submenu(self, window, monkeypatch): + monkeypatch.setattr( + plugin_menu, "get_all_plugin_metadata", + lambda: [{"name": "Go", "version": "1.0", "author": "someone", + "run_config": GO_CONFIG}]) + set_plugin_menu(window) + submenus = submenu_action_texts(window.plugin_menu) + assert len(submenus) == 1 + # About, a separator (empty text), then one run action for the suffix + assert [text for text in submenus[0] if text] == ["About", "Run with Go"] + + def test_multiple_suffixes_get_one_run_action_each(self, window, monkeypatch): + monkeypatch.setattr( + plugin_menu, "get_all_plugin_metadata", + lambda: [{"name": "C++", "version": "1.0", "author": "someone", + "run_config": MULTI_CONFIG}]) + set_plugin_menu(window) + submenu = submenu_action_texts(window.plugin_menu)[0] + runs = [text for text in submenu if "(" in text] + assert len(runs) == 2 + assert ".cpp" in runs[0] and ".hpp" in runs[1] + + def test_the_browser_entry_comes_first(self, window, monkeypatch): + monkeypatch.setattr( + plugin_menu, "get_all_plugin_metadata", + lambda: [{"name": "French", "version": "1.0", "author": "someone"}]) + set_plugin_menu(window) + assert "Plugin Browser" in window.plugin_menu.actions()[0].text() + + def test_the_about_dialog_names_version_and_author(self, app, monkeypatch): + shown: list[str] = [] + monkeypatch.setattr( + plugin_menu.QMessageBox, "exec", lambda self: shown.append(self.text())) + plugin_menu._make_about_callback("Go", "2.1", "someone")() + assert "2.1" in shown[0] + assert "someone" in shown[0] + + def test_a_run_callback_ignores_a_non_editor_tab(self, window, monkeypatch): + window.tab_widget.addTab(QWidget(), "not an editor") + window.tab_widget.setCurrentIndex(0) + started: list[object] = [] + monkeypatch.setattr( + plugin_menu, "FileRunnerProcess", + lambda **kwargs: started.append(kwargs)) + plugin_menu._make_run_callback(window, GO_CONFIG)() + assert not started diff --git a/test/test_utils/test_prompt_store.py b/test/test_utils/test_prompt_store.py new file mode 100644 index 0000000..71ea936 --- /dev/null +++ b/test/test_utils/test_prompt_store.py @@ -0,0 +1,203 @@ +"""An edited prompt file must be what the review actually sends.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest + +from pybreeze.pybreeze_ui.extend_ai_gui import prompt_store +from pybreeze.pybreeze_ui.extend_ai_gui.ai_gui_global_variable import ( + COT_TEMPLATE_RELATION, SKILLS_TEMPLATE_RELATION +) +from pybreeze.pybreeze_ui.extend_ai_gui.code_review.cot_chain import CODE_DIFF, build_prompt +from pybreeze.pybreeze_ui.extend_ai_gui.prompt_store import ( + load_prompt, prompt_dir, prompt_path +) + +CODE = "def f():\n pass\n" + + +@pytest.fixture() +def prompts(tmp_path, monkeypatch): + """Point the prompt directory at a temporary one, never the real home.""" + monkeypatch.setattr(prompt_store, "pybreeze_data_dir", lambda: tmp_path) + return tmp_path / "prompts" + + +def write(prompts, name: str, text: str) -> None: + prompts.mkdir(parents=True, exist_ok=True) + (prompts / name).write_text(text, encoding="utf-8") + + +class TestWhereThePromptsLive: + def test_looking_at_a_prompt_creates_no_directory(self, prompts): + # Opening the editor on a built-in prompt must leave nothing behind. + load_prompt("linter.md", "built-in") + assert not prompt_dir().exists() + + def test_saving_creates_the_directory(self, prompts, tmp_path): + from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.prompt_file_io import ( + save_prompt_text + ) + + assert save_prompt_text(None, str(prompt_path("linter.md")), "text", "error") + assert prompt_dir().is_dir() + assert load_prompt("linter.md", "built-in") == "text" + + def test_it_sits_under_the_user_directory_not_the_working_one(self, prompts): + # The whole point of moving off bare filenames: the prompts a user wrote + # are the same whichever folder the IDE was started from. + assert prompt_path("linter.md").parent == prompts + + def test_a_prompt_path_is_named_after_its_template(self, prompts): + assert prompt_path("linter.md").name == "linter.md" + + +class TestResolvingAPrompt: + def test_no_file_means_the_built_in_is_used(self, prompts): + assert load_prompt("linter.md", "built-in") == "built-in" + + def test_an_edited_file_wins(self, prompts): + write(prompts, "linter.md", "my own linter prompt") + assert load_prompt("linter.md", "built-in") == "my own linter prompt" + + def test_an_empty_file_falls_back_rather_than_asking_nothing(self, prompts): + write(prompts, "linter.md", " \n ") + assert load_prompt("linter.md", "built-in") == "built-in" + + def test_an_unreadable_file_falls_back_instead_of_stopping_the_review( + self, prompts, monkeypatch): + write(prompts, "linter.md", "my own linter prompt") + + def refuse(*_args, **_kwargs): + raise OSError("permission denied") + + monkeypatch.setattr(prompt_store.Path, "read_text", refuse) + assert load_prompt("linter.md", "built-in") == "built-in" + + +class TestTheChainUsesTheEditedPrompt: + def test_an_edited_step_reaches_the_prompt_that_is_sent(self, prompts): + write(prompts, "linter.md", "Only report imports. Code:\n{code_diff}") + prompt = build_prompt("linter.md", {CODE_DIFF: CODE}) + assert "Only report imports" in prompt + assert CODE in prompt + + def test_an_unedited_step_still_uses_its_built_in(self, prompts): + prompt = build_prompt("linter.md", {CODE_DIFF: CODE}) + assert "Only report imports" not in prompt + assert prompt + + def test_the_edited_prompt_is_still_wrapped_in_the_global_rules(self, prompts): + write(prompts, "linter.md", "Only report imports.") + prompt = build_prompt("linter.md", {CODE_DIFF: CODE}) + assert "conduct a code review according to the following global rules" in prompt + + def test_an_edit_that_drops_a_placeholder_is_honoured(self, prompts): + # Removing {code_diff} is a legitimate edit, not an error. + write(prompts, "linter.md", "Say hello and nothing else.") + assert "Say hello" in build_prompt("linter.md", {CODE_DIFF: CODE}) + + def test_an_edit_naming_an_unknown_placeholder_falls_back(self, prompts): + # A prompt the chain cannot fill would otherwise take the review down + # with a KeyError the user could not diagnose from the UI. + write(prompts, "linter.md", "Review {this_does_not_exist}") + prompt = build_prompt("linter.md", {CODE_DIFF: CODE}) + assert prompt is not None + assert "this_does_not_exist" not in prompt + assert CODE in prompt + + def test_every_template_can_be_overridden(self, prompts): + for name in COT_TEMPLATE_RELATION: + write(prompts, name, f"edited {name}") + prompt = build_prompt(name, {CODE_DIFF: CODE}) + assert f"edited {name}" in prompt, name + + +class TestTheEditorAndTheChainAgree: + """The point of the editor: what is saved there is what a review sends.""" + + def test_saving_in_the_editor_changes_what_the_chain_sends(self, prompts): + from PySide6.QtWidgets import QApplication, QMessageBox + + from pybreeze.extend_multi_language.update_language_dict import update_language_dict + from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.cot_prompt_editor_widget import ( + CoTPromptEditor + ) + + QApplication.instance() or QApplication([]) + update_language_dict() + editor = CoTPromptEditor() + # The confirmation dialogs would block; the save itself is what matters. + QMessageBox.information = staticmethod(lambda *a, **k: None) + + editor.file_selector.setCurrentIndex( + editor.prompt_files.index("linter.md")) + editor.middle_editor.setPlainText("Only report imports. Code:\n{code_diff}") + editor.save_file() + + assert "Only report imports" in build_prompt("linter.md", {CODE_DIFF: CODE}) + editor.deleteLater() + + def test_the_editor_writes_where_the_chain_reads(self, prompts): + from PySide6.QtWidgets import QApplication + + from pybreeze.extend_multi_language.update_language_dict import update_language_dict + from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.cot_prompt_editor_widget import ( + CoTPromptEditor + ) + + QApplication.instance() or QApplication([]) + update_language_dict() + editor = CoTPromptEditor() + editor.file_selector.setCurrentIndex(0) + assert editor.current_file == str(prompt_path(editor.prompt_files[0])) + editor.deleteLater() + + def test_the_editor_shows_an_edited_file_rather_than_the_built_in(self, prompts): + from PySide6.QtWidgets import QApplication + + from pybreeze.extend_multi_language.update_language_dict import update_language_dict + from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.cot_prompt_editor_widget import ( + CoTPromptEditor + ) + + QApplication.instance() or QApplication([]) + update_language_dict() + write(prompts, "linter.md", "my own linter prompt") + editor = CoTPromptEditor() + editor.file_selector.setCurrentIndex(editor.prompt_files.index("linter.md")) + assert editor.middle_editor.toPlainText() == "my own linter prompt" + editor.deleteLater() + + +class TestTheSkillSelectorLoadsWhatItNames: + def test_choosing_a_skill_loads_its_built_in_text(self, prompts): + from PySide6.QtWidgets import QApplication + + from pybreeze.extend_multi_language.update_language_dict import update_language_dict + from pybreeze.pybreeze_ui.extend_ai_gui.skills.skills_send_gui import SkillsSendGUI + + QApplication.instance() or QApplication([]) + update_language_dict() + widget = SkillsSendGUI() + first = widget.prompt_select.currentText() + assert widget.prompt_input.toPlainText() == SKILLS_TEMPLATE_RELATION[first] + widget.deleteLater() + + def test_an_edited_skill_is_what_the_selector_loads(self, prompts): + from PySide6.QtWidgets import QApplication + + from pybreeze.extend_multi_language.update_language_dict import update_language_dict + from pybreeze.pybreeze_ui.extend_ai_gui.skills.skills_send_gui import SkillsSendGUI + + QApplication.instance() or QApplication([]) + update_language_dict() + name = next(iter(SKILLS_TEMPLATE_RELATION)) + write(prompts, name, "my own skill prompt") + widget = SkillsSendGUI() + widget.prompt_select.setCurrentText(name) + assert widget.prompt_input.toPlainText() == "my own skill prompt" + widget.deleteLater() diff --git a/test/test_utils/test_prthinker_setting_dialog.py b/test/test_utils/test_prthinker_setting_dialog.py new file mode 100644 index 0000000..a2b0c39 --- /dev/null +++ b/test/test_utils/test_prthinker_setting_dialog.py @@ -0,0 +1,127 @@ +"""The prthinker settings form: what it builds, what it reads back, what it stores.""" +from __future__ import annotations + +import json +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication, QComboBox, QDialog, QLineEdit + +from pybreeze.extend.prthinker_extend import prthinker_setting +from pybreeze.extend.prthinker_extend.prthinker_setting import ( + BACKENDS, DEFAULT_SETTING, PLATFORMS, SETTING_FILE_NAME, save_setting +) +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.dialog import prthinker_setting_dialog +from pybreeze.pybreeze_ui.dialog.prthinker_setting_dialog import ( + FIELDS, SECRET_FIELDS, PRThinkerSettingDialog +) + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +@pytest.fixture() +def data_dir(tmp_path, monkeypatch): + """Point the settings file at a temporary directory, never the real home.""" + monkeypatch.setattr(prthinker_setting, "pybreeze_data_dir", lambda: tmp_path) + return tmp_path + + +@pytest.fixture() +def dialog(app, data_dir): + made = PRThinkerSettingDialog() + yield made + made.deleteLater() + + +def stored(data_dir) -> dict: + return json.loads((data_dir / SETTING_FILE_NAME).read_text(encoding="utf-8")) + + +class TestTheFormItBuilds: + def test_every_field_gets_an_editor(self, dialog): + assert set(dialog.editors) == {key for key, _label in FIELDS} + + def test_a_key_is_shown_as_dots(self, dialog): + for key in SECRET_FIELDS: + assert dialog.editors[key].echoMode() == QLineEdit.EchoMode.Password, key + + def test_an_ordinary_field_is_shown_as_text(self, dialog): + assert dialog.editors["repository"].echoMode() == QLineEdit.EchoMode.Normal + + def test_the_backend_is_chosen_from_the_supported_list(self, dialog): + editor = dialog.editors["backend"] + assert isinstance(editor, QComboBox) + assert [editor.itemText(i) for i in range(editor.count())] == list(BACKENDS) + + def test_the_platform_is_chosen_from_the_supported_list(self, dialog): + editor = dialog.editors["platform"] + assert [editor.itemText(i) for i in range(editor.count())] == list(PLATFORMS) + + def test_a_stored_choice_comes_back_selected(self, app, data_dir): + save_setting({**DEFAULT_SETTING, "backend": "anthropic", "platform": "gitea"}) + made = PRThinkerSettingDialog() + assert made.editors["backend"].currentText() == "anthropic" + assert made.editors["platform"].currentText() == "gitea" + made.deleteLater() + + def test_a_stored_value_that_is_not_on_offer_leaves_the_first_choice(self, app, data_dir): + (data_dir / SETTING_FILE_NAME).write_text( + json.dumps({**DEFAULT_SETTING, "backend": "nonsense"}), encoding="utf-8") + made = PRThinkerSettingDialog() + assert made.editors["backend"].currentText() == BACKENDS[0] + made.deleteLater() + + def test_stored_text_is_filled_in(self, app, data_dir): + save_setting({**DEFAULT_SETTING, "repository": "owner/name"}) + made = PRThinkerSettingDialog() + assert made.editors["repository"].text() == "owner/name" + made.deleteLater() + + +class TestReadingTheFormBack: + def test_values_reads_both_kinds_of_editor(self, dialog): + dialog.editors["repository"].setText("owner/name") + dialog.editors["backend"].setCurrentText("openai") + values = dialog.values() + assert values["repository"] == "owner/name" + assert values["backend"] == "openai" + + def test_values_covers_every_field(self, dialog): + assert set(dialog.values()) == {key for key, _label in FIELDS} + + +class TestSaving: + def test_what_was_typed_reaches_the_file(self, dialog, data_dir): + dialog.editors["repository"].setText("owner/name") + dialog.editors["platform_token"].setText("secret-token") + dialog.save() + assert stored(data_dir)["repository"] == "owner/name" + assert stored(data_dir)["platform_token"] == "secret-token" + + def test_saving_closes_the_window(self, dialog): + dialog.save() + assert dialog.result() == QDialog.DialogCode.Accepted + + def test_a_failed_save_leaves_the_window_open(self, dialog, monkeypatch): + # A settings file that cannot be written must not look like a success: + # the user needs the form still in front of them to retry or copy from. + monkeypatch.setattr( + prthinker_setting_dialog, "save_setting", lambda _setting: False) + dialog.save() + assert dialog.result() != QDialog.DialogCode.Accepted + + def test_a_field_left_untouched_keeps_its_stored_value(self, app, data_dir): + save_setting({**DEFAULT_SETTING, "model_name": "kept"}) + made = PRThinkerSettingDialog() + made.editors["repository"].setText("owner/name") + made.save() + assert stored(data_dir)["model_name"] == "kept" + made.deleteLater()