2026-09-09 16:46:15 +00:00
committed by gitea-admin
co-authored by duylh19
parent 13e2c22067
commit 1b8429e33a
147 changed files with 20993 additions and 461 deletions
+84
View File
@@ -0,0 +1,84 @@
"""Mục "Giới thiệu" trong Cài đặt, và dòng phiên bản ở góc dưới phải.
Dòng "Made by QuanDH14" từng là widget thường trực ở góc dưới phải, rồi chuyển
hẳn vào Cài đặt ▸ Giới thiệu. Nay chỗ đó mang MỘT thông tin khác: số phiên bản —
người dùng muốn tra được ngay mà không phải mở Cài đặt.
Điểm phải chốt là **một nguồn duy nhất**: tiêu đề cửa sổ, tab Giới thiệu và góc
dưới phải đều đọc ``cowork_local.__version__``. Ghi cứng con số ở một trong ba
chỗ thì lần nâng phiên bản sau sẽ để lại một chỗ nói sai.
"""
from __future__ import annotations
import pytest
from .test_settings_dialog_dac_ta import _Ctx
@pytest.fixture
def dialog(qapp):
from cowork_local.ui.settings_dialog import SettingsDialog
dlg = SettingsDialog(_Ctx())
yield dlg
dlg.deleteLater()
# ---- đầu MỚI: mục Giới thiệu trong Cài đặt -------------------------------
def test_cai_dat_co_muc_gioi_thieu(dialog):
from cowork_local.i18n import tr
labels = [dialog.section_list.item(i).text()
for i in range(dialog.section_list.count())]
assert tr("settings.group.about") in labels
def test_muc_gioi_thieu_dung_cuoi(dialog):
"""Nó không có gì để đổi, nên đặt trước các mục thao tác được là đẩy chúng
xuống mà không được gì."""
from cowork_local.i18n import tr
last = dialog.section_list.item(dialog.section_list.count() - 1).text()
assert last == tr("settings.group.about")
def test_muc_gioi_thieu_hien_dong_phien_ban(dialog):
from cowork_local import __version__
from cowork_local.i18n import tr
text = dialog._about_page.version_label.text()
assert text == tr("app.version", v=__version__)
assert __version__ in text
def test_muc_gioi_thieu_hien_ten_va_phien_ban(dialog):
from cowork_local import DISPLAY_NAME, __version__
text = dialog._about_page.app_label.text()
assert DISPLAY_NAME in text
assert __version__ in text
# ---- góc dưới phải: đúng một dòng, và là dòng phiên bản ------------------
def test_goc_duoi_phai_hien_phien_ban_tu_cung_mot_nguon():
"""Thanh trạng thái mang ĐÚNG một widget thường trực, là dòng phiên bản.
Kiểm trên mã nguồn thay vì dựng ``MainWindow`` — dựng cửa sổ thật mất ~35
giây cho một khẳng định mà đọc file trả lời được ngay.
"""
from pathlib import Path
src = (Path(__file__).resolve().parents[2]
/ "presentation" / "shell" / "main_window.py").read_text(encoding="utf-8")
assert "_credit" not in src, "widget ghi công tác giả đã chuyển vào Cài đặt"
assert src.count("addPermanentWidget") == 1, (
"góc dưới phải chỉ được mang MỘT widget thường trực — nó chiếm một góc "
"màn hình trên MỌI màn hình, suốt cả phiên làm việc")
assert 'tr("app.version", v=__version__)' in src, (
"dòng phiên bản phải đọc __version__ như tiêu đề cửa sổ và tab Giới "
"thiệu; ghi cứng con số ở đây thì lần nâng phiên bản sau sẽ nói sai")
assert "setSizeGripEnabled(False)" in src, (
"size grip vẽ một vệt ngay bên phải dòng phiên bản — góc đó chỉ được "
"mang mỗi con số; cửa sổ vẫn kéo giãn được từ các cạnh")
+170
View File
@@ -0,0 +1,170 @@
"""Màn giới thiệu của khung chat khi hội thoại còn rỗng.
Bấm "Cuộc trò chuyện mới" trước đây để lại một khung trắng: không có gì nói
người dùng đang làm trong project nào, thư mục có bao nhiêu tệp, hay bắt đầu từ
đâu. Đây là trạng thái RỖNG — trạng thái duy nhất người dùng nhìn thấy trước khi
họ gõ chữ đầu tiên.
"""
from __future__ import annotations
import pytest
pytest.importorskip("PySide6", reason="cần PySide6 để dựng widget thật")
@pytest.fixture
def welcome(qapp):
from cowork_local.presentation.chat.chat_welcome import ChatWelcome
w = ChatWelcome()
yield w
w.deleteLater()
# ---- bốn thẻ gợi ý -------------------------------------------------------
def test_co_dung_bon_the(welcome):
assert len(welcome.cards) == 4
def test_bam_the_thi_phat_cau_goi_y(welcome):
nhan = []
welcome.suggestion_picked.connect(nhan.append)
welcome.cards[0].click()
assert len(nhan) == 1
assert len(nhan[0]) > 20, "câu gợi ý phải là một câu thật, không phải nhãn thẻ"
def test_bon_the_cho_bon_cau_khac_nhau(welcome):
nhan = []
welcome.suggestion_picked.connect(nhan.append)
for card in welcome.cards:
card.click()
assert len(set(nhan)) == 4
def test_the_co_tieu_de_va_mo_ta(welcome):
for card in welcome.cards:
assert card.title_label.text().strip()
assert card.sub_label.text().strip()
# Nhãn của chính QPushButton phải rỗng — chữ do hai QLabel bên trong vẽ,
# đặt cả hai chỗ sẽ in đè lên nhau.
assert card.text() == ""
def test_the_khong_bi_bop_thanh_khung_rong(qapp, welcome):
"""Regression: bốn thẻ hiện ra nhưng RỖNG — không chữ, không icon.
``QPushButton`` tự tính ``sizeHint``/``minimumSizeHint`` từ text và icon
CỦA CHÍNH NÓ và bỏ qua layout con. Thẻ để cả hai thứ đó rỗng có chủ ý (chữ
do hai QLabel bên trong vẽ), nên nút báo 54x15 trong khi layout con đòi
258x48 — hai QLabel và ô icon được chia 0px chiều cao và không có gì được
vẽ ra. Đặt ``text()`` không rỗng thì chữ in đè, nên hai override là đường
duy nhất.
"""
welcome.resize(900, 700)
welcome.show()
qapp.processEvents()
try:
for card in welcome.cards:
can = card.layout().minimumSize()
assert card.width() >= can.width(), (
f"thẻ rộng {card.width()}px, layout con cần {can.width()}px")
assert card.height() >= can.height(), (
f"thẻ cao {card.height()}px, layout con cần {can.height()}px")
assert card.title_label.height() > 0, "tiêu đề bị chia 0px chiều cao"
assert card.sub_label.height() > 0, "dòng mô tả bị chia 0px chiều cao"
assert card._icon.height() > 0, "ô icon bị chia 0px chiều cao"
finally:
welcome.hide()
# ---- dòng bối cảnh: KHÔNG BIẾT khác 0 ------------------------------------
def test_khong_biet_so_tep_thi_bo_manh_do(welcome):
"""Hiện "0 tệp" khi người dùng vừa thấy có tệp trong thư mục còn tệ hơn là
bỏ mảnh đó khỏi dòng meta."""
welcome.refresh(user_name="local", project="p", files=-1)
meta = welcome.meta_label.text()
assert "p" in meta
assert "0" not in meta
def test_khong_co_tep_that_thi_van_hien_so_0(welcome):
"""Khác với KHÔNG BIẾT: thư mục rỗng thật thì nói rõ là rỗng."""
welcome.refresh(user_name="local", project="p", files=0)
assert "0" in welcome.meta_label.text()
def test_khong_co_gi_de_noi_thi_an_dong_meta(welcome):
welcome.refresh()
assert welcome.meta_label.isHidden() is True
def test_co_ten_thi_chao_ten(welcome):
welcome.refresh(user_name="Mynt4")
assert "Mynt4" in welcome.greeting_label.text()
def test_khong_co_ten_thi_khong_chao_rong(welcome):
"""Không được ra "Chào — bắt đầu từ đâu?" với một khoảng trống."""
from cowork_local.i18n import tr
welcome.refresh(user_name="")
assert welcome.greeting_label.text() == tr("welcome.greeting_anon")
# ---- i18n ----------------------------------------------------------------
@pytest.mark.parametrize("key", [
"welcome.greeting", "welcome.greeting_anon", "welcome.meta_project",
"welcome.meta_files",
"welcome.card_docs", "welcome.card_docs_sub", "welcome.prompt_docs",
"welcome.card_data", "welcome.card_data_sub", "welcome.prompt_data",
"welcome.card_schedule", "welcome.card_schedule_sub", "welcome.prompt_schedule",
"welcome.card_graph", "welcome.card_graph_sub", "welcome.prompt_graph",
])
def test_key_co_du_ba_ngon_ngu(key):
from cowork_local import i18n
for lang in ("en", "ja", "vi"):
assert i18n.STRINGS[key].get(lang), f"{key} thiếu {lang}"
def test_doi_ngon_ngu_thi_the_doi_theo(welcome):
from cowork_local import i18n
truoc = i18n.get_language()
try:
i18n.set_language("vi")
welcome.refresh(user_name="x", project="p")
vi = welcome.cards[0].title_label.text()
i18n.set_language("ja")
ja = welcome.cards[0].title_label.text()
finally:
i18n.set_language(truoc)
assert vi != ja, "thẻ không được áp lại khi đổi ngôn ngữ"
# ---- màu lấy từ theme ----------------------------------------------------
def test_duoc_style_bang_token_trong_theme():
"""Guardrail G4: ngoài theme/ không file nào được đặt tên một màu."""
from pathlib import Path
from cowork_local.theme.qss import _TEMPLATE
for rule in ("QWidget#chatWelcome", "QPushButton#welcomeCard",
"QLabel#welcomeGreeting"):
assert rule in _TEMPLATE.template, f"{rule} chưa được style trong theme"
src = (Path(__file__).resolve().parents[2]
/ "presentation" / "chat" / "chat_welcome.py").read_text(encoding="utf-8")
assert "setStyleSheet" not in src
+174
View File
@@ -0,0 +1,174 @@
"""Co4E left column: the skill library must be read ONCE per sidebar reload.
Why this test exists
--------------------
``Co4ESidebarMixin._reload_sidebar`` used to call
``core.skills.skill_prefix_for(name)`` once per skill. Every one of those calls
re-reads the *whole* skill folder from disk (``list_skills()`` +
``builtin_skills()``), so the reload was O(N**2) in the number of skills.
That reload runs on every language switch (``Co4ETab._retranslate`` ->
``_reload_sidebar``). Measured end-to-end on a real library of 121 skills, the
per-name calls cost ~3.8 s of blocked GUI thread — the "app freezes for a few
seconds when I switch Vietnamese -> English" the user reported.
Two different things are guarded here, and they fail for different reasons:
* ``test_reload_sidebar_reads_the_skill_library_once`` — the performance
contract. Red before the fix (the library was read 1 + N times), green after.
* the two equivalence tests — the *correctness* contract. A lookup table is
easy to build subtly wrong, and a wrong one silently changes which skill text
is pushed into an agent prompt. They pin the answer to ``skill_prefix_for``
itself in exactly the cases where a naive ``{name: skill}`` dict diverges:
a name shared by a user skill and a built-in (first match wins, user first),
a skill whose instructions are blank (it does NOT end ``skill_prefix_for``'s
scan, so a later namesake with real instructions must still win), and
lookups by slug / different case / padding / unknown name / empty name.
Those two are characterization tests: green before AND after by design.
"""
from __future__ import annotations
from dataclasses import replace
import pytest
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QListWidget
from cowork_local.core import co4e, skills as skills_mod
from cowork_local.presentation.co4e.co4e_sidebar import Co4ESidebarMixin
class _SidebarHarness(Co4ESidebarMixin):
"""Just enough state to run the real ``_reload_sidebar``.
Building a whole ``Co4ETab`` would drag in config/HOME-dependent module
constants (see ``tests/test_build_co4e_tab.py``); the mixin only touches
the three list widgets, so the real production method runs unchanged here.
"""
def __init__(self) -> None:
"""Create the three palette lists ``_reload_sidebar`` fills in."""
self.wf_list = QListWidget()
self.agent_list = QListWidget()
self.skill_list = QListWidget()
@pytest.fixture
def harness(qapp, monkeypatch):
"""A sidebar harness with the workflow/agent sources stubbed out empty."""
monkeypatch.setattr(co4e, "list_workflows", lambda: [])
monkeypatch.setattr(co4e, "list_custom_agents", lambda: [])
return _SidebarHarness()
def _install_skills(monkeypatch, user, builtin):
"""Replace the two disk-reading skill sources and count how often they run.
Fresh copies are handed out on every call so a caller mutating a returned
``Skill`` cannot make a later call look different.
"""
calls = {"list_skills": 0, "builtin_skills": 0}
def _list_skills(directory=None):
"""Stand-in for ``skills.list_skills`` that records each read."""
calls["list_skills"] += 1
return [replace(s) for s in user]
def _builtin_skills():
"""Stand-in for ``skills.builtin_skills`` that records each read."""
calls["builtin_skills"] += 1
return [replace(s) for s in builtin]
monkeypatch.setattr(skills_mod, "list_skills", _list_skills)
monkeypatch.setattr(skills_mod, "builtin_skills", _builtin_skills)
return calls
def _skill_rows(sidebar):
"""Return ``(item text, payload instructions)`` for every skill palette row."""
return [(sidebar.skill_list.item(i).text(),
sidebar.skill_list.item(i).data(Qt.UserRole)["instructions"])
for i in range(sidebar.skill_list.count())]
def test_reload_sidebar_reads_the_skill_library_once(harness, monkeypatch):
"""One sidebar reload must hit the skill library exactly once, not once per skill."""
user = [skills_mod.Skill(name=f"Skill {i}", instructions=f"Body {i}")
for i in range(8)]
calls = _install_skills(monkeypatch, user, [])
harness._reload_sidebar()
assert harness.skill_list.count() == 8, "every skill must still reach the palette"
assert calls["list_skills"] == 1, (
f"the skill library was read {calls['list_skills']}x for 8 skills — "
"reading it once per skill is the O(N^2) freeze on language switch"
)
assert calls["builtin_skills"] == 1, (
f"built-in skills were read {calls['builtin_skills']}x for 8 skills"
)
def test_skill_payloads_match_skill_prefix_for(harness, monkeypatch):
"""The palette payload must be byte-identical to ``skill_prefix_for``'s answer.
The fixture is deliberately hostile: a name shared by a user skill and a
built-in, a blank-instructions skill followed by a namesake with real
content, and names with padding/odd casing.
"""
user = [
skills_mod.Skill(name="Shared Name", instructions="USER VERSION"),
skills_mod.Skill(name="Blank First", instructions=" "),
skills_mod.Skill(name="Blank First", instructions="LATER, WITH CONTENT"),
skills_mod.Skill(name=" Padded Name ", instructions="PADDED BODY"),
skills_mod.Skill(name="MiXeD CaSe", instructions="MIXED BODY"),
]
builtin = [
skills_mod.Skill(name="Shared Name", instructions="BUILTIN VERSION"),
skills_mod.Skill(name="Builtin Only", instructions="BUILTIN BODY"),
]
_install_skills(monkeypatch, user, builtin)
harness._reload_sidebar()
expected = [(s.name, skills_mod.skill_prefix_for(s.name)) for s in user + builtin]
assert _skill_rows(harness) == expected
rows = dict(_skill_rows(harness))
# A user skill wins over a built-in of the same name: list_skills() comes
# first and skill_prefix_for() returns the FIRST match, not the last.
assert rows["Shared Name"] == "## Skill: Shared Name\nUSER VERSION"
# A blank-instructions skill does not end the scan, so its later namesake
# supplies the text. A last-write-wins dict would answer '' here.
assert rows["Blank First"] == "## Skill: Blank First\nLATER, WITH CONTENT"
assert rows["Builtin Only"] == "## Skill: Builtin Only\nBUILTIN BODY"
def test_lookup_matches_skill_prefix_for_on_odd_names(monkeypatch):
"""Slug / case / padding / unknown / empty lookups must answer like ``skill_prefix_for``."""
# Imported inside the test on purpose: this symbol is what the fix
# introduces, and a module-level import would turn the pre-fix run into a
# collection error instead of a real assertion failure in the test above.
from cowork_local.presentation.co4e.co4e_sidebar import _skill_prefix_lookup
user = [
skills_mod.Skill(name="Shared Name", instructions="USER VERSION"),
skills_mod.Skill(name="Blank First", instructions=""),
skills_mod.Skill(name="Blank First", instructions="LATER, WITH CONTENT"),
skills_mod.Skill(name="Tiếng Việt / 日本語", instructions="UNICODE BODY"),
skills_mod.Skill(name="", instructions="NAMELESS BODY"),
]
builtin = [skills_mod.Skill(name="Shared Name", instructions="BUILTIN VERSION")]
_install_skills(monkeypatch, user, builtin)
lookup = _skill_prefix_lookup(skills_mod.list_skills() + skills_mod.builtin_skills())
probes = [
"Shared Name", "shared name", "SHARED NAME", " Shared Name ",
"shared-name", # by slug
"Blank First", "blank-first",
"Tiếng Việt / 日本語", "tiếng-việt-日本語",
"", " ", "no-such-skill-anywhere", "skill",
]
for probe in probes:
assert lookup(probe) == skills_mod.skill_prefix_for(probe), probe
+135
View File
@@ -0,0 +1,135 @@
"""Thư mục người dùng tự chọn ở tab Thư mục không được biến mất khi chuyển tab.
Hai lỗi riêng biệt cùng gây ra triệu chứng "chọn folder khác, sang tab khác rồi
quay lại thì về folder cũ":
1. ``WorkspaceFileTree.root_changed`` **không có ai lắng nghe**. Người dùng chọn
thư mục trong cây thì chỉ cái cây đổi gốc; ``_root``, khung xem và terminal ở
lại thư mục cũ. Tín hiệu chết.
2. ``WorkspaceTab._load_current`` gọi ``set_root(project.workspace_dir())`` vô
điều kiện, và ``_goto`` gọi ``workspace.refresh()`` mỗi lần vào lại màn
Workspace — nên mỗi cú chuyển tab kéo thư mục về workspace của project.
Đổi sang project KHÁC thì vẫn phải re-root: thư mục của màn này thuộc về project.
Chỉ có lần refresh trong cùng một project là không được đụng.
"""
from __future__ import annotations
import pytest
pytest.importorskip("PySide6", reason="cần PySide6 để dựng widget thật")
class _Cfg:
"""Đủ cho FolderTab, không hơn."""
def __init__(self, out_dir):
self._out = str(out_dir)
self.data = {}
def cowork_output_dir(self):
from pathlib import Path
return Path(self._out)
class _Ctx:
def __init__(self, out_dir):
self.config = _Cfg(out_dir)
@pytest.fixture
def folder_tab(qapp, tmp_path):
from cowork_local.presentation.folder.folder_tab import FolderTab
goc = tmp_path / "goc-ban-dau"
goc.mkdir()
tab = FolderTab(_Ctx(goc))
yield tab, tmp_path
tab.deleteLater()
# ---- lỗi 1: tín hiệu chết ------------------------------------------------
def test_nguoi_dung_chon_thu_muc_thi_khung_xem_va_terminal_theo_kip(folder_tab, tmp_path):
"""``root_changed`` phải được nối, không thì ba widget con lệch nhau."""
tab, base = folder_tab
moi = base / "nguoi-dung-chon"
moi.mkdir()
tab.tree.set_root(str(moi)) # phát root_changed như khi bấm chọn thư mục
assert tab._root == str(moi)
assert tab.preview._root == str(moi)
# ---- lỗi 2: refresh không được ghi đè lựa chọn tay ----------------------
def test_refresh_cung_project_khong_keo_ve_thu_muc_cu(folder_tab, tmp_path):
"""Đây chính là triệu chứng: chuyển tab rồi quay lại là mất lựa chọn."""
tab, base = folder_tab
ws = base / "workspace-cua-project"
ws.mkdir()
tab.set_project_root(str(ws)) # vào màn lần đầu
nguoi_dung_chon = base / "folder-khac"
nguoi_dung_chon.mkdir()
tab.tree.set_root(str(nguoi_dung_chon))
tab.set_project_root(str(ws)) # chuyển tab rồi quay lại -> refresh
assert tab._root == str(nguoi_dung_chon), "lựa chọn tay bị ghi đè"
def test_doi_sang_project_khac_thi_van_re_root(folder_tab, tmp_path):
"""Thư mục của màn này thuộc về project — đổi project là phải đổi theo."""
tab, base = folder_tab
ws_a = base / "ws-a"
ws_b = base / "ws-b"
ws_a.mkdir()
ws_b.mkdir()
tab.set_project_root(str(ws_a))
khac = base / "tu-chon"
khac.mkdir()
tab.tree.set_root(str(khac))
tab.set_project_root(str(ws_b)) # người dùng đổi project
assert tab._root == str(ws_b)
def test_quay_lai_project_cu_thi_ve_workspace_cua_no(folder_tab, tmp_path):
tab, base = folder_tab
ws_a = base / "ws-a"
ws_b = base / "ws-b"
ws_a.mkdir()
ws_b.mkdir()
tab.set_project_root(str(ws_a))
tab.set_project_root(str(ws_b))
tab.set_project_root(str(ws_a))
assert tab._root == str(ws_a)
def test_lan_dau_vao_man_van_ap_duoc_goc(folder_tab, tmp_path):
"""``_project_root`` khởi tạo rỗng nên lần gọi đầu không bị bỏ qua."""
tab, base = folder_tab
ws = base / "ws"
ws.mkdir()
tab.set_project_root(str(ws))
assert tab._root == str(ws)
# ---- chỗ gọi phải dùng hàm mới -------------------------------------------
def test_workspace_tab_goi_set_project_root_chu_khong_set_root():
from pathlib import Path
src = (Path(__file__).resolve().parents[2] / "ui" / "workspace_tab.py").read_text(encoding="utf-8")
assert "_folder.set_project_root(" in src
assert "_folder.set_root(" not in src, (
"gọi set_root() vô điều kiện là quay lại đúng lỗi cũ")
+261
View File
@@ -0,0 +1,261 @@
"""Vào màn GraphRAG phải có báo "đang tải".
``prewarm()`` chỉ chạy 3 giây sau khi cửa sổ hiện (``main_window.py``), nên bấm
GraphRAG trong 3 giây đầu sẽ gặp ``_ensure_web()`` dựng ``QWebEngineView`` ĐỒNG BỘ
trên GUI thread — đóng băng 1-2 giây mà trước đây không có gì báo.
Vì GUI thread bị chặn, panel phải được ``repaint()`` NGAY, không phải ``update()``:
``update()`` chỉ xếp hàng một lượt vẽ cho vòng lặp sự kiện, mà vòng lặp đó sắp bị
chặn — panel sẽ chỉ hiện ra sau khi hết đóng băng, đúng lúc không còn cần nữa.
"""
from __future__ import annotations
import pytest
pytest.importorskip("PySide6", reason="cần PySide6 để dựng widget thật")
class _Renderer:
"""Thay GraphRenderer. Ghi lại panel có đang hiện lúc nó bị gọi hay không."""
def __init__(self, qapp, web=None, path=""):
from PySide6.QtCore import QObject, Signal
class _Sig(QObject):
graph_rendered = Signal()
project_changed = Signal()
self._sig = _Sig()
self.graph_rendered = self._sig.graph_rendered
self.project_changed = self._sig.project_changed
self._hien = False
self.web = web
self.path_edit = type("E", (), {"text": lambda _s: path})()
self.busy_luc_duoc_goi = None
self._view = None
def isVisible(self): # noqa: N802 — khớp API của QWidget
return self._hien
def auto_scan_and_fit(self):
# Chốt điều quan trọng nhất: panel phải hiện TRƯỚC khi việc chặn bắt đầu.
self.busy_luc_duoc_goi = not self._view._busy.isHidden()
@pytest.fixture
def view(qapp):
from cowork_local.presentation.graph.structure_graph_view import StructureGraphView
return StructureGraphView
def _dung(qapp, ViewCls, web=None, path="C:/tmp"):
"""StructureGraphView với renderer bị thay — renderer thật kéo theo QtWebEngine."""
v = ViewCls.__new__(ViewCls)
from PySide6.QtWidgets import QWidget
QWidget.__init__(v)
v.renderer = _Renderer(qapp, web=web, path=path)
v.renderer._view = v
v._workspace_project = None
v.ctx = type("C", (), {"active_project_id": "default"})()
v.resize(800, 600)
v._build_busy_panel()
return v
# ---- panel bật trước khi việc chặn bắt đầu -------------------------------
def test_panel_hien_TRUOC_khi_dung_khung_xem(qapp, view):
v = _dung(qapp, view)
try:
v.auto_scan_and_fit()
assert v.renderer.busy_luc_duoc_goi is True, (
"panel bật sau khi GUI thread đã bị chặn thì người dùng không thấy gì")
finally:
v.deleteLater()
def test_chua_dung_khung_xem_thi_bao_dang_tai_khung(qapp, view):
"""Hai giai đoạn, hai thông báo khác nhau."""
from cowork_local.i18n import tr
v = _dung(qapp, view, web=None)
try:
v.auto_scan_and_fit()
assert v._busy_label.text() == tr("structure.loading_view")
finally:
v.deleteLater()
def test_da_co_khung_xem_thi_bao_dang_quet(qapp, view):
from cowork_local.i18n import tr
v = _dung(qapp, view, web=object())
try:
v.auto_scan_and_fit()
assert v._busy_label.text() == tr("structure.scanning")
finally:
v.deleteLater()
# ---- panel tắt đúng lúc --------------------------------------------------
def test_panel_tat_khi_do_thi_ve_xong(qapp, view):
"""Tắt theo ``graph_rendered``, không tắt ngay khi hàm trả về — lượt quét
còn chạy ở luồng nền phía sau."""
v = _dung(qapp, view)
try:
v.auto_scan_and_fit()
assert v._busy.isHidden() is False
v.renderer._sig.graph_rendered.emit()
assert v._busy.isHidden() is True
finally:
v.deleteLater()
def test_chua_chon_thu_muc_thi_panel_khong_treo_lai(qapp, view):
"""Không có gì để vẽ thì ``graph_rendered`` không phát — panel phải tự dọn."""
v = _dung(qapp, view, path="")
try:
v.auto_scan_and_fit()
assert v._busy.isHidden() is True, "panel treo lại mãi mãi"
finally:
v.deleteLater()
# ---- panel nằm giữa và theo kích thước ----------------------------------
def test_panel_nam_giua_khung(qapp, view):
v = _dung(qapp, view)
try:
v.auto_scan_and_fit()
g = v._busy.geometry()
assert abs(g.center().x() - v.rect().center().x()) <= 1
assert abs(g.center().y() - v.rect().center().y()) <= 1
finally:
v.deleteLater()
def test_doi_kich_thuoc_thi_panel_theo(qapp, view):
"""Gửi ``QResizeEvent`` thật thay vì chỉ gọi ``resize()``.
Widget chưa ``show()`` thì Qt hoãn việc phát ``resizeEvent`` — mà việc Qt
phát event là phần của Qt, không phải phần cần kiểm. Cái cần kiểm là hàm
xử lý event có căn lại panel hay không.
"""
from PySide6.QtCore import QSize
from PySide6.QtGui import QResizeEvent
v = _dung(qapp, view)
try:
v.auto_scan_and_fit()
cu = v.size()
v.resize(1200, 900)
v.resizeEvent(QResizeEvent(QSize(1200, 900), cu))
g = v._busy.geometry()
assert abs(g.center().x() - v.rect().center().x()) <= 1
assert abs(g.center().y() - v.rect().center().y()) <= 1
finally:
v.deleteLater()
# ---- màu lấy từ theme, không hardcode ------------------------------------
def test_panel_duoc_style_bang_token_trong_theme():
"""Guardrail G4: ngoài theme/ không file nào được đặt tên một màu."""
from pathlib import Path
from cowork_local.theme.qss import _TEMPLATE
assert "QWidget#graphBusy" in _TEMPLATE.template
src = (Path(__file__).resolve().parents[2]
/ "presentation" / "graph" / "structure_graph_view.py").read_text(encoding="utf-8")
assert "setStyleSheet" not in src
# ---- đổi project ngay trên màn này thì quét luôn, có panel ---------------
def test_doi_project_khi_man_dang_mo_thi_panel_bat(qapp, view):
"""Renderer quét ngay (không chờ lần ghé sau) nên panel phải bật theo."""
v = _dung(qapp, view)
try:
v.renderer._hien = True
v.renderer.project_changed.emit()
assert v._busy.isHidden() is False
finally:
v.deleteLater()
def test_doi_project_khi_man_dang_an_thi_khong_bat_panel(qapp, view):
"""Renderer hoãn lượt quét sang lần ghé sau — bật panel lúc này là treo lại."""
v = _dung(qapp, view)
try:
v.renderer._hien = False
v.renderer.project_changed.emit()
assert v._busy.isHidden() is True
finally:
v.deleteLater()
# ---- đường dẫn quét rỗng thì lấy từ project đang hoạt động ---------------
def test_o_duong_dan_rong_thi_dien_tu_project_dang_chon(qapp, view, monkeypatch, tmp_path):
"""GraphRenderer.auto_scan_and_fit thoát sớm khi ô đường dẫn rỗng, trong khi
nút Scan lại có đường lùi — vào màn không làm gì, bấm Scan thì chạy."""
import cowork_local.core.projects as projects
ws = tmp_path / "ws"
ws.mkdir()
class _P:
project_id = "p1"
def workspace_dir(self, base=None):
return ws
monkeypatch.setattr(projects, "load_project", lambda pid: _P() if pid == "p1" else None)
v = _dung(qapp, view, path="")
v.ctx = type("C", (), {"active_project_id": "p1"})()
v.renderer.path_edit = type("E", (), {
"_t": "",
"text": lambda s: s._t,
"setText": lambda s, t: setattr(s, "_t", t)})()
try:
v._ensure_scan_path()
assert v.renderer.path_edit.text() == str(ws)
finally:
v.deleteLater()
def test_da_co_duong_dan_thi_khong_ghi_de(qapp, view):
v = _dung(qapp, view, path="C:/da-chon")
v.ctx = type("C", (), {"active_project_id": "p1"})()
try:
v._ensure_scan_path()
assert v.renderer.path_edit.text() == "C:/da-chon"
finally:
v.deleteLater()
def test_chua_chon_project_thi_khong_bia_duong_dan(qapp, view):
"""Không lấy cwd() làm đường lùi: quét thư mục làm việc của tiến trình là
quét một cây không liên quan gì tới project."""
v = _dung(qapp, view, path="")
v.ctx = type("C", (), {"active_project_id": "default"})()
v.renderer.path_edit = type("E", (), {
"_t": "",
"text": lambda s: s._t,
"setText": lambda s, t: setattr(s, "_t", t)})()
try:
v._ensure_scan_path()
assert v.renderer.path_edit.text() == ""
finally:
v.deleteLater()
@@ -0,0 +1,87 @@
"""Project chọn ở tab GraphRAG không được biến mất khi chuyển tab.
Cùng khuôn với ``test_folder_root_persists.py``: ``WorkspaceTab._bind_project``
gọi ``self._structure.set_project(pid)`` vô điều kiện, và nó chạy lại mỗi lần
người dùng vào lại màn Workspace (``_goto`` → ``refresh`` → ``_load_current`` →
``_bind_project``). Nên bộ chọn project của chính màn GraphRAG bị kéo về giá trị
của Workspace mỗi cú chuyển tab.
"""
from __future__ import annotations
import pytest
pytest.importorskip("PySide6", reason="cần PySide6 để dựng widget thật")
class _Ghi:
"""Thay GraphRenderer — chỉ ghi lại nó bị áp project mấy lần, với giá trị gì."""
def __init__(self):
self.lan_ap = []
def set_project(self, project_id):
self.lan_ap.append(project_id)
@pytest.fixture
def view(qapp, monkeypatch):
"""StructureGraphView với renderer bị thay — dựng renderer thật kéo theo
QtWebEngine, quá đắt cho một khẳng định về luồng điều khiển."""
from cowork_local.presentation.graph.structure_graph_view import StructureGraphView
v = StructureGraphView.__new__(StructureGraphView)
v._workspace_project = None
v.renderer = _Ghi()
return v
def test_lan_dau_van_ap_du_project_la_rong(view):
"""``None`` khởi tạo để lần gọi đầu không bị bỏ qua khi pid là chuỗi rỗng."""
view.set_workspace_project("")
assert view.renderer.lan_ap == [""]
def test_refresh_cung_project_khong_ap_lai(view):
"""Đây chính là triệu chứng: chuyển tab rồi quay lại là mất lựa chọn."""
view.set_workspace_project("p-a")
view.set_workspace_project("p-a") # chuyển tab rồi quay lại -> refresh
view.set_workspace_project("p-a")
assert view.renderer.lan_ap == ["p-a"], "áp lại là kéo bộ chọn về, xoá lựa chọn tay"
def test_doi_project_o_workspace_thi_van_ap(view):
"""Đổi project thật thì GraphRAG phải theo."""
view.set_workspace_project("p-a")
view.set_workspace_project("p-b")
assert view.renderer.lan_ap == ["p-a", "p-b"]
def test_quay_lai_project_cu_thi_ap_lai(view):
view.set_workspace_project("p-a")
view.set_workspace_project("p-b")
view.set_workspace_project("p-a")
assert view.renderer.lan_ap == ["p-a", "p-b", "p-a"]
def test_bo_khoa_van_di_qua_duoc(view):
"""Bỏ chọn project ở Workspace là một thay đổi thật, không phải refresh."""
view.set_workspace_project("p-a")
view.set_workspace_project("")
assert view.renderer.lan_ap == ["p-a", ""]
# ---- chỗ gọi phải dùng hàm mới -------------------------------------------
def test_workspace_tab_goi_set_workspace_project():
from pathlib import Path
src = (Path(__file__).resolve().parents[2] / "ui" / "workspace_tab.py").read_text(encoding="utf-8")
assert "_structure.set_workspace_project(pid)" in src
assert "_structure.set_project(pid)" not in src, (
"gọi set_project() vô điều kiện là quay lại đúng lỗi cũ")
+186
View File
@@ -0,0 +1,186 @@
"""Trợ lý Hỗ trợ phải trả lời từ sổ tay, không phải từ trí nhớ về phần mềm khác.
Người dùng thật đã được trợ lý hướng dẫn vào "Dashboard → Add Project" và
"Settings → Project Settings → New Project". Cả hai đều không tồn tại. Prompt cũ
chỉ liệt kê TÊN các màn hình, nên model không có cách nào biết trên mỗi màn có
gì và nó lấp khoảng trống bằng thứ nghe hợp lý.
Nhóm bài quan trọng nhất ở đây là nhóm CHỐNG TRÔI: sổ tay viết tay sẽ lỗi thời
khi ai đó đổi UI, và một sổ tay sai còn tệ hơn không có sổ tay.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
import pytest
from cowork_local.core import help_knowledge
REPO = Path(__file__).resolve().parents[2]
@pytest.fixture(scope="module")
def guide() -> str:
return help_knowledge.app_guide()
# ---- sổ tay có thật và được nạp ------------------------------------------
def test_so_tay_ton_tai_va_doc_duoc(guide):
assert guide, "docs/help/app_guide.md rỗng hoặc không đọc được"
assert len(guide) > 1500, "sổ tay quá ngắn để trả lời được câu hỏi thật"
def test_thieu_so_tay_thi_khong_lam_hong_tro_ly(monkeypatch):
"""Trợ lý thiếu sổ tay chỉ kém hữu ích đi, không được ném lỗi làm hỏng khung chat."""
monkeypatch.setattr(help_knowledge, "_GUIDE", Path("khong-ton-tai.md"))
help_knowledge.app_guide.cache_clear()
try:
assert help_knowledge.app_guide() == ""
assert help_knowledge.build_prompt("vai tro").strip() == "vai tro"
finally:
help_knowledge.app_guide.cache_clear()
# ---- CHỐNG TRÔI: sổ tay phải khớp màn hình thật ---------------------------
def _nav_screens() -> set[str]:
"""Tên các màn hình thật, trích tự động từ docs/screens/manifest.json."""
data = json.loads((REPO / "docs" / "screens" / "manifest.json").read_text(encoding="utf-8"))
return {e["nav"].strip() for e in data if e.get("nav", "").strip()}
def test_so_tay_nhac_du_moi_man_hinh_that(guide):
"""Thiếu một màn là trợ lý sẽ không biết gì về màn đó."""
thieu = [s for s in _nav_screens() if s.lower() not in guide.lower()]
assert not thieu, f"sổ tay chưa nhắc tới: {sorted(thieu)}"
@pytest.mark.parametrize("bia", [
"Add Project", # trợ lý từng bịa ra nút này ở Dashboard
"Project Settings", # ...và mục này trong Cài đặt
"New Project", # tên tiếng Anh của nút không tồn tại
])
def test_so_tay_khong_gioi_thieu_thu_khong_co(guide, bia):
"""Sổ tay chỉ được PHỦ ĐỊNH mấy tên này, không được dùng như hướng dẫn.
Cho phép chúng xuất hiện trong mục "những chỗ hay hỏi" kèm chữ 'không có',
vì nói thẳng "app không có nút đó" là đúng việc cần làm.
"""
for dong in guide.splitlines():
if bia.lower() in dong.lower():
assert "không có" in dong.lower() or "⚠️" in dong, (
f"dòng nhắc {bia!r} mà không phủ định: {dong.strip()!r}")
def test_so_tay_chi_dung_cho_tao_project(guide):
"""Câu hỏi hay nhất — và là câu trợ lý từng trả lời sai."""
assert "Workspace ▸ Project" in guide
assert "Project mới" in guide
# ---- ghép prompt ---------------------------------------------------------
def test_prompt_co_du_ba_phan(guide):
prompt = help_knowledge.build_prompt(
"VAI TRO", help_knowledge.screen_context("Workspace ▸ Project"))
assert "VAI TRO" in prompt
assert "SỔ TAY ỨNG DỤNG" in prompt
assert "Workspace ▸ Project" in prompt
def test_luat_chong_bia_dung_SAU_so_tay(guide):
"""Luật phải là thứ cuối cùng model đọc về cách dùng sổ tay."""
prompt = help_knowledge.build_prompt("VAI TRO")
assert prompt.index("SỔ TAY ỨNG DỤNG") < prompt.index("LUẬT TRẢ LỜI")
def test_man_hinh_dang_mo_dung_cuoi_cung():
"""Nó đổi theo từng lượt hỏi nên đứng cuối, sát câu hỏi nhất."""
prompt = help_knowledge.build_prompt(
"VAI TRO", help_knowledge.screen_context("Monitoring"))
assert prompt.index("LUẬT TRẢ LỜI") < prompt.index("MÀN HÌNH NGƯỜI DÙNG ĐANG MỞ")
def test_khong_biet_man_hinh_thi_khong_bia_ra_mot_cai():
assert help_knowledge.screen_context("") == ""
assert help_knowledge.screen_context("", []) == ""
assert "MÀN HÌNH NGƯỜI DÙNG ĐANG MỞ" not in help_knowledge.build_prompt("VAI TRO")
# ---- danh sách nút đang hiện, đọc từ giao diện đang chạy -----------------
def test_liet_ke_dung_cac_nut_dang_hien():
"""Không có phần này thì "ở đây làm được gì" là câu model buộc phải đoán."""
ctx = help_knowledge.screen_context("Workspace ▸ Project",
["Project mới", "Sửa project", "Lưu project"])
assert "Project mới" in ctx
assert "Sửa project" in ctx
assert "CHÍNH XÁC" in ctx, "phải nói rõ đây là danh sách đầy đủ, không phải gợi ý"
def test_nhan_rong_va_trung_khong_lam_ban_prompt():
ctx = help_knowledge.screen_context("X", [" ", "", "Lưu", None])
assert ctx.count("- ") == 1
def test_cat_bot_khi_qua_nhieu_nut():
"""Màn đông như Co4E có hàng chục nút; dồn hết vào chỉ làm loãng prompt."""
ctx = help_knowledge.screen_context("Co4E", [f"nut-{i}" for i in range(200)])
assert ctx.count("- ") == help_knowledge._MAX_ACTIONS
def test_chi_co_nut_ma_khong_biet_man_hinh_thi_van_dung_duoc():
ctx = help_knowledge.screen_context("", ["Lưu"])
assert "Lưu" in ctx
# ---- ví dụ few-shot chốt đúng lỗi đã xảy ra ------------------------------
def test_luat_co_vi_du_cau_tra_loi_sai_that(guide):
"""Nêu tên đúng thứ nó từng bịa thì hiệu quả hơn một luật chung chung."""
prompt = help_knowledge.build_prompt("VAI TRO")
assert "Add Project" in prompt
assert "SAI:" in prompt and "ĐÚNG:" in prompt
def test_luat_day_cach_noi_khong_biet(guide):
"""Nói không biết là câu trả lời đúng, và phải có ví dụ cho nó."""
prompt = help_knowledge.build_prompt("VAI TRO")
assert "không chắc" in prompt
# ---- màn hình đang mở, lấy từ cửa sổ thật --------------------------------
def test_cua_so_bao_dung_ten_man_hinh_dang_mo(qapp, tmp_path):
from cowork_local.presentation.shell.bootstrap import build_config, build_context
from cowork_local.presentation.shell.main_window import MainWindow
config_path = tmp_path / "config.json"
build_config(config_path)
win = MainWindow(build_context(config_path))
try:
nhan = win.current_screen_label()
assert nhan, "cửa sổ không nói được nó đang mở màn nào"
assert "▸" in nhan, f"màn Workspace phải kèm sub-tab, nhận được {nhan!r}"
# Tên trong nhãn phải là tên THẬT, để trợ lý nhắc lại được cho người dùng
assert nhan.split("▸")[-1].strip() in help_knowledge.app_guide()
# Ngữ cảnh sống phải đi qua được toàn bộ đường: widget -> prompt
ctx = win.help_context()
assert nhan in ctx
assert "VAI TRO" in help_knowledge.build_prompt("VAI TRO", ctx)
finally:
win.close()
+329
View File
@@ -0,0 +1,329 @@
"""Đổi ngôn ngữ thì MỌI chữ trên màn hình phải đổi theo.
Lỗi mà bộ test này khoá lại: `w.setToolTip(tr("k"))` chỉ đúng ở đúng thời điểm
chạy dòng đó. Không có gì áp lại, nên sau khi người dùng đổi ngôn ngữ thì chữ
đứng nguyên ở ngôn ngữ cũ. Vì `DEFAULT_LANGUAGE = "vi"`, triệu chứng người dùng
báo là "chọn English mà nhiều chỗ vẫn tiếng Việt".
Cách kiểm: KHÔNG so chữ với bảng dịch — làm thế thì một nhãn tiếng Việt trùng
chữ với nhãn khác sẽ báo oan (và ngược lại, "OneDrive" giống nhau ở cả ba ngôn
ngữ sẽ lọt). Thay vào đó thay `tr()` bằng hàm trả về một chuỗi MỐC, rồi gọi
`set_language()`. Chỗ nào được áp lại sẽ mang mốc; chỗ nào không mang mốc là chỗ
không có ai áp lại — đó chính là lỗi, và biết chắc chứ không phải suy đoán.
Hai điểm mà một bản kiểm ngây thơ sẽ sai:
* `from ...i18n import tr` COPY tham chiếu vào namespace của từng module, nên
sửa `i18n.tr` một mình là không đủ — phải thay ở mọi module đã import nó.
* Lưới vẽ lại bằng `deleteLater()` để lại widget cũ còn sống tới khi vòng lặp sự
kiện tiêu hoá xong. Không `sendPostedEvents(DeferredDelete)` thì hàng chục
widget bóng ma mang chữ cũ sẽ bị đếm là lỗi trong khi người dùng không hề thấy.
"""
from __future__ import annotations
import sys
import pytest
pytest.importorskip("PySide6", reason="cần PySide6 để dựng cửa sổ thật")
MOC = "\u2063" # invisible separator: không chuỗi hiển thị thật nào chứa nó
#: Khoá cố ý giống nhau ở cả ba ngôn ngữ: tên thương hiệu, tên sản phẩm, ký hiệu,
#: placeholder thuần định dạng, và mã mức độ hiển thị nguyên dạng. Thêm khoá mới
#: vào đây phải kèm lý do — đây là chỗ dễ dùng để lặng lẽ bỏ qua việc dịch.
KHOA_KHONG_CAN_DICH = {
# thương hiệu / tên sản phẩm
"app.logo", "app.version", "login.header",
"app.tab.code", "app.tab.cowork", "app.tab.structure",
"code.title", "code.onedrive_badge", "code.onedrive_btn",
"cowork.title", "sidebar.filter.code", "sidebar.filter.cowork",
"workspace.tab_co4e", "workspace.tab_co4e_tooltip", "workspace.tab_cowork",
"workspace.tab_graphrag", "structure.cmem_ui_open",
"settings.group.anthropic", "settings.group.cowork", "settings.group.structure",
"settings.group.teams", "settings.history_onedrive",
"settings.ms365_connector.onedrive", "settings.ms365_connector.outlook",
"settings.ms365_connector.sharepoint", "settings.ms365_connector.teams",
# tên nhóm catalog = danh sách tên sản phẩm, giống hệt ở cả ba ngôn ngữ
"connectors.cat_cad", "connectors.cat_cae", "connectors.cat_ms365",
"schedtask.stepexec.co4e", "schedtask.stepexec.cowork",
"schedtask.type.co4e_code", "schedtask.type.cowork",
"monitoring.tab_mcp", "ext.mode_rest", "schedtask.f_cron",
"help_agent.badge", "help_agent.title", # dùng nguyên dạng trong cả câu tiếng Nhật
"help_agent.default_user", # đứng thay cho TÊN người dùng
# "OK" giữ nguyên dạng ở cả ba ngôn ngữ — bản tiếng Nhật của chính Qt cũng dùng "OK"
"dialog.ok",
# viết tắt / ký hiệu / thuần định dạng
"accounts.ai_search_btn", "monitoring.ai_filter_btn", "monitoring.overview_res_cpu",
"monitoring.na", "schedtask.add_link_label", "settings.teams_webhook",
"cowork.project_label", "connectors.jira_fail", "tools_admin.jira_fail",
# mã mức độ: giữ nguyên dạng ở CẢ BA ngôn ngữ, có chủ ý
"monitoring.severity_critical", "monitoring.severity_info", "monitoring.severity_medium",
}
# ---- dữ liệu: bảng dịch không được thiếu tiếng Nhật ----------------------
def test_moi_khoa_co_du_ba_ngon_ngu():
from cowork_local.i18n import STRINGS
thieu = {k: sorted({"en", "ja", "vi"} - {x for x in v if v.get(x)})
for k, v in STRINGS.items()
if not all(v.get(x) for x in ("en", "ja", "vi"))}
assert thieu == {}, f"khoá thiếu bản dịch: {thieu}"
def test_ban_dich_nhat_khong_phai_chuoi_tieng_anh():
"""`ja` bằng đúng `en` nghĩa là khoá đó chưa được dịch — trừ danh sách miễn.
Đây là nửa còn lại của lỗi người dùng báo: "chọn tiếng Nhật thì vài chỗ hiển
thị tiếng Anh". Nó KHÔNG phải lỗi dây nối (widget vẫn áp lại `tr()` đúng),
mà là lỗ trong dữ liệu dịch — nên phải có cổng riêng canh.
"""
from cowork_local.i18n import STRINGS
chua_dich = sorted(k for k, v in STRINGS.items()
if v.get("en") == v.get("ja") and k not in KHOA_KHONG_CAN_DICH)
assert chua_dich == [], (
"khoá chưa có bản dịch tiếng Nhật (thêm bản dịch, hoặc khai vào "
f"KHOA_KHONG_CAN_DICH kèm lý do): {chua_dich}")
def test_danh_sach_mien_khong_phinh_len_am_tham():
"""Danh sách miễn chỉ được chứa khoá THẬT SỰ giống nhau ở en/ja.
Không có test này thì cách dễ nhất để làm cổng trên xanh lại là nhét khoá
vào danh sách miễn rồi để nguyên đó sau khi đã dịch.
"""
from cowork_local.i18n import STRINGS
thua = sorted(k for k in KHOA_KHONG_CAN_DICH
if k in STRINGS and STRINGS[k].get("en") != STRINGS[k].get("ja"))
assert thua == [], f"khoá đã có bản dịch nhưng vẫn nằm trong danh sách miễn: {thua}"
# ---- lúc chạy: đổi ngôn ngữ thì chữ trên màn hình phải đổi --------------
def _chu_cua(w):
"""Mọi chỗ chữ hiện ra từ một widget, kèm tên thuộc tính."""
from PySide6.QtWidgets import (
QComboBox, QLineEdit, QListWidget, QPlainTextEdit, QTabWidget,
QTableWidget, QTextEdit, QTreeWidget,
)
ra = []
def them(ten, s):
if isinstance(s, str) and s.strip():
ra.append((ten, s))
# QLineEdit.text() là NỘI DUNG người dùng gõ, không phải nhãn giao diện.
if hasattr(w, "text") and not isinstance(w, (QLineEdit, QTextEdit, QPlainTextEdit)):
try:
them("text", w.text())
except RuntimeError:
return ra
for ten in ("windowTitle", "placeholderText", "toolTip", "title", "accessibleName"):
f = getattr(w, ten, None)
if callable(f):
try:
them(ten, f())
except (RuntimeError, TypeError):
pass
if isinstance(w, QTabWidget):
for i in range(w.count()):
them(f"tabText[{i}]", w.tabText(i))
them(f"tabToolTip[{i}]", w.tabToolTip(i))
if isinstance(w, QComboBox):
for i in range(w.count()):
them(f"itemText[{i}]", w.itemText(i))
if isinstance(w, QListWidget):
for i in range(w.count()):
it = w.item(i)
if it is not None:
them(f"item[{i}]", it.text())
them(f"itemTip[{i}]", it.toolTip())
if isinstance(w, QTableWidget):
for c in range(w.columnCount()):
h = w.horizontalHeaderItem(c)
if h is not None:
them(f"hheader[{c}]", h.text())
if isinstance(w, QTreeWidget):
hi = w.headerItem()
if hi is not None:
for c in range(w.columnCount()):
them(f"hheader[{c}]", hi.text(c))
return ra
def _chu_so_huu(w):
"""Lớp widget của chính dự án gần nhất trên đường đi lên.
Một `QPushButton` trần không cho biết nó thuộc màn nào; tổ tiên gần nhất do
dự án định nghĩa thì cho biết, và đó là file cần sửa.
"""
cur = w
while cur is not None:
mod = type(cur).__module__ or ""
if mod.startswith("cowork_local"):
return f"{mod}.{type(cur).__name__}"
cur = cur.parent()
return "?"
def _chup(root, giu):
"""Ảnh chụp mọi chỗ chữ trong cây widget.
``giu`` nhận thêm tham chiếu tới từng widget đã đi qua: wrapper PySide6 bị
thu hồi thì `id()` được cấp lại cho wrapper sau, và vòng quét sẽ tưởng đã
thăm rồi mà dừng sớm — mất phần lớn cây.
"""
from PySide6.QtWidgets import QMenu, QWidget
anh, ngan, da_tham = {}, [root], set()
while ngan:
w = ngan.pop()
if id(w) in da_tham:
continue
da_tham.add(id(w))
giu.append(w)
so_huu = _chu_so_huu(w)
for ten, s in _chu_cua(w):
anh[(id(w), type(w).__name__, so_huu, ten)] = s
try:
ngan.extend([c for c in w.children() if isinstance(c, (QWidget, QMenu))])
except RuntimeError:
pass
return anh
def _thay_tr(moi, cu):
"""Thay `tr` ở MỌI module của dự án đang trỏ tới ``cu``. Trả về số module."""
from cowork_local import i18n as i18n_mod
n = 0
for ten, m in list(sys.modules.items()):
if ten.startswith("cowork_local") and getattr(m, "tr", None) is cu:
setattr(m, "tr", moi)
n += 1
i18n_mod.tr = moi
return n
def _tieu_hoa(qapp):
"""Cho Qt xử lý hết `deleteLater()` để không đếm widget bóng ma."""
from PySide6.QtCore import QEvent
for _ in range(3):
qapp.processEvents()
qapp.sendPostedEvents(None, QEvent.DeferredDelete)
@pytest.fixture
def cua_so(qapp, tmp_path):
"""`MainWindow` thật, đã dựng cả bốn trang dựng-lười.
Trang chưa dựng thì không có chữ nào để kiểm, mà đó lại đúng là nơi lỗi hay
nằm — nên phải gọi `_ensure_page` cho hết.
"""
from cowork_local.presentation.shell.bootstrap import build_config, build_context
from cowork_local.presentation.shell.main_window import MainWindow
duong_dan = tmp_path / "config.json"
build_config(duong_dan)
win = MainWindow(build_context(duong_dan))
win.resize(1500, 950)
for row in range(len(win._page_widgets)):
win._ensure_page(row)
yield win
win.close()
def test_doi_ngon_ngu_thi_khong_con_chu_ngon_ngu_cu(qapp, cua_so):
"""Không chỗ nào giữ lại chữ của ngôn ngữ cũ sau khi đổi ngôn ngữ."""
from cowork_local import i18n as i18n_mod
giu = [] # chống cấp lại id() cho wrapper mới
goc = i18n_mod.tr
ngon_ngu_cu = i18n_mod.get_language()
i18n_mod.set_language("vi")
_tieu_hoa(qapp)
truoc = _chup(cua_so, giu)
def moc(key, **kw):
return MOC + key
try:
assert _thay_tr(moc, goc) > 0, "không thay được tr() ở module nào"
i18n_mod.set_language("en")
_tieu_hoa(qapp)
sau = _chup(cua_so, giu)
finally:
_thay_tr(goc, moc)
i18n_mod.set_language(ngon_ngu_cu)
# Chỉ tính chỗ mà chữ CÓ phụ thuộc ngôn ngữ: "OneDrive" giống nhau ở cả ba
# ngôn ngữ nên không áp lại cũng không ai thấy khác.
phu_thuoc_ngon_ngu = set()
for v in i18n_mod.STRINGS.values():
gia_tri = {v.get("en"), v.get("ja"), v.get("vi")}
if len(gia_tri) > 1:
phu_thuoc_ngon_ngu.update(x for x in gia_tri if x)
# Nhãn nhà cung cấp là DANH TÍNH lấy từ ``config.PROVIDER_LABELS``, không đi
# qua ``tr()`` ở bất kỳ đâu (Agents Admin, model resolver, preview_ai đều
# hiện nguyên dạng như nhau). Một trong số chúng tình cờ trùng chữ với bản
# tiếng Anh của ``settings.group.openai`` — là tiêu đề mục trong Cài đặt,
# một khoá khác hẳn. Không trừ ra thì phép đo báo oan chỗ này mãi mãi.
# Muốn dịch tên nhà cung cấp thì phải sửa ``config.py`` và sửa đồng loạt cả
# năm nơi đang đọc nó — đó là một quyết định sản phẩm, không phải lỗi dây nối.
from cowork_local.config import PROVIDER_LABELS
phu_thuoc_ngon_ngu -= set(PROVIDER_LABELS.values())
con_chu_cu = sorted(
f"{cho[2]} :: {cho[1]}.{cho[3]} = {chu!r}"
for cho, chu in sau.items()
if cho in truoc and not chu.startswith(MOC) and chu in phu_thuoc_ngon_ngu
)
assert con_chu_cu == [], (
"những chỗ này không được áp lại khi đổi ngôn ngữ — dùng bind_text / "
"bind_tip / bind_placeholder / bind_items / bind_dynamic của i18n thay "
"cho setText(tr(...)) một lần:\n " + "\n ".join(con_chu_cu))
def test_phep_do_thuc_su_quet_duoc_man_hinh(qapp, cua_so):
"""Chốt độ phủ của chính phép đo trên.
Một hôm nào đó `_chup` đi sai (đúng lỗi `id()` bị cấp lại đã gặp) thì nó vẫn
trả về một dict rỗng và test trên vẫn xanh — xanh vì không kiểm gì cả.
"""
giu = []
anh = _chup(cua_so, giu)
assert len(anh) > 500, f"chỉ quét được {len(anh)} chỗ chữ — phép đo đang bị cắt"
def test_binding_tu_don_khi_widget_bi_xoa(qapp):
"""Ràng buộc không được giữ widget sống, và không được ném khi widget chết.
Đây là điều kiện để `bind_*` dùng được cho widget dựng lại liên tục (hàng
Kanban, ô lịch) mà không tích thành rác.
"""
from PySide6.QtWidgets import QLabel
from cowork_local import i18n as i18n_mod
lbl = QLabel()
i18n_mod.bind_text(lbl, "app.settings")
truoc = len(i18n_mod._bindings)
assert truoc > 0
lbl.deleteLater()
del lbl
_tieu_hoa(qapp)
i18n_mod._apply_bindings() # không được ném
assert len(i18n_mod._bindings) < truoc, "ràng buộc của widget đã xoá vẫn còn"
+354
View File
@@ -0,0 +1,354 @@
"""Chữ trên giao diện phải đi qua ``tr()`` — kể cả chữ do CHÍNH Qt vẽ.
Bộ ``test_i18n_khong_con_chu_cu.py`` bắt lỗi "có dịch nhưng không ai áp lại".
Nó không bắt được lỗi người dùng báo tháng 9/2026: "đang tiếng Nhật mà nút Save,
Cancel và phần mật khẩu sandbox vẫn tiếng Anh". Lý do là hai lỗ khác hẳn:
* **Chuỗi hardcode.** ``QPushButton("Unlock")`` không hỏi ``tr()`` bao giờ, nên
phép đo "đổi ngôn ngữ rồi tìm chỗ không mang mốc" thấy nó đứng yên ở cả hai
lần chụp và coi là bình thường.
* **Nhãn nút của chính Qt.** ``QDialogButtonBox(Save | Cancel)`` và
``QMessageBox.question`` lấy chữ từ bảng dịch của Qt. Ứng dụng không cài
``QTranslator`` nào nên bảng đó luôn rơi về tiếng Anh — không dòng mã nào của
dự án sai, mà màn hình vẫn sai.
Ba cổng dưới đây canh cả hai lỗ, và canh ở mức CẤU TRÚC (không ai được dựng lại
kiểu cũ) chứ không chỉ sửa xong từng chỗ.
"""
from __future__ import annotations
import ast
import pathlib
import pytest
REPO = pathlib.Path(__file__).resolve().parents[2]
#: Chỉ hai tầng này vẽ giao diện.
THU_MUC_UI = ("ui", "presentation")
#: Nơi DUY NHẤT được phép chạm vào nút chuẩn của Qt — chính chỗ gán nhãn lại.
NOI_BOC_QT = "ui/dialog_buttons.py"
def _file_ui():
"""Mọi file .py thuộc tầng giao diện, kèm cây AST đã phân tích."""
for thu_muc in THU_MUC_UI:
for path in sorted((REPO / thu_muc).rglob("*.py")):
rel = path.relative_to(REPO).as_posix()
yield rel, ast.parse(path.read_text(encoding="utf-8"))
def _ten_ham(func) -> str:
if isinstance(func, ast.Name):
return func.id
if isinstance(func, ast.Attribute):
return func.attr
return ""
# ---- cấu trúc: không ai được dùng lại nhãn mặc định của Qt ----------------
def test_khong_dung_nut_chuan_qdialogbuttonbox_truc_tiep():
"""``QDialogButtonBox(Save | Cancel)`` phải đi qua ``dialog_buttons()``.
Dựng thẳng thì Qt tự vẽ chữ, và chữ đó là tiếng Anh ở cả ba ngôn ngữ.
"""
vi_pham = []
for rel, tree in _file_ui():
if rel == NOI_BOC_QT:
continue
for node in ast.walk(tree):
if (isinstance(node, ast.Call) and _ten_ham(node.func) == "QDialogButtonBox"
and node.args):
# Không tham số = hộp rỗng rồi tự addButton(tr(...)) — vẫn đúng.
vi_pham.append(f"{rel}:{node.lineno}")
assert vi_pham == [], (
"dựng QDialogButtonBox với nút chuẩn thì nhãn do Qt vẽ và luôn là tiếng "
"Anh — dùng ui.dialog_buttons.dialog_buttons() thay vào:\n "
+ "\n ".join(vi_pham))
def test_khong_goi_qinputdialog_tinh_truc_tiep():
"""Hộp nhập liệu phải đi qua ``ask_text`` / ``ask_multiline`` / ``ask_item``.
``QInputDialog.getText`` và họ hàng tự dựng hộp thoại bên trong nên không ai
chạm được vào nút của nó: "Cancel" đứng nguyên tiếng Anh ở cả ja lẫn vi.
"""
vi_pham = []
for rel, tree in _file_ui():
if rel == NOI_BOC_QT:
continue
for node in ast.walk(tree):
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
and isinstance(node.func.value, ast.Name)
and node.func.value.id == "QInputDialog"
and node.func.attr.startswith("get")):
vi_pham.append(f"{rel}:{node.lineno} QInputDialog.{node.func.attr}")
assert vi_pham == [], (
"hàm tĩnh QInputDialog hiện nút Cancel tiếng Anh ở mọi ngôn ngữ — dùng "
"ask_text / ask_multiline / ask_item của ui.dialog_buttons:\n "
+ "\n ".join(vi_pham))
def test_khong_goi_qmessagebox_question_truc_tiep():
"""Hộp hỏi Có/Không phải đi qua ``confirm()``.
``QMessageBox.question`` hiện "&Yes" / "&No" tiếng Anh ở mọi ngôn ngữ.
"""
vi_pham = []
for rel, tree in _file_ui():
if rel == NOI_BOC_QT:
continue
for node in ast.walk(tree):
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
and node.func.attr == "question"
and isinstance(node.func.value, ast.Name)
and node.func.value.id == "QMessageBox"):
vi_pham.append(f"{rel}:{node.lineno}")
assert vi_pham == [], (
"QMessageBox.question hiện nút Yes/No tiếng Anh ở mọi ngôn ngữ — dùng "
"ui.dialog_buttons.confirm() thay vào:\n " + "\n ".join(vi_pham))
# ---- dữ liệu: không chuỗi hiển thị nào được viết thẳng vào widget ----------
#: Đối số thứ N của các hàm dựng widget này là NHÃN hiển thị.
CTOR_NHAN = {
"QLabel": 0, "QPushButton": 0, "QGroupBox": 0, "QCheckBox": 0,
"QRadioButton": 0, "QToolButton": 0, "QAction": 0, "QMenu": 0,
"QListWidgetItem": 0, "QTreeWidgetItem": 0, "QTableWidgetItem": 0,
"IconLabel": 1, "ToggleSwitch": 0,
}
#: Đối số thứ N của các setter này là chuỗi hiển thị.
SETTER_NHAN = {
"setText": 0, "setToolTip": 0, "setPlaceholderText": 0, "setWindowTitle": 0,
"setTitle": 0, "setStatusTip": 0, "setWhatsThis": 0, "setLabelText": 0,
"setInformativeText": 0, "setAccessibleName": 0, "addItem": 0,
"insertItem": 1, "setItemText": 1, "addAction": 0, "addTab": 1,
"insertTab": 2, "setTabText": 1, "setTabToolTip": 1, "setHeaderLabel": 0,
}
#: Mọi đối số chuỗi đều là nhãn.
SETTER_NHAN_TAT_CA = {
"setHorizontalHeaderLabels", "setHeaderLabels", "setVerticalHeaderLabels",
}
#: ``QMessageBox.<loai>(parent, title, body)`` — đối số 1 và 2 là chữ hiển thị.
MSGBOX_LOAI = {"information", "warning", "critical", "about"}
#: Chuỗi cố ý viết thẳng, kèm lý do. Thêm mục ở đây là một quyết định, không
#: phải cách làm cổng xanh lại — chữ người dùng ĐỌC ĐƯỢC thì phải dịch.
CHO_PHEP_HARDCODE = {
# ký hiệu đơn vị và tiền tố phiên bản: giống nhau ở cả ba ngôn ngữ
("presentation/monitoring/tabs/overview_tab.py", "/s"),
("presentation/settings/about_widget.py", " v"),
("presentation/settings/routing_settings_widget.py", " s"),
("presentation/settings/routing_settings_widget.py", " h"),
("presentation/shell/main_window.py", " v"),
("ui/task_editor_dialog.py", " s"),
}
def _chuoi(node) -> str | None:
"""Chữ của một literal chuỗi. Bỏ qua f-string đã có ``tr()`` bên trong."""
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return node.value
if isinstance(node, ast.JoinedStr):
if any(isinstance(n, ast.Call) and _ten_ham(n.func) == "tr"
for n in ast.walk(node)):
return None
phan = [v.value for v in node.values
if isinstance(v, ast.Constant) and isinstance(v.value, str)]
return "".join(phan) if phan else None
return None
def _la_chu_nguoi_doc(s: str) -> bool:
"""Chuỗi có phải chữ người dùng ĐỌC, chứ không phải mã/ký hiệu/định dạng.
Mốc "ít nhất ba chữ cái" loại được đơn vị (``/s``, `` h``) và tiền tố phiên
bản (`` v``) mà vẫn giữ lại mọi nhãn thật — nhãn ngắn nhất trên màn này là
"Unlock".
"""
import re
if len(s.strip()) < 2 or re.fullmatch(r"[\W\d_]+", s):
return False
if re.fullmatch(r"[a-z][a-zA-Z0-9]*|[a-z0-9_.]+", s):
return False # objectName / khoá dạng định danh
if s.startswith(("http://", "https://", "#", "<")):
return False
return len(re.findall(r"[A-Za-zÀ-ỹ぀-ヿ一-鿿]", s)) >= 3
def test_khong_con_chuoi_hien_thi_viet_thang_vao_widget():
"""Không nhãn/tooltip/placeholder nào được viết thẳng bằng một ngôn ngữ.
Đây là nửa còn lại của lỗi tháng 9/2026: nhóm mật khẩu Sandbox Security
dựng bằng ``QPushButton("Unlock")`` và ``IconLabel(..., "Locked ...")``, nên
đổi sang tiếng Nhật thì cả cụm vẫn nguyên tiếng Anh.
"""
vi_pham = []
for rel, tree in _file_ui():
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
fn, args = _ten_ham(node.func), node.args
def bao(i: int) -> None:
if i >= len(args):
return
s = _chuoi(args[i])
if s and _la_chu_nguoi_doc(s) and (rel, s) not in CHO_PHEP_HARDCODE:
vi_pham.append(f"{rel}:{args[i].lineno} {fn}({s!r})")
if fn in CTOR_NHAN:
bao(CTOR_NHAN[fn])
if fn in SETTER_NHAN:
bao(SETTER_NHAN[fn])
if fn in SETTER_NHAN_TAT_CA:
for i in range(len(args)):
bao(i)
if (fn in MSGBOX_LOAI and isinstance(node.func, ast.Attribute)
and isinstance(node.func.value, ast.Name)
and node.func.value.id == "QMessageBox"):
bao(1)
bao(2)
assert vi_pham == [], (
"chữ hiển thị viết thẳng, không đi qua tr() nên không đổi theo ngôn "
"ngữ:\n " + "\n ".join(sorted(vi_pham)))
def test_moi_tool_co_mo_ta_hien_thi_rieng():
"""Mỗi tool trong ``TOOL_SPECS`` phải có khoá mô tả để hiển thị.
Thẻ tool ở Giám sát ▸ Công cụ từng hiện thẳng ``spec.description`` — chuỗi
gửi cho MÔ HÌNH trong schema function-calling, nên luôn tiếng Anh. Thiếu
khoá thì ``tr()`` trả về chính khoá, và người dùng đọc được
``tools_admin.desc.<tên>`` ngay trên thẻ.
"""
from cowork_local.core.tools import TOOL_SPECS
from cowork_local.i18n import STRINGS
thieu = [s.name for s in TOOL_SPECS
if f"tools_admin.desc.{s.name}" not in STRINGS]
assert thieu == [], (
"tool chưa có khoá mô tả hiển thị (thêm tools_admin.desc.<tên> vào "
f"i18n/libreoffice_view.py): {thieu}")
def test_add_section_nhan_khoa_chu_khong_nhan_chuoi_da_dich():
"""``_add_section`` tự dựng nhãn mục, nên phải nhận KHOÁ.
Truyền ``tr(...)`` vào đây hỏng ÂM THẦM: khoá thiếu thì ``tr()`` trả về
chính chuỗi đưa vào, nên tiêu đề hiện đúng ở ngôn ngữ lúc dựng panel rồi
đứng nguyên mãi. Đúng lỗi người dùng báo: mở Co4E ▸ Step config ở tiếng
Nhật thì đổi sang tiếng Anh vẫn còn tiếng Nhật.
"""
vi_pham = []
for rel, tree in _file_ui():
for node in ast.walk(tree):
if (isinstance(node, ast.Call) and _ten_ham(node.func) == "_add_section"
and len(node.args) >= 2
and isinstance(node.args[1], ast.Call)
and _ten_ham(node.args[1].func) == "tr"):
vi_pham.append(f"{rel}:{node.lineno}")
assert vi_pham == [], (
"_add_section nhận khoá dịch, không nhận tr(...) — truyền chuỗi đã dịch "
"thì tiêu đề mục không bao giờ đổi lại:\n " + "\n ".join(vi_pham))
# ---- lúc chạy: nhãn nút thật sự đổi theo ngôn ngữ -------------------------
def test_tieu_de_muc_step_config_doi_theo_ngon_ngu(qapp):
"""Bốn tiêu đề mục của Step config phải đổi theo ngôn ngữ.
Panel này dựng LƯỜI (chỉ khi người dùng chọn một bước trên canvas Co4E) nên
nó không nằm trong cây widget mà ``test_i18n_khong_con_chu_cu`` quét — phải
dựng thẳng ở đây thì mới canh được.
"""
pytest.importorskip("PySide6")
from cowork_local import i18n
from cowork_local.presentation.co4e.node_property_panel import StepConfigPanel
from cowork_local.presentation.co4e.step_config_section import _SectionHeader
cu = i18n.get_language()
try:
i18n.set_language("ja")
panel = StepConfigPanel() # dựng TRONG lúc đang tiếng Nhật
nhat = [h.text() for h in panel.findChildren(_SectionHeader)]
assert nhat, "không tìm thấy tiêu đề mục nào"
i18n.set_language("en")
anh = [h.text() for h in panel.findChildren(_SectionHeader)]
assert all(a != n for a, n in zip(anh, nhat)), (
f"tiêu đề mục giữ nguyên ngôn ngữ lúc dựng panel: {anh}")
assert anh == [f"▶ {i18n.tr(k)}" for k in
("co4e.tab_basic", "co4e.tab_model_perm",
"co4e.tab_skills_files", "co4e.f_subagents")]
finally:
i18n.set_language(cu)
def test_doi_ngon_ngu_khong_lam_mat_trang_thai_gap_mo(qapp):
"""Mục đang MỞ vẫn phải mang dấu ▼ sau khi đổi ngôn ngữ.
Đây là lý do nhãn này dùng ``bind_dynamic`` chứ không ``bind_text``: bind
cứng một khoá sẽ ghi đè cả dấu gập/mở, trả mục đang mở về ▶ trong khi phần
thân của nó vẫn đang hiện.
"""
pytest.importorskip("PySide6")
from cowork_local import i18n
from cowork_local.presentation.co4e.node_property_panel import StepConfigPanel
from cowork_local.presentation.co4e.step_config_section import _SectionHeader
cu = i18n.get_language()
try:
i18n.set_language("en")
panel = StepConfigPanel()
header = panel.findChildren(_SectionHeader)[0]
header.clicked.emit() # mở mục đầu tiên
assert header.text().startswith("▼")
i18n.set_language("ja")
assert header.text().startswith("▼"), (
f"đổi ngôn ngữ làm mất trạng thái đang mở: {header.text()!r}")
finally:
i18n.set_language(cu)
def test_nhan_nut_hop_thoai_doi_theo_ngon_ngu(qapp):
"""Nút Save/Cancel mang chữ của dự án ở cả ba ngôn ngữ.
Chốt luôn cái bẫy vòng đời: ràng buộc giữ widget bằng ``weakref``, mà nút
chuẩn do C++ dựng nên wrapper Python của nó có thể bị thu hồi. Hộp thoại ở
đây thả hết tham chiếu cục bộ, đúng như mọi call site thật.
"""
pytest.importorskip("PySide6")
from PySide6.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout
from cowork_local import i18n
from cowork_local.ui.dialog_buttons import dialog_buttons
dlg = QDialog()
QVBoxLayout(dlg).addWidget(
dialog_buttons(QDialogButtonBox.Save | QDialogButtonBox.Cancel))
hop = dlg.findChild(QDialogButtonBox)
cu = i18n.get_language()
try:
for lang in ("en", "ja", "vi"):
i18n.set_language(lang)
mong_doi = [i18n.STRINGS["dialog.save"][lang],
i18n.STRINGS["dialog.cancel"][lang]]
assert [b.text() for b in hop.buttons()] == mong_doi, (
f"nhãn nút không theo ngôn ngữ {lang}")
finally:
i18n.set_language(cu)
dlg.close()
@@ -0,0 +1,389 @@
"""Đổi ngôn ngữ phải có lớp phủ "đang xử lý" — và chỉ khi nó đáng có.
``set_language()`` chạy ĐỒNG BỘ trên GUI thread: nó gọi lần lượt mọi callback
đã đăng ký qua ``on_language_changed``. Trong lúc đó không có vòng lặp sự kiện,
nên không ``QTimer`` nào bắn được và không thanh tiến trình nào quay được — lớp
phủ phải được ``repaint()`` NGAY, không phải ``update()``.
Cùng lý do đó, lớp phủ không thể "đợi xem có chậm không rồi mới hiện": khi việc
chặn đã bắt đầu thì không còn ai chạy để bật nó lên. Thứ duy nhất đo được là
LẦN ĐỔI TRƯỚC. Vì vậy chính sách là: lần đầu trong phiên thì luôn hiện (thận
trọng), các lần sau chỉ hiện khi lần trước đã đủ chậm để người ta kịp nhận ra.
Máy nhanh vì thế nháy đúng một lần rồi thôi, thay vì nháy mãi mãi.
"""
from __future__ import annotations
from pathlib import Path
import pytest
pytest.importorskip("PySide6", reason="cần PySide6 để dựng widget thật")
from cowork_local.presentation.shell import top_bar as top_bar_mod
class _Ctx:
"""Ngữ cảnh tối thiểu mà ``_switch_language``/``_open_settings`` cần."""
def __init__(self, language: str = "vi") -> None:
self.config = type("C", (), {})()
self.config.language = language
self.config.active_provider = "p"
self.config.theme = "dark"
self.config.data = {}
self.so_lan_luu = 0
def save(self) -> None:
self.so_lan_luu += 1
class _Any:
"""Nuốt mọi lời gọi.
``_open_settings`` còn làm mới sidebar/cowork/workspace sau khi đổi ngôn
ngữ; không thứ nào trong số đó là thứ đang được kiểm ở đây.
"""
def __getattr__(self, name):
return _Any()
def __call__(self, *a, **k):
return _Any()
def _dung(qapp):
"""Một ``QMainWindow`` thật có mang ``TopBarMixin`` — đúng ``self`` mà mixin
nhận trong sản phẩm, nên lớp phủ làm con của cửa sổ chính y như ``Toast``."""
from PySide6.QtWidgets import QComboBox, QMainWindow
class W(top_bar_mod.TopBarMixin, QMainWindow):
_THEME_ICONS = {"system": "monitor", "dark": "moon", "light": "sun"}
w = W()
w.resize(900, 700)
w.ctx = _Ctx()
combo = QComboBox()
for code in ("en", "ja", "vi"):
combo.addItem(code.upper(), code)
combo.setCurrentIndex(combo.findData("vi"))
w.language_combo = combo
pcombo = QComboBox()
pcombo.addItem("P", "p")
w.provider_combo = pcombo
return w
def _dong_ho(*moc):
"""Đồng hồ giả trả lần lượt các mốc thời gian (giây) cho trước."""
it = iter(moc)
return lambda: next(it)
@pytest.fixture
def dat_ngon_ngu():
"""Đặt ngôn ngữ hiện tại mà KHÔNG phát tán cho listener.
``set_language()`` gọi mọi callback đã đăng ký — trong một phiên pytest đầy
đủ, danh sách đó chứa widget của các test khác. Ở đây chỉ cần biết ``tr()``
đang trả về ngôn ngữ nào.
"""
from cowork_local import i18n as i18n_mod
cu = i18n_mod._current
def dat(code: str) -> None:
i18n_mod._current = code
yield dat
i18n_mod._current = cu
# ---- lớp phủ bật TRƯỚC khi việc chặn bắt đầu -----------------------------
def test_overlay_hien_truoc_khi_set_language_chan(qapp, monkeypatch, dat_ngon_ngu):
"""Đi qua đúng đường người dùng bấm: combo ngôn ngữ ở chân nav rail.
``isHidden()`` chứ không ``isVisible()``: cửa sổ cha chưa ``show()`` thì
``isVisible()`` của widget con luôn False, đúng idiom mà
``test_graphrag_busy_panel.py:41`` đã dùng.
"""
dat_ngon_ngu("vi")
w = _dung(qapp)
ghi = {}
def probe(lang):
ghi["hien"] = not w._lang_busy.isHidden()
monkeypatch.setattr(top_bar_mod, "set_language", probe)
try:
w.language_combo.setCurrentIndex(w.language_combo.findData("en"))
w._on_language_changed(0)
assert ghi["hien"] is True, (
"lớp phủ bật sau khi GUI thread đã bị chặn thì người dùng không thấy gì")
finally:
w.deleteLater()
def test_nguoi_dung_that_su_nhin_thay_lop_phu(qapp, monkeypatch, dat_ngon_ngu):
"""Cửa sổ đã hiện thì ``isVisible()`` mới có nghĩa — chốt luôn mức đó."""
dat_ngon_ngu("vi")
w = _dung(qapp)
ghi = {}
def probe(lang):
ghi["hien"] = w._lang_busy.isVisible()
monkeypatch.setattr(top_bar_mod, "set_language", probe)
w.show()
try:
w._switch_language("en")
assert ghi["hien"] is True
finally:
w.hide()
w.deleteLater()
def test_overlay_tat_sau_khi_doi_xong(qapp, monkeypatch, dat_ngon_ngu):
dat_ngon_ngu("vi")
w = _dung(qapp)
monkeypatch.setattr(top_bar_mod, "set_language", lambda lang: None)
try:
w._switch_language("en")
assert w._lang_busy.isHidden() is True
finally:
w.deleteLater()
def test_callback_nem_loi_van_tat_overlay(qapp, monkeypatch, dat_ngon_ngu):
"""``i18n/__init__.py:68`` chỉ nuốt ``RuntimeError``. Một listener ném loại
khác sẽ xuyên qua ``set_language()`` — không có ``try/finally`` thì lớp phủ
treo lại trên màn hình vĩnh viễn."""
dat_ngon_ngu("vi")
w = _dung(qapp)
def probe(lang):
raise ValueError("một listener nào đó hỏng")
monkeypatch.setattr(top_bar_mod, "set_language", probe)
try:
with pytest.raises(ValueError):
w._switch_language("en")
assert w._lang_busy.isHidden() is True, "lớp phủ treo lại mãi mãi"
assert w.language_combo.isEnabled() is True, "combo khoá lại mãi mãi"
finally:
w.deleteLater()
# ---- chữ hiện bằng NGÔN NGỮ CŨ -------------------------------------------
def _chu_luc_chan(qapp, monkeypatch, dat_ngon_ngu, tu: str, sang: str) -> str:
"""Chữ trên lớp phủ tại thời điểm việc chặn bắt đầu.
Probe đổi ``_current`` thật, y như ``set_language()`` làm, nên nếu ``tr()``
bị gọi SAU lượt đổi thì chữ sẽ ra ngôn ngữ mới và test đỏ.
"""
from cowork_local import i18n as i18n_mod
dat_ngon_ngu(tu)
w = _dung(qapp)
ghi = {}
def probe(lang):
i18n_mod._current = lang
ghi["chu"] = w._lang_busy.text()
monkeypatch.setattr(top_bar_mod, "set_language", probe)
try:
w._switch_language(sang)
return ghi["chu"]
finally:
w.deleteLater()
def test_chu_hien_bang_ngon_ngu_cu(qapp, monkeypatch, dat_ngon_ngu):
from cowork_local.i18n import STRINGS
chu = _chu_luc_chan(qapp, monkeypatch, dat_ngon_ngu, tu="vi", sang="en")
assert chu == STRINGS["app.lang.switching"]["vi"]
def test_chu_hien_bang_ngon_ngu_cu_chieu_nguoc(qapp, monkeypatch, dat_ngon_ngu):
from cowork_local.i18n import STRINGS
chu = _chu_luc_chan(qapp, monkeypatch, dat_ngon_ngu, tu="en", sang="vi")
assert chu == STRINGS["app.lang.switching"]["en"]
def test_i18n_du_ba_ngon_ngu():
from cowork_local.i18n import STRINGS
assert set(STRINGS["app.lang.switching"]) >= {"en", "ja", "vi"}
# ---- hình dạng: phủ kín cửa sổ, khoá combo -------------------------------
def test_overlay_phu_kin_cua_so(qapp, monkeypatch, dat_ngon_ngu):
"""Phủ nửa vời thì người dùng vẫn bấm được vào phần còn lại."""
dat_ngon_ngu("vi")
w = _dung(qapp)
monkeypatch.setattr(top_bar_mod, "set_language", lambda lang: None)
try:
w._switch_language("en")
assert w._lang_busy.geometry() == w.rect()
finally:
w.deleteLater()
def test_combo_bi_khoa_trong_luc_chan(qapp, monkeypatch, dat_ngon_ngu):
dat_ngon_ngu("vi")
w = _dung(qapp)
ghi = {}
def probe(lang):
ghi["khoa"] = w.language_combo.isEnabled()
monkeypatch.setattr(top_bar_mod, "set_language", probe)
try:
w._switch_language("en")
assert ghi["khoa"] is False
assert w.language_combo.isEnabled() is True, "phải mở khoá lại sau khi xong"
finally:
w.deleteLater()
# ---- chính sách: chỉ nháy một lần trên máy nhanh -------------------------
def test_lan_dau_trong_phien_luon_hien(qapp, monkeypatch, dat_ngon_ngu):
"""Chưa đo được gì thì nghiêng về phía hiện: một cái nháy còn hơn một lần
đơ vài giây không lời giải thích."""
from cowork_local.presentation.shell import busy_overlay as busy_mod
dat_ngon_ngu("vi")
w = _dung(qapp)
ghi = {}
monkeypatch.setattr(top_bar_mod, "set_language",
lambda lang: ghi.setdefault("hien", not w._lang_busy.isHidden()))
try:
assert w._lang_busy_overlay()._last_ms is None
w._switch_language("en")
assert ghi["hien"] is True
assert busy_mod._NOTICEABLE_MS > 0
finally:
w.deleteLater()
def test_lan_truoc_nhanh_thi_khong_hien_nua(qapp, monkeypatch, dat_ngon_ngu):
"""20 ms thì không ai kịp thấy mình đang đợi — hiện lớp phủ ở đó là tự tạo
ra một cái nháy toàn màn hình."""
from cowork_local.presentation.shell import busy_overlay as busy_mod
dat_ngon_ngu("vi")
w = _dung(qapp)
monkeypatch.setattr(busy_mod, "perf_counter", _dong_ho(0.0, 0.02, 5.0, 5.02))
ghi = []
monkeypatch.setattr(top_bar_mod, "set_language",
lambda lang: ghi.append(not w._lang_busy.isHidden()))
try:
w._switch_language("en")
w._switch_language("ja")
assert ghi == [True, False]
finally:
w.deleteLater()
def test_lan_truoc_cham_thi_van_hien(qapp, monkeypatch, dat_ngon_ngu):
"""Máy chậm / thư viện skill lớn: lần trước mất 1 giây thì lần này phải có
lớp phủ, không đợi thêm lần nào nữa."""
from cowork_local.presentation.shell import busy_overlay as busy_mod
dat_ngon_ngu("vi")
w = _dung(qapp)
monkeypatch.setattr(busy_mod, "perf_counter", _dong_ho(0.0, 1.0, 5.0, 6.0))
ghi = []
monkeypatch.setattr(top_bar_mod, "set_language",
lambda lang: ghi.append(not w._lang_busy.isHidden()))
try:
w._switch_language("en")
w._switch_language("ja")
assert ghi == [True, True]
finally:
w.deleteLater()
# ---- đường Cài đặt ▸ Chung -----------------------------------------------
def _mo_cai_dat(qapp, monkeypatch, ngon_ngu_sau_khi_luu: str):
"""Chạy ``_open_settings`` với hộp thoại bị thay, trả về (window, ghi)."""
w = _dung(qapp)
w._apply_theme = lambda: None
w.theme_btn = _Any()
w.cowork = _Any()
w.workspace = _Any()
w.sidebar = _Any()
ghi = {"goi": 0}
class _Dlg:
def __init__(self, ctx, parent):
self._ctx = ctx
def exec(self):
self._ctx.config.language = ngon_ngu_sau_khi_luu
return True
def probe(lang):
ghi["goi"] += 1
ghi["hien"] = not w._lang_busy.isHidden()
monkeypatch.setattr(top_bar_mod, "SettingsDialog", _Dlg)
monkeypatch.setattr(top_bar_mod, "set_language", probe)
return w, ghi
def test_duong_cai_dat_cung_co_overlay(qapp, monkeypatch, dat_ngon_ngu):
"""Cài đặt ▸ Chung đổi ngôn ngữ thì cũng chặn GUI thread y hệt combo."""
dat_ngon_ngu("vi")
w, ghi = _mo_cai_dat(qapp, monkeypatch, ngon_ngu_sau_khi_luu="en")
try:
w._open_settings()
assert ghi["goi"] == 1
assert ghi["hien"] is True
finally:
w.deleteLater()
def test_duong_cai_dat_khong_nhay_khi_khong_doi_ngon_ngu(qapp, monkeypatch, dat_ngon_ngu):
"""Bấm Lưu mà không đụng tới ngôn ngữ: ``set_language()`` là no-op
(``i18n/__init__.py:62``), nên bật lớp phủ ở đây chỉ là một cái nháy."""
dat_ngon_ngu("vi")
w, ghi = _mo_cai_dat(qapp, monkeypatch, ngon_ngu_sau_khi_luu="vi")
try:
w._open_settings()
assert ghi["goi"] == 0, "gọi set_language() cho một lượt đổi không tồn tại"
assert getattr(w, "_lang_busy", None) is None
finally:
w.deleteLater()
# ---- màu lấy từ theme, không hardcode ------------------------------------
def test_mau_lay_tu_theme():
"""Guardrail G4: ngoài ``theme/`` không file nào được đặt tên một màu."""
from cowork_local.theme.qss import _TEMPLATE
assert "QWidget#busyOverlayPanel" in _TEMPLATE.template
src = (Path(__file__).resolve().parents[2]
/ "presentation" / "shell" / "busy_overlay.py").read_text(encoding="utf-8")
assert "setStyleSheet" not in src
+86
View File
@@ -0,0 +1,86 @@
"""Thanh menu trái không được kéo mất.
Kéo thanh chia sang trái hết cỡ thì thanh menu biến mất hẳn, và khi đã mất thì
không còn gì đủ rộng để nhận ra, nói gì tới bắt lại mà kéo ra.
Nguyên nhân: ``_nav_wrap.setMinimumWidth(132)`` chỉ chi phối việc BỐ TRÍ, không
chi phối thao tác kéo. ``QSplitter`` mặc định cho phép người dùng kéo một ngăn
vượt qua chính minimum của nó rồi đóng sập về 0 — phải tắt bằng
``setChildrenCollapsible(False)``.
Thu gọn là việc của nút MENU, và nó dừng ở 54px chứ không về 0.
"""
from __future__ import annotations
import pytest
from cowork_local.presentation.shell.rail_metrics import (
_NAV_COLLAPSED_WIDTH, _NAV_MIN_WIDTH,
)
pytest.importorskip("PySide6", reason="cần PySide6 để dựng cửa sổ thật")
@pytest.fixture(scope="module")
def window(qapp, tmp_path_factory):
from cowork_local.presentation.shell.bootstrap import build_config, build_context
from cowork_local.presentation.shell.main_window import MainWindow
config_path = tmp_path_factory.mktemp("cfg") / "config.json"
build_config(config_path)
win = MainWindow(build_context(config_path))
win.resize(1280, 800)
yield win
win.close()
def test_splitter_khong_cho_dong_sap_ngan_nao(window):
"""Chốt trực tiếp thứ đã thiếu.
Cố ý KHÔNG khẳng định ``isCollapsible(0)``: hàm đó trả về cờ đặt RIÊNG cho
từng ngăn, và nó vẫn là ``True`` kể cả khi chính sách chung đã tắt — nên
khẳng định vào đó là chốt một chi tiết cài đặt của Qt, không phải hành vi ta
cần. Ba bài dưới kiểm bằng thao tác kéo thật.
"""
assert window.split.childrenCollapsible() is False
def test_thanh_menu_co_san_min_width(window):
"""Không có sàn thì tắt collapsible cũng chẳng chặn được gì."""
assert window._nav_wrap.minimumWidth() == _NAV_MIN_WIDTH
def test_keo_het_co_sang_trai_van_khong_mat_thanh_menu(window):
"""Kéo thanh chia về 0 — Qt phải kẹp lại ở sàn, không cho về 0."""
window.split.setSizes([0, 1280])
assert window.split.sizes()[0] >= _NAV_MIN_WIDTH, (
f"thanh menu bị thu về {window.split.sizes()[0]}px")
def test_keo_qua_da_van_bi_kep_lai(window):
"""Giá trị âm/cực nhỏ cũng phải bị kẹp, không chỉ đúng số 0."""
window.split.setSizes([-500, 1780])
assert window.split.sizes()[0] >= _NAV_MIN_WIDTH
def test_nut_menu_van_thu_gon_duoc_ve_54(window):
"""Bản vá không được chặn mất đường thu gọn hợp lệ.
Nút MENU thu về ``_NAV_COLLAPSED_WIDTH`` (54px) bằng cách hạ CHÍNH minimum
của widget, nên ``setChildrenCollapsible(False)`` không cản — nó chỉ cấm đi
xuống dưới minimum đang có.
"""
assert window._nav_collapsed is False
window._toggle_nav()
try:
assert window._nav_collapsed is True
assert window._nav_wrap.minimumWidth() == _NAV_COLLAPSED_WIDTH
window.split.setSizes([0, 1280])
assert window.split.sizes()[0] >= _NAV_COLLAPSED_WIDTH
finally:
window._toggle_nav()
assert window._nav_collapsed is False
assert window._nav_wrap.minimumWidth() == _NAV_MIN_WIDTH
+240
View File
@@ -0,0 +1,240 @@
"""Tab Project: chế độ chỉ-xem, chặn trùng tên, menu chuột phải, đổi ngôn ngữ.
Bốn tính năng nằm cùng ``presentation/workspace/project_editing.py`` nên test
cũng đi cùng nhau. Xem docstring của module đó về lý do chúng là một khối.
"""
from __future__ import annotations
import pytest
from cowork_local.presentation.workspace.project_editing import (
ProjectRow, _row_layout_of,
)
# ---- ProjectRow: giữ SỐ, không giữ chuỗi đã format -----------------------
def test_dong_project_dung_lai_nhan_theo_ngon_ngu(qapp):
"""Đây là bug gốc: ``workspace.counts`` được format một lần lúc dựng dòng,
nên đổi ngôn ngữ xong nó vẫn nằm ở ngôn ngữ cũ — kể cả khi chọn tiếng Anh."""
from cowork_local import i18n
truoc = i18n.get_language()
try:
i18n.set_language("vi")
row = ProjectRow("Dự án A", chats=2, tasks=3)
vi = row.counts_label.text()
i18n.set_language("ja")
row.retranslate()
ja = row.counts_label.text()
i18n.set_language("en")
row.retranslate()
en = row.counts_label.text()
finally:
i18n.set_language(truoc)
assert vi != ja != en, (vi, ja, en)
# Số liệu phải sống sót qua mọi lần dịch lại
for text in (vi, ja, en):
assert "2" in text and "3" in text, text
def test_dong_project_giu_so_de_dich_lai_duoc(qapp):
"""Giữ số chứ không giữ chuỗi — nếu chỉ giữ chuỗi thì dịch lại phải đọc đĩa."""
row = ProjectRow("X", chats=7, tasks=1)
assert (row.chats, row.tasks) == (7, 1)
# ---- _row_layout_of ------------------------------------------------------
def test_tim_duoc_layout_dang_chua_widget(qapp):
"""Nút "Sửa project" được chèn cạnh nút Lưu bằng hàm này."""
from PySide6.QtWidgets import QHBoxLayout, QPushButton, QVBoxLayout, QWidget
host = QWidget()
outer = QVBoxLayout(host)
inner = QHBoxLayout()
btn = QPushButton("x")
inner.addWidget(btn)
outer.addLayout(inner)
assert _row_layout_of(btn) is inner
def test_khong_co_cha_thi_tra_None(qapp):
from PySide6.QtWidgets import QPushButton
assert _row_layout_of(QPushButton("mo coi")) is None
# ---- chặn trùng tên ------------------------------------------------------
class _FakeProject:
def __init__(self, pid: str, name: str):
self.project_id = pid
self.name = name
class _NameChecker:
"""Chỉ phần chặn trùng tên của mixin, không dựng cả WorkspaceTab."""
from cowork_local.presentation.workspace.project_editing import (
ProjectEditingMixin as _M,
)
_name_taken = _M._name_taken
@pytest.fixture
def checker(monkeypatch):
import cowork_local.core.projects as projects
monkeypatch.setattr(projects, "list_projects", lambda: [
_FakeProject("p1", "Báo cáo"),
_FakeProject("p2", "Sales Q3"),
])
return _NameChecker()
@pytest.mark.parametrize("name", ["Báo cáo", "báo cáo", " Báo cáo ", "BÁO CÁO"])
def test_trung_ten_bi_chan_du_hoa_thuong_hay_khoang_trang(checker, name):
"""Với người dùng thì "Báo cáo" và "báo cáo " là cùng một cái tên."""
assert checker._name_taken(name) is True
def test_ten_moi_thi_khong_bi_chan(checker):
assert checker._name_taken("Báo cáo 2026") is False
def test_ten_rong_khong_bi_coi_la_trung(checker):
"""Ô tên trống là "chưa nhập", không phải "trùng" — thông báo phải khác nhau."""
assert checker._name_taken("") is False
assert checker._name_taken(" ") is False
def test_sua_chinh_no_thi_giu_nguyen_ten_duoc(checker):
"""Mở project rồi bấm Lưu mà không đổi tên thì không được báo trùng chính nó."""
assert checker._name_taken("Báo cáo", ignore_id="p1") is False
assert checker._name_taken("Báo cáo", ignore_id="p2") is True
# ---- i18n: các key mới có đủ 3 ngôn ngữ ----------------------------------
@pytest.mark.parametrize("key", [
"workspace.edit_project", "workspace.menu_open", "workspace.menu_edit",
"workspace.menu_delete", "workspace.name_taken_title", "workspace.name_taken_body",
"workspace.instructions",
])
def test_key_moi_co_du_ba_ngon_ngu(key):
from cowork_local import i18n
entry = i18n.STRINGS[key]
for lang in ("en", "ja", "vi"):
assert entry.get(lang), f"{key} thiếu {lang}"
def test_nhan_instructions_da_duoc_dich():
"""Nó nằm giữa "Tên"/"名前" và "Mô tả"/"説明" đã dịch, nên để nguyên tiếng
Anh là sót chứ không phải chủ ý."""
from cowork_local import i18n
entry = i18n.STRINGS["workspace.instructions"]
assert entry["ja"] != entry["en"]
assert entry["vi"] != entry["en"]
# ---- theme: nút vàng / xanh lá dùng token, không dùng hex ----------------
@pytest.mark.parametrize("object_name,token", [("warning", "warning"), ("success", "success")])
def test_nut_mau_duoc_style_bang_token_trong_theme(object_name, token):
"""Màu của hai nút phải nằm trong theme/, không phải setStyleSheet cục bộ."""
from cowork_local.theme.qss import _TEMPLATE
rule = f"QPushButton#{object_name}"
assert rule in _TEMPLATE.template, f"{rule} chưa được style trong theme"
block = _TEMPLATE.template.split(rule, 1)[1][:200]
assert f"${token}" in block
def test_khong_hardcode_mau_trong_module_moi():
"""Guardrail G4: ngoài theme/ không file nào được đặt tên một màu."""
import re
from pathlib import Path
src = (Path(__file__).resolve().parents[2]
/ "presentation" / "workspace" / "project_editing.py").read_text(encoding="utf-8")
code = "\n".join(l for l in src.splitlines()
if not l.strip().startswith("#") and "#CCA700" not in l)
assert not re.search(r'setStyleSheet\(', code), "không được setStyleSheet cục bộ"
# ---- tích hợp: chế độ chỉ-xem trên WorkspaceTab thật ----------------------
#
# KHÔNG tạo project trong các bài này. ``core/projects.py`` gắn
# ``PROJECTS_DIR = CONFIG_DIR / "projects"`` vào ``~/.cowork_local`` THẬT, nên
# gọi ``_create()`` là ghi vào dữ liệu đang dùng của người chạy test. Chỉ cần
# gán ``_current_id`` một giá trị giả là đủ để bật đúng nhánh cần kiểm.
@pytest.fixture(scope="module")
def workspace(qapp, tmp_path_factory):
from cowork_local.presentation.shell.bootstrap import build_config, build_context
from cowork_local.presentation.shell.main_window import MainWindow
config_path = tmp_path_factory.mktemp("cfg") / "config.json"
build_config(config_path)
win = MainWindow(build_context(config_path))
yield win.workspace
win.close()
def test_project_san_co_mo_ra_o_che_do_chi_xem(workspace):
"""Lỡ tay không được sửa mất nội dung của một project đang dùng."""
workspace._current_id = "gia-lap"
workspace.set_project_editable(False)
assert workspace.name_edit.isReadOnly() is True
assert workspace.desc_edit.isReadOnly() is True
assert workspace.instr_edit.isReadOnly() is True
assert workspace._save_btn.isEnabled() is False
assert workspace._edit_btn.isEnabled() is True
def test_bam_sua_project_thi_mo_khoa_form(workspace):
workspace._current_id = "gia-lap"
workspace.enter_project_edit_mode()
assert workspace.name_edit.isReadOnly() is False
assert workspace._save_btn.isEnabled() is True
assert workspace._edit_btn.isEnabled() is False
def test_nut_luu_doi_sang_xanh_la_khi_dang_sua(workspace):
"""Yêu cầu: Save đổi màu xanh lá khi vào chế độ sửa."""
workspace._current_id = "gia-lap"
workspace.set_project_editable(False)
assert workspace._save_btn.objectName() == "primary"
workspace.set_project_editable(True)
assert workspace._save_btn.objectName() == "success"
def test_nut_sua_project_mau_vang(workspace):
"""Yêu cầu: nút Edit project màu vàng — qua token ``warning``."""
assert workspace._edit_btn.objectName() == "warning"
def test_chua_chon_project_thi_khong_co_gi_bam_duoc(workspace):
"""Không project nào đang mở thì cả Sửa lẫn Lưu đều không có nghĩa."""
workspace._current_id = ""
workspace.set_project_editable(False)
assert workspace._edit_btn.isVisible() is False
assert workspace._save_btn.isEnabled() is False
def test_danh_sach_project_bat_chuot_phai(workspace):
"""Trước đây bấm phải không làm gì cả."""
from PySide6.QtCore import Qt
assert workspace.project_list.contextMenuPolicy() == Qt.CustomContextMenu
+212
View File
@@ -0,0 +1,212 @@
"""Regression: Định tuyến / Tự chạy phải đổi chữ theo ngôn ngữ runtime (UI-20260907-01).
Người dùng dựng app ở một ngôn ngữ rồi đổi sang ngôn ngữ khác: chữ trên công tắc
"Định tuyến" và ô "Tự chạy" ở tab Cowork vẫn đóng băng ở ngôn ngữ lúc dựng widget.
Vì vậy MỌI test ở đây phải theo khuôn **dựng ở X → đổi sang Y → kiểm**: kiểm ngay
lúc vừa dựng là vô nghĩa (widget luôn đúng lúc dựng, kể cả khi bản vá sai).
"""
from __future__ import annotations
import pytest
from cowork_local import i18n
from cowork_local.ui.routing_toggle import AutoRunToggle, RoutingToggle
class FakeCtx:
"""AppContext tối thiểu; ghi lại MỌI lần ghi để test chứng minh dịch lại không ghi đĩa."""
def __init__(self) -> None:
"""Khởi tạo với sổ ghi rỗng."""
self.writes: list = []
def project_routing_mode(self, surface: str) -> str:
"""Chế độ định tuyến của bề mặt đang mở — cố định "manual" cho test."""
return "manual"
def set_project_routing_mode(self, surface: str, mode: str) -> None:
"""Ghi lại lần ghi chế độ định tuyến thay vì chạm đĩa."""
self.writes.append((surface, mode))
def project_auto_run(self) -> bool:
"""Trạng thái tự chạy của project — cố định True cho test."""
return True
def set_project_auto_run(self, value: bool) -> None:
"""Ghi lại lần ghi cờ tự chạy thay vì chạm đĩa."""
self.writes.append(("auto_run", value))
@pytest.fixture
def i18n_sach():
"""Trả ngôn ngữ VÀ danh sách listener về nguyên trạng.
``i18n._listeners`` chỉ có đường vào (i18n/__init__.py:100); widget do test
dựng sẽ nằm lại đó và bị gọi ở mọi test sau. Khôi phục để test hermetic.
"""
lang, listeners = i18n.get_language(), list(i18n._listeners)
yield
i18n._listeners[:] = listeners
i18n.set_language(lang)
def _chu_dinh_tuyen_phai_la(toggle: RoutingToggle, lang: str) -> None:
"""Mọi chữ trên công tắc định tuyến phải khớp bản dịch của ``lang``.
So với ``i18n.STRINGS`` chứ không hard-code chuỗi: đổi từ ngữ sau này không
được làm test đỏ oan.
"""
assert toggle._label.text() == i18n.STRINGS["routing.toggle_label"][lang]
assert toggle._combo.toolTip() == i18n.STRINGS["routing.toggle_tooltip"][lang]
for i, (_value, key) in enumerate(toggle._modes):
assert toggle._combo.itemText(i) == i18n.STRINGS[key][lang], (
f"item {i} sai ngôn ngữ"
)
def _on_dinh_layout(app, lap: int = 3) -> None:
"""Ép Qt xử lý hết layout request đang xếp hàng để bề rộng widget ổn định."""
from PySide6.QtCore import QEvent
for _ in range(lap):
app.sendPostedEvents(None, QEvent.LayoutRequest)
app.processEvents()
def _be_rong_o_chu(combo) -> int:
"""Bề rộng vùng hiển thị chữ của combo (đã trừ khung và mũi tên)."""
from PySide6.QtWidgets import QStyle, QStyleOptionComboBox
opt = QStyleOptionComboBox()
opt.initFrom(combo)
opt.currentText = combo.currentText()
opt.editable = combo.isEditable()
opt.frame = combo.hasFrame()
opt.subControls = QStyle.SC_All
return combo.style().subControlRect(
QStyle.CC_ComboBox, opt, QStyle.SC_ComboBoxEditField, combo
).width()
def test_dinh_tuyen_dung_ngon_ngu_khi_dung_o_ja_roi_doi_sang_vi(qapp, i18n_sach):
"""Dựng ở tiếng Nhật, đổi sang tiếng Việt → nhãn/tooltip/4 chế độ phải là tiếng Việt."""
i18n.set_language("ja")
toggle = RoutingToggle(FakeCtx(), "cowork")
i18n.set_language("vi")
_chu_dinh_tuyen_phai_la(toggle, "vi")
def test_dinh_tuyen_dung_ngon_ngu_khi_dung_o_ja_roi_doi_sang_en(qapp, i18n_sach):
"""Dựng ở tiếng Nhật, đổi sang tiếng Anh → toàn bộ chữ phải là tiếng Anh."""
i18n.set_language("ja")
toggle = RoutingToggle(FakeCtx(), "cowork")
i18n.set_language("en")
_chu_dinh_tuyen_phai_la(toggle, "en")
def test_dinh_tuyen_dung_ngon_ngu_khi_dung_o_vi_roi_doi_sang_ja(qapp, i18n_sach):
"""Chiều ngược lại: dựng ở tiếng Việt, đổi sang tiếng Nhật."""
i18n.set_language("vi")
toggle = RoutingToggle(FakeCtx(), "cowork")
i18n.set_language("ja")
_chu_dinh_tuyen_phai_la(toggle, "ja")
def test_doi_ngon_ngu_khong_lam_doi_che_do_dinh_tuyen(qapp, i18n_sach):
"""Test KHOÁ: dịch lại chỉ được đổi CHỮ, không đổi chế độ và không ghi đĩa.
Cột ``data`` ("off"/"auto"/"manual"/"fallback") được persist xuống đĩa; nếu
ai đó dịch luôn cột đó, hoặc đổi ``retranslate()`` sang ``clear()+addItem()``,
thì mode routing của mọi workspace bị âm thầm reset về "off".
"""
i18n.set_language("ja")
ctx = FakeCtx()
toggle = RoutingToggle(ctx, "cowork")
for lang in ("vi", "en", "ja"):
i18n.set_language(lang)
assert toggle.current_mode() == "manual"
assert toggle._combo.currentData() == "manual"
assert ctx.writes == [], "đổi ngôn ngữ không được ghi lại chế độ định tuyến"
def test_tu_chay_dung_ngon_ngu_sau_khi_doi_ngon_ngu(qapp, i18n_sach):
"""Ô "Tự chạy" cũng phải đổi chữ, và không được tự đổi trạng thái tick."""
i18n.set_language("ja")
ctx = FakeCtx()
toggle = AutoRunToggle(ctx)
i18n.set_language("vi")
assert toggle._chk.text() == i18n.STRINGS["routing.autorun_label"]["vi"]
assert toggle._chk.toolTip() == i18n.STRINGS["routing.autorun_tooltip"]["vi"]
i18n.set_language("en")
assert toggle._chk.text() == i18n.STRINGS["routing.autorun_label"]["en"]
assert toggle._chk.toolTip() == i18n.STRINGS["routing.autorun_tooltip"]["en"]
assert toggle._chk.isChecked() is True
assert ctx.writes == [], "đổi ngôn ngữ không được ghi lại cờ tự chạy"
def test_bon_che_do_khong_bi_cat_chu_sau_khi_doi_ngon_ngu(qapp, i18n_sach):
"""Dịch lại xong thì combo phải rộng ra theo chữ mới, không nuốt đuôi.
Combo dựng ở tiếng Nhật rồi đổi sang tiếng Việt: tên chế độ tiếng Việt dài
hơn tên tiếng Nhật, nên nếu bề rộng còn bị khoá theo lần hiện đầu tiên thì
"Thủ công"/"Dự phòng" (96px) không lọt ô chữ 89px và bị cắt đuôi.
Hai lớp assert, mỗi lớp bắt một nửa bản vá:
* chữ phải là tiếng Việt — bắt việc quên đăng ký ``on_language_changed``;
* chữ phải lọt ô — bắt việc quên ``setSizeAdjustPolicy``, vì combo chỉ đo
lại bề rộng khi chính sách là ``AdjustToContents``.
``host`` được nới rộng có chủ ý: ``addStretch`` phải còn chỗ trống để nhả ra
cho combo, nếu không thì cả combo đúng lẫn combo sai đều kẹt ở bề rộng cũ và
test không phân biệt được hai trạng thái.
"""
from PySide6.QtGui import QFontMetrics
from PySide6.QtWidgets import QHBoxLayout, QWidget
i18n.set_language("ja")
host = QWidget()
lay = QHBoxLayout(host)
toggle = RoutingToggle(FakeCtx(), "cowork")
lay.addWidget(toggle)
lay.addStretch(1)
host.resize(600, 60)
host.show()
_on_dinh_layout(qapp)
i18n.set_language("vi")
_on_dinh_layout(qapp)
combo = toggle._combo
fm = QFontMetrics(combo.font())
o_chu = _be_rong_o_chu(combo)
chu_hien = [combo.itemText(i) for i in range(combo.count())]
bi_cat = [t for t in chu_hien if fm.horizontalAdvance(t) > o_chu]
host.close()
assert chu_hien == [i18n.STRINGS[key]["vi"] for _value, key in toggle._modes]
assert not bi_cat, f"bị cắt chữ: {bi_cat} (ô chữ rộng {o_chu}px)"
@pytest.mark.parametrize("key", [
"routing.toggle_label", "routing.toggle_tooltip",
"routing.autorun_label", "routing.autorun_tooltip",
"routing.mode_off", "routing.mode_auto",
"routing.mode_manual", "routing.mode_fallback",
])
def test_key_routing_co_du_ba_ngon_ngu(key):
"""Test KHOÁ: thiếu một bản dịch thì ``tr()`` rơi về tiếng Anh, lỗi lại tái diễn."""
for lang in ("en", "ja", "vi"):
assert i18n.STRINGS[key].get(lang), f"{key} thiếu {lang}"
+235
View File
@@ -0,0 +1,235 @@
"""Sandbox Security unlock — chốt các đường KHÔNG được mở khoá (SEC-20260907-01).
``DEFAULT_CONFIG`` ship ``agent_security.sandbox_pw = ""`` kể từ commit
``3827552 fix(security): remove shared unlock defaults``, và cấu hình đưa tới
dialog LUÔN được deep-merge với defaults đó
(``infrastructure/config/json_config_repository.py``). Nghĩa là trên mọi bản cài
không đặt ``COWORK_SANDBOX_PASSWORD``, mật khẩu đã lưu là chuỗi rỗng — và phép so
sánh ``pw == self._sandbox_pw`` nhận luôn ô nhập trống.
Ba nhóm bài ở đây:
* **đường tấn công** — chốt đúng lỗ trên;
* **đường đi đúng** — bản vá không được phá, kể cả với mật khẩu có dấu;
* **chặn cả lớp lỗi** — commit ``3827552`` sửa ``config.py`` nhưng bỏ sót bản sao
thứ hai của literal trong ``ui/settings_dialog.py``. Bài cuối quét chéo mọi thư
mục nguồn để lần sau không sót kiểu đó nữa.
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
# Fake ctx dùng chung với đặc tả SettingsDialog: nó đã là "đủ cho SettingsDialog,
# không hơn". Dựng bản thứ hai ở đây chỉ tạo thêm một chỗ để lệch nhau.
from .test_settings_dialog_dac_ta import _Ctx
@pytest.fixture
def shown(monkeypatch):
"""Ghi lại mọi QMessageBox thay vì bật modal thật (modal sẽ treo test).
Trả về list các ``(loại, tiêu_đề, nội_dung)`` — cần thiết để phân biệt
"chưa cấu hình mật khẩu" với "sai mật khẩu"; nếu chỉ nuốt hộp thoại đi thì
hai nhánh gộp lại làm một mà test vẫn xanh.
"""
from PySide6.QtWidgets import QMessageBox
calls: list[tuple[str, str, str]] = []
def _record(kind):
def _fn(_parent, title, text, *a, **k):
calls.append((kind, title, text))
return staticmethod(_fn)
monkeypatch.setattr(QMessageBox, "warning", _record("warning"))
monkeypatch.setattr(QMessageBox, "information", _record("information"))
return calls
def _dialog(stored_pw: str):
"""SettingsDialog với ``sandbox_pw`` đúng như bản cài thật: key CÓ mặt."""
from cowork_local.ui.settings_dialog import SettingsDialog
ctx = _Ctx()
ctx.config.data["agent_security"]["sandbox_pw"] = stored_pw
return SettingsDialog(ctx)
# ---- đường tấn công ------------------------------------------------------
def test_o_trong_khong_mo_duoc_khoa(qapp, shown):
"""Chưa đặt mật khẩu (sandbox_pw == "") thì ô nhập trống KHÔNG được mở khoá."""
dlg = _dialog("")
dlg.sandbox_pw_edit.setText("")
dlg._sandbox_unlock()
assert dlg._sandbox_unlocked is False
dlg.deleteLater()
def test_go_bua_khi_chua_dat_mat_khau_cung_khong_mo_duoc(qapp, shown):
"""Mật khẩu lưu rỗng thì KHÔNG chuỗi nào mở được, kể cả chuỗi khác rỗng."""
dlg = _dialog("")
dlg.sandbox_pw_edit.setText("bat ky")
dlg._sandbox_unlock()
assert dlg._sandbox_unlocked is False
dlg.deleteLater()
def test_mat_khau_sai_khong_mo_duoc(qapp, shown):
"""Đã đặt mật khẩu thì gõ sai vẫn khoá."""
dlg = _dialog("K7MNP2QRSTVW")
dlg.sandbox_pw_edit.setText("K7MNP2QRSTVX")
dlg._sandbox_unlock()
assert dlg._sandbox_unlocked is False
dlg.deleteLater()
# ---- thông báo phải phân biệt được hai tình huống -------------------------
def test_chua_cau_hinh_bao_khac_voi_sai_mat_khau(qapp, shown):
"""Hai nhánh phải nói hai chuyện khác nhau.
Người chưa từng đặt mật khẩu mà nhận "Password incorrect" sẽ gõ lại mãi một
thứ không tồn tại. Không có bài này thì gộp hai nhánh về một thông báo chung
vẫn xanh hết.
"""
from cowork_local.i18n import tr
dlg = _dialog("")
dlg.sandbox_pw_edit.setText("")
dlg._sandbox_unlock()
chua_cau_hinh = list(shown)
dlg.deleteLater()
shown.clear()
dlg2 = _dialog("K7MNP2QRSTVW")
dlg2.sandbox_pw_edit.setText("sai roi")
dlg2._sandbox_unlock()
sai_mat_khau = list(shown)
dlg2.deleteLater()
assert len(chua_cau_hinh) == 1, "phải hiện đúng một thông báo"
assert len(sai_mat_khau) == 1
assert chua_cau_hinh[0][2] == tr("settings.sandbox_pw_unset_body")
assert chua_cau_hinh[0][2] != sai_mat_khau[0][2], (
"chưa cấu hình mật khẩu và sai mật khẩu phải là hai thông báo khác nhau")
# ---- đường đi đúng vẫn phải chạy ----------------------------------------
def test_mat_khau_dung_van_mo_duoc(qapp, shown):
"""Bản vá không được phá đường đi hợp lệ."""
dlg = _dialog("K7MNP2QRSTVW")
dlg.sandbox_pw_edit.setText("K7MNP2QRSTVW")
dlg._sandbox_unlock()
assert dlg._sandbox_unlocked is True
dlg.deleteLater()
@pytest.mark.parametrize("pw", ["mật khẩu", "パスワード", "sénhà-2026"])
def test_mat_khau_co_dau_khong_lam_crash(qapp, shown, pw):
"""``secrets.compare_digest`` ném TypeError nếu str có ký tự ngoài ASCII.
App mặc định tiếng Việt và phục vụ khách Nhật, nên chữ có dấu trong ô mật
khẩu là input bình thường. Phải so sánh trên bytes.
"""
dlg = _dialog(pw)
dlg.sandbox_pw_edit.setText(pw)
dlg._sandbox_unlock() # không được ném TypeError
assert dlg._sandbox_unlocked is True
dlg.deleteLater()
def test_mat_khau_co_dau_sai_thi_van_khoa(qapp, shown):
"""Chữ có dấu không được biến thành đường mở khoá dễ dãi."""
dlg = _dialog("mật khẩu")
dlg.sandbox_pw_edit.setText("mat khau")
dlg._sandbox_unlock()
assert dlg._sandbox_unlocked is False
dlg.deleteLater()
# ---- hàm so khớp, gọi thẳng ----------------------------------------------
@pytest.mark.parametrize("entered,stored,expected", [
("", "", False), # cả hai rỗng
("", "K7MNP2QRSTVW", False), # ô nhập rỗng
("K7MNP2QRSTVW", "", False), # chưa đặt mật khẩu — nhánh phòng thủ
("K7MNP2QRSTVW", "K7MNP2QRSTVW", True),
("mật khẩu", "mật khẩu", True), # ngoài ASCII
("mật khẩu", "mat khau", False),
])
def test_ham_so_khop(entered, stored, expected):
"""Gọi thẳng ``_sandbox_password_matches`` — phủ cả nhánh mà call site đã
chặn trước bằng return sớm."""
from cowork_local.ui.settings_dialog import _sandbox_password_matches
assert _sandbox_password_matches(entered, stored) is expected
# ---- chặn cả lớp lỗi -----------------------------------------------------
#: ``.get("<khoá kiểu credential>", "<literal khác rỗng>")`` — mặc định trông có
#: vẻ an toàn nhưng thực ra là credential nằm trong mã nguồn. Nó cũng là code
#: chết: cấu hình đã deep-merge với DEFAULT_CONFIG nên key luôn tồn tại.
#:
#: Cố ý KHÔNG bắt ``key`` và ``code`` trần: ``it.get("key", "?")`` của Jira
#: (``core/jira_tool.py``) là mã issue, không phải credential. Danh sách dưới đây
#: chỉ gồm tên đã mang nghĩa bí mật.
_CREDENTIAL_FALLBACK = re.compile(
r'\.get\(\s*["\'][a-z_]*'
r'(?:pw|passwd|password|secret|token|api_key|unlock_code|access_code)'
r'[a-z_]*["\']\s*,\s*["\'][^"\']+["\']'
)
#: Quét CHÉO mọi thư mục nguồn, không chỉ tầng giao diện. Sai sót gốc của commit
#: ``3827552`` là sửa ``config.py`` mà quên bản sao trong ``ui/`` — tức là lỗi đi
#: xuyên thư mục, nên phép quét cũng phải đi xuyên thư mục.
_SCANNED = (
"ui", "presentation", "core", "infrastructure", "application", "domain",
"mcp_servers", "providers", "security", "theme", "config.py", "state.py",
)
def test_khong_con_fallback_credential_trong_ma_nguon():
"""Không file nguồn nào được đặt credential làm giá trị mặc định của ``.get()``."""
root = Path(__file__).resolve().parents[2]
offenders = []
for name in _SCANNED:
target = root / name
if target.is_file():
files = [target]
elif target.is_dir():
files = [p for p in target.rglob("*.py") if "__pycache__" not in p.parts]
else: # thư mục bị đổi tên/xoá
continue
for path in files:
for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
if _CREDENTIAL_FALLBACK.search(line):
offenders.append(
f"{path.relative_to(root).as_posix()}:{lineno}: {line.strip()}")
assert not offenders, "credential nằm trong mã nguồn:\n " + "\n ".join(offenders)
def test_phep_quet_thuc_su_nhin_thay_file():
"""Lưới an toàn cho bài trên: đổi tên thư mục làm nó quét rỗng mà vẫn xanh."""
root = Path(__file__).resolve().parents[2]
seen = sum(
1 for name in _SCANNED
for _ in ([root / name] if (root / name).is_file()
else (root / name).rglob("*.py") if (root / name).is_dir() else [])
)
assert seen > 200, f"chỉ quét được {seen} file — phạm vi quét đã hỏng"
+12 -6
View File
@@ -70,15 +70,21 @@ def dialog(qapp):
# ---- dialog gồm những mục nào -------------------------------------------
def test_co_dung_nam_muc(dialog):
"""Năm mục thật trên màn hình. Plan R08-T07 ghi bốn widget và có một cái
tên `connector`, nhưng UI connector đã dời khỏi Settings từ trước (xem
ghi chú ở settings_dialog.py:180) — nên con số thật là năm, không bốn."""
def test_co_dung_sau_muc(dialog):
"""Sáu mục thật trên màn hình.
Plan R08-T07 ghi bốn widget và có một cái tên `connector`, nhưng UI
connector đã dời khỏi Settings từ trước (xem ghi chú ở
settings_dialog.py:180) — nên con số thật là năm, không bốn.
Mục thứ sáu là "Giới thiệu", thêm ngày 2026-09-07 cùng lúc với việc gỡ dòng
ghi công tác giả khỏi góc dưới phải cửa sổ. Đây là thay đổi CÓ CHỦ Ý, không
phải tách nhầm."""
dlg, _ = dialog
labels = [dlg.section_list.item(i).text()
for i in range(dlg.section_list.count())]
assert len(labels) == 5, labels
assert dlg.section_stack.count() == 5
assert len(labels) == 6, labels
assert dlg.section_stack.count() == 6
def test_moi_muc_deu_bam_duoc(dialog):
+158
View File
@@ -0,0 +1,158 @@
"""Màn hình app mở lên lần đầu.
Trước đây `MainWindow.__init__` gọi `_goto(ROW_WORKSPACE, current_subtab())` rồi
ngay sau đó `_restore_sessions()` lại `_show_cowork_tab()` để mở lại hội thoại
lần trước. Kết quả: thứ người dùng thấy khi bật app là cuộc trò chuyện cũ, không
phải danh sách project — và dòng landing ở trên trông như đang quyết định điều đó
trong khi thực ra bị ghi đè vài chục dòng sau.
Giờ landing được chốt SAU restore: hội thoại cũ vẫn được nạp lại (đó là mục đích
của restore — phục hồi sau khi thoát đột ngột), nhưng khung nhìn đầu tiên là
"Tất cả project…".
"""
from __future__ import annotations
import pytest
pytest.importorskip("PySide6", reason="cần PySide6 để dựng cửa sổ thật")
@pytest.fixture
def window(qapp, tmp_path):
from cowork_local.presentation.shell.bootstrap import build_config, build_context
from cowork_local.presentation.shell.main_window import MainWindow
config_path = tmp_path / "config.json"
build_config(config_path)
win = MainWindow(build_context(config_path))
yield win
win.close()
def test_mo_app_len_thi_dung_o_man_workspace(window):
"""Vẫn là màn Workspace, không phải Dashboard/Lịch/Giám sát."""
assert window.pages.currentIndex() == window._ROW_WORKSPACE
def test_mo_app_len_thi_o_khung_tat_ca_project(window):
"""Đúng khung mà link "Tất cả project…" ở thanh menu mở ra.
Trừ khi cổng project đang giữ nó đóng: chưa có project nào thì sub-tab
Cowork bị ẩn, và màn hình đúng cho người dùng đó là màn quản lý project.
Ép mở bằng cửa sau sẽ để nội dung ở Cowork trong khi cổng nói là Project.
"""
ws = window.workspace
if ws.subtab_available(ws._cowork_tab_idx):
assert ws.current_subtab() == ws._cowork_tab_idx
assert ws._history_opened is True
else:
assert ws.current_subtab() == ws._project_tab_idx
def test_bang_lich_su_khong_bi_an_di(window):
"""Danh sách phải nhìn thấy được — mở đúng tab mà bảng vẫn gập là vô nghĩa.
Dùng ``isHidden()`` chứ không ``isVisible()``: test không gọi ``show()`` trên
cửa sổ (bật cửa sổ thật lên sẽ nhảy ra trước mặt người đang làm việc), mà
``isVisible()`` của widget con chỉ True khi đã có tổ tiên được hiện.
``isHidden()`` phản ánh đúng thứ ta quan tâm: có ai gọi ``setVisible(False)``
lên nó hay không.
"""
ws = window.workspace
if not ws.subtab_available(ws._cowork_tab_idx):
pytest.skip("cổng project đang đóng — bảng lịch sử không thuộc màn này")
assert ws._sidebar.isHidden() is False
# ---- vệt sáng thanh menu phải khớp nội dung ------------------------------
#
# Bug: mở app lên ở khung "Tất cả project…" thì bấm "Project" trên thanh menu
# KHÔNG có tác dụng; phải bấm sang mục khác rồi bấm về mới được.
#
# Nguyên nhân: landing gọi thẳng ``workspace.show_history_pane()``, đổi nội dung
# sang sub-tab Cowork nhưng bỏ qua ``_goto`` — chỗ duy nhất dời vệt sáng. Vệt
# sáng ở lại "Project", mà QTreeWidget không phát ``currentItemChanged`` khi bấm
# lại đúng dòng đang chọn, nên cú bấm rơi vào hư không.
def _hang_dang_chon(window):
"""(page, sub) mà thanh menu đang tô sáng, hoặc None."""
from PySide6.QtCore import Qt
for tree in (window.nav, window.nav_bottom):
item = tree.currentItem()
if item is not None:
data = item.data(0, Qt.UserRole) or {}
return data.get("page"), data.get("sub")
return None
def test_vet_sang_thanh_menu_khop_voi_noi_dung_dang_hien(window):
"""Không khớp là cú bấm đầu tiên vào đúng mục đó sẽ rơi vào hư không."""
dang_chon = _hang_dang_chon(window)
assert dang_chon is not None, "thanh menu không tô sáng dòng nào"
page, sub = dang_chon
assert page == window._ROW_WORKSPACE
# Bat bien that su quan trong: vet sang KHOP noi dung. Man hinh ha canh la
# cai nao thi tuy cong project, nhung hai thu nay khong bao gio duoc lech.
assert sub == window.workspace.current_subtab(), (
f"nội dung ở sub-tab {window.workspace.current_subtab()} "
f"nhưng thanh menu đang sáng ở {sub}")
def test_bam_project_tren_thanh_menu_an_ngay_lan_dau(window):
"""Tái hiện đúng thao tác của người dùng: mở app xong bấm ngay "Project"."""
from PySide6.QtCore import Qt
project_sub = window.workspace._project_tab_idx
muc_project = None
for i in range(window.nav.topLevelItemCount()):
it = window.nav.topLevelItem(i)
data = it.data(0, Qt.UserRole) or {}
if data.get("page") == window._ROW_WORKSPACE and data.get("sub") == project_sub:
muc_project = it
break
if muc_project is None:
pytest.skip("thanh menu chưa dựng mục con cho sub-tab Project")
if window.workspace.current_subtab() == project_sub:
pytest.skip("cổng project đang đóng — app đã hạ cánh sẵn ở màn Project")
assert window.nav.currentItem() is not muc_project, (
"mục Project đang được tô sáng sẵn dù nội dung ở Cowork — "
"bấm vào nó sẽ không phát tín hiệu nào")
window.nav.setCurrentItem(muc_project)
assert window.workspace.current_subtab() == project_sub
def test_khi_cong_project_MO_thi_ha_canh_o_cowork_va_vet_sang_theo(window):
"""Nhánh của người dùng ĐÃ có project — nhánh mà bug được báo.
Môi trường test không có project nào (cố ý: ``core/projects.py`` ghi vào
``~/.cowork_local`` thật). Mở cổng bằng tay để đi đúng nhánh đó mà không
phải tạo project trên đĩa.
"""
from PySide6.QtCore import Qt
ws = window.workspace
if ws._cowork_tab_idx < 0:
pytest.skip("bản dựng này không có sub-tab Cowork")
ws.tabs.setTabVisible(ws._cowork_tab_idx, True)
try:
window.goto_all_projects()
assert ws.current_subtab() == ws._cowork_tab_idx
assert ws._history_opened is True
item = window.nav.currentItem()
assert item is not None, "thanh menu không tô sáng dòng nào"
data = item.data(0, Qt.UserRole) or {}
assert data.get("sub") == ws._cowork_tab_idx, (
f"nội dung ở Cowork ({ws._cowork_tab_idx}) "
f"nhưng thanh menu sáng ở {data.get('sub')}")
finally:
ws.tabs.setTabVisible(ws._cowork_tab_idx, False)
window.goto_all_projects()
+167
View File
@@ -0,0 +1,167 @@
"""Sáu chỗ chỉnh nhỏ trên giao diện, mỗi bài chốt đúng một triệu chứng đã báo.
Gom một file vì chúng không chia sẻ gì ngoài việc đều là phản hồi từ người dùng
trong cùng một vòng; tách sáu file cho sáu khẳng định chỉ tạo thêm chỗ để tìm.
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parents[2]
# ---- bỏ nhãn "provider · model" cạnh chữ Cowork --------------------------
def test_thanh_cong_cu_cowork_khong_con_nhan_provider():
"""Nó lặp lại thứ bộ chọn provider ở thanh trên đang hiển thị."""
src = (REPO / "ui" / "cowork_tab.py").read_text(encoding="utf-8")
assert "model_lbl" not in src
assert "PROVIDER_LABELS" not in src, "import đã thành vô dụng thì phải gỡ"
# ---- không báo trạng thái khi đổi provider -------------------------------
def test_doi_provider_khong_bao_o_thanh_trang_thai():
"""Bộ chọn nằm ngay trên màn hình và đã hiện thứ vừa chọn."""
src = (REPO / "presentation" / "shell" / "top_bar.py").read_text(encoding="utf-8")
than_ham = src.split("def _on_provider_changed")[1].split("def _on_language_changed")[0]
assert "showMessage" not in than_ham
assert "using_provider" not in than_ham
# ---- tên project mặc định không gắn ngôn ngữ ----------------------------
def test_ten_project_mac_dinh_khong_qua_tr():
"""Tên project được GHI XUỐNG ĐĨA.
Tạo project lúc đang ở tiếng Nhật thì tên nó thành "新規プロジェクト" vĩnh
viễn, và đổi ngôn ngữ về tiếng Việt không sửa được — đó là dữ liệu, không
phải chữ giao diện. Người dùng nhìn thấy chữ Nhật trên màn tiếng Việt và
tưởng là lỗi hiển thị.
"""
from cowork_local.presentation.workspace import project_editing as pe
assert pe._DEFAULT_PROJECT_NAME.isascii(), "tên mặc định phải trung tính"
src = (REPO / "presentation" / "workspace" / "project_editing.py").read_text(encoding="utf-8")
than_ham = src.split("def _create")[1].split("def _delete")[0]
assert 'tr("workspace.default_new_name")' not in than_ham
def test_ten_mac_dinh_khong_trung_nhau(monkeypatch):
"""Bấm "Project mới" hai lần liên tiếp không được ra hai tên giống nhau."""
from cowork_local.presentation.workspace.project_editing import (
ProjectEditingMixin, _DEFAULT_PROJECT_NAME,
)
import cowork_local.core.projects as projects
class _P:
def __init__(self, pid, name):
self.project_id, self.name = pid, name
da_co = [_P("p1", _DEFAULT_PROJECT_NAME)]
monkeypatch.setattr(projects, "list_projects", lambda: da_co)
class _K:
_name_taken = ProjectEditingMixin._name_taken
assert _K()._name_taken(_DEFAULT_PROJECT_NAME) is True
assert _K()._name_taken(f"{_DEFAULT_PROJECT_NAME} (2)") is False
# ---- "Tất cả project…" phải hiện MỌI project ----------------------------
def test_tat_ca_project_xoa_bo_loc_theo_project():
"""Bảng lịch sử nhúng trong Cowork của MỘT project nên bị lọc theo project đó.
Vào bằng link "Tất cả project…" mà còn bộ lọc thì tạo 5 project chỉ thấy 1.
"""
src = (REPO / "presentation" / "shell" / "rail_project.py").read_text(encoding="utf-8")
than_ham = src.split("def goto_all_projects")[1].split("def _on_rail_recent")[0]
assert 'set_project_filter("")' in than_ham
# ---- ba nút quản lý project về cùng một hàng ----------------------------
def test_sua_va_luu_project_cung_hang_voi_project_moi(qapp, tmp_path):
"""Trước đó "Lưu project" nằm dưới cùng khung bên phải, cách "Project mới"
gần hết chiều cao màn hình."""
from cowork_local.presentation.shell.bootstrap import build_config, build_context
from cowork_local.presentation.shell.main_window import MainWindow
from cowork_local.presentation.workspace.project_editing import _row_layout_of
config_path = tmp_path / "config.json"
build_config(config_path)
win = MainWindow(build_context(config_path))
try:
ws = win.workspace
hang = _row_layout_of(ws._new_btn)
assert hang is not None, "không tìm được hàng chứa nút Project mới"
assert _row_layout_of(ws._edit_btn) is hang
assert _row_layout_of(ws._save_btn) is hang
# Thu tu doc tu trai sang: tao moi -> sua -> luu
assert (hang.indexOf(ws._new_btn)
< hang.indexOf(ws._edit_btn)
< hang.indexOf(ws._save_btn))
finally:
win.close()
def test_hai_nut_project_khong_hien_ngoai_sub_tab_project(qapp, tmp_path):
"""Hàng tiêu đề vắt ngang CẢ màn Workspace.
Chuyển "Sửa project" + "Lưu project" lên đó (bug 10) làm chúng hiện luôn ở
Cowork, Co4E, Thư mục và GraphRAG — nơi không có biểu mẫu project nào để sửa
hay lưu. Đúng luật mà ``_new_btn`` đã theo từ trước.
"""
from cowork_local.presentation.shell.bootstrap import build_config, build_context
from cowork_local.presentation.shell.main_window import MainWindow
config_path = tmp_path / "config.json"
build_config(config_path)
win = MainWindow(build_context(config_path))
try:
ws = win.workspace
ws._current_id = "gia-lap"
ws.tabs.setCurrentIndex(ws._project_tab_idx)
ws._sync_project_buttons()
assert ws._edit_btn.isHidden() is False
assert ws._save_btn.isHidden() is False
if ws._cowork_tab_idx < 0:
pytest.skip("bản dựng này không có sub-tab Cowork")
ws.tabs.setTabVisible(ws._cowork_tab_idx, True)
ws.tabs.setCurrentIndex(ws._cowork_tab_idx)
ws._sync_project_buttons()
assert ws._edit_btn.isHidden() is True, "nút Sửa project lọt sang tab Cowork"
assert ws._save_btn.isHidden() is True, "nút Lưu project lọt sang tab Cowork"
finally:
win.close()
def test_chua_chon_project_thi_hai_nut_cung_an(qapp, tmp_path):
"""Không có project nào đang mở thì cả Sửa lẫn Lưu đều vô nghĩa."""
from cowork_local.presentation.shell.bootstrap import build_config, build_context
from cowork_local.presentation.shell.main_window import MainWindow
config_path = tmp_path / "config.json"
build_config(config_path)
win = MainWindow(build_context(config_path))
try:
ws = win.workspace
ws._current_id = ""
ws.tabs.setCurrentIndex(ws._project_tab_idx)
ws._sync_project_buttons()
assert ws._edit_btn.isHidden() is True
assert ws._save_btn.isHidden() is True
finally:
win.close()