fix các bug theo yêu cầu https://fptsoftware362-my.sharepoint.com/❌/g/personal/nampdt_fpt_com/IQAHBJ4A9xqDTLgvt2bhukJEAdRB5LRz2hbJpTivvIiBSYM?wdExp=TEAMS-TREATMENT&web=1&isSPOFile=1&ovuser=f01e930a-b52e-42b1-b70f-a8882b5d043b%2CAnhTNM1%40fpt.com&clickparams=eyJBcHBOYW1lIjoiVGVhbXMtRGVza3RvcCIsIkFwcFZlcnNpb24iOiI0OS8yNjA4MTMxOTMxNyIsIkhhc0ZlZGVyYXRlZFVzZXIiOmZhbHNlfQ%3D%3D --------- Co-authored-by: Duy Le Huu <duylh19@fpt.com> Reviewed-on: #10 Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com>
This commit was merged in pull request #10.
This commit is contained in:
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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ũ")
|
||||
@@ -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ũ")
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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"
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user