Files
cowork-local/tests/ui/test_project_editing.py
T
69ffe8954a feat(ui): màn quản lý project — chế độ chỉ-xem, chặn trùng tên, menu chuột phải
Bốn thay đổi trên cùng một khối, gom vào presentation/workspace/project_editing.py:

- Project đã có mở ra ở dạng CHỈ ĐỌC; phải bấm "Sửa project" (nút vàng) mới gõ
  được, và nút Lưu chuyển xanh lá. Trước đây form luôn mở nên chỉ cần lỡ tay là
  sửa mất nội dung của một project đang dùng mà không có gì cảnh báo.
- Chặn TRÙNG TÊN khi tạo và khi lưu (không phân biệt hoa thường, bỏ khoảng
  trắng hai đầu). Danh sách bên trái chỉ hiện tên, nên trùng tên là không phân
  biệt được nữa. Chặn TRƯỚC khi ghi đĩa.
- Chuột phải một project: Mở / Sửa / Xoá. Trước đây bấm phải không làm 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. ProjectRow giờ giữ SỐ và tự
  format lại — KHÔNG gọi refresh(), vì refresh() kết thúc bằng _load_current()
  và sẽ xoá mất nội dung người dùng đang gõ dở.

Ba nút về chung một hàng với "Project mới", và cùng theo một luật hiện/ẩn: chỉ
hiện trên sub-tab Project và khi có project đang mở. Hàng tiêu đề vắt ngang cả
màn Workspace nên thứ gì đặt lên đó cũng lọt sang Cowork/Co4E/Thư mục/GraphRAG
nếu không tự ẩn.

Màu vàng/xanh lá đi qua token warning/success. Hai nút dùng nền *_soft với chữ
và viền là token, KHÔNG phải nền đặc: hai token đó được chỉnh làm màu CHỮ (cùng
lý do accent và accent_solid là hai token riêng) — chữ trắng trên warning dark
#CCA700 chỉ đạt ~2.3:1, trượt AA.

ui/workspace_tab.py đang ở đúng trần bánh cóc nên phần mã mới bắt buộc ra module
riêng; chuyển _ProjectRow và CRUD sang đó làm file co từ 566 xuống 523 dòng mã,
và bánh cóc được siết theo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 01:34:36 +09:00

241 lines
8.6 KiB
Python

"""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