## Summary Nhánh `feature/perf-ui-logic`: tối ưu hiệu năng/UI, sửa lỗi workspace và điều hướng, và làm cho công tắc **"Block network for agent-run commands"** chặn thật mọi đường ra mạng của app, **trừ nhà cung cấp AI**. **Chặn mạng (b78d483,8c497cf,10b8379)** - Bộ kiểm tra chung `application/network/network_guard.py`, nối vào cấu hình đang chạy ở Composition Root: đổi công tắc trong Settings là có hiệu lực ngay. - Lệnh shell của agent và task script chạy trong **Windows AppContainer không có quyền mạng**: kernel chặn socket, ping, DNS, Invoke-WebRequest… Không cần quyền admin. Không cô lập được thì lệnh bị từ chối, không chạy khi mạng còn mở. macOS dùng `sandbox-exec`, Linux dùng `unshare --net`. - Bật chặn thì: dừng MCP đang chạy, không khởi động server mới, từ chối lời gọi connector; Microsoft 365 (đăng nhập, Graph, đồng bộ cloud, rules, mail), Teams, nút Test REST/Jira/MCP, link đính kèm task, pip tự cài và tài nguyên web trong xem trước HTML đều bị từ chối. - Vẫn dùng được: chat, tải danh sách model, thử model; tool OneDrive đã đồng bộ trên máy. - Công tắc **mặc định tắt** khi mở app lần đầu; nhãn giữ nguyên như cũ. - Xem trước HTML trong tab Folder giờ hiện được ảnh/CSS/JS từ web khi mạng mở (trước đây trang `file://` không tải được). - Sửa lỗi app văng khi chuyển tab Graph → Folder: profile WebEngine của trang xem trước bị huỷ trước trang (`0xc0000409` trong Qt6Core.dll); giờ dùng một profile chung thuộc QApplication. - Không cấp quyền AppContainer kế thừa lên thư mục chứa PySide6 (nếu có, Chromium không nạp được `Qt6WebEngineCore.dll` và tab Graph trắng). - Cột mục lục trong Settings tính độ rộng theo kiểu chữ của mục đang chọn, "Sandbox Security Layer" không còn bị cắt. **Các commit khác trong nhánh** - `b7a41b3` mỗi thư mục làm việc chỉ thuộc về một project · `bbdf146` bật nút Sửa project khi đã có project đang mở - `35f24e0`, `cc8d5c8`, `2e3e719`, `c699beb` canh hàng / khoảng cách thanh điều hướng - `2759ed9` không refresh workspace khi chuyển tab Cowork · `7607f44` checkpoint hiệu năng và UI - `8548c1e` chặn tool mạng của agent · `caf3b74` renderer GraphRAG native trên macOS · `c00b83c` khoảng cách metadata hàng project · `a04f8a9` ẩn picker workspace cloud ## Change Type - [x] Cowork feature - [x] Bug fix - [ ] Core AI contribution - [x] Test / hardening - [x] Performance - [ ] Documentation ## Related Work Cowork Task: Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets Core AI Issue: Core Task: Related PR: ## Scope What is intentionally included? - Mọi đường ra mạng do app tự mở, trừ nhà cung cấp AI (xem Summary). - Test: `tests/test_network_guard_lanes.py` (có bài chạy AppContainer thật trên Windows), `tests/ui/test_html_preview_remote_images.py`. What is intentionally NOT included? - Chặn cả nhà cung cấp AI / chạy model trên máy (Phương án 2). - Terminal người dùng tự gõ trong tab Folder, sinh ảnh, cơ chế tự tin chứng chỉ lạ (`tls_trust`). - Huy hiệu trạng thái "đang chặn" trên thanh trên cùng. ## Validation - [x] Unit tests - [x] Integration tests - [x] Manual verification - [x] Regression check Commands / evidence: - `python -m pytest tests/test_network_guard_lanes.py tests/test_sandbox_block_network.py tests/ui -q` → chỉ còn 1 bài fail, fail cả trên `b7a41b3` (nhãn `ProjectRow` 'Project' chưa dịch, `tests/ui/test_i18n_khong_con_chu_cu.py`). - `python -m pytest tests -q --ignore=tests/ui` → 4 bài fail, cả 4 cũng fail trên `b7a41b3` (`test_canonical_audit_logger`, 2 bài `test_mcp_audit_security`, `test_monitoring_tab_container`). - Chạy cả `tests` trong một lượt thì treo ở các test dựng MainWindow trong `tests/ui`; `b7a41b3` cũng treo đúng chỗ đó. - `check_imports.py` và `check_orphan_modules.py` PASS. `check_loc.py` báo 9 file quá dài, giống hệt trước khi sửa (không file nào do nhánh này làm dài thêm). - Kiểm tra tay trên Windows 11: trong AppContainer, Python báo `WinError 10013`, ping/nslookup/PowerShell/curl đều không ra được mạng; cmd, git, python chạy bình thường. - Kiểm tra tay trên Windows 11: xem trước HTML tải được 4/4 tài nguyên web khi mạng mở, 0/4 khi bật chặn; tab Graph hoạt động; tạo/huỷ trang xem trước nhiều lần không còn cảnh báo profile của Qt. ## Security Impact Permission / credential / network / customer data impact: - Network: khi bật công tắc, chỉ nhà cung cấp AI còn ra mạng; nội dung chat vẫn gửi tới nhà cung cấp AI. - Permission: lần đầu chạy lệnh trong sandbox, app **thêm quyền (ACE) cho SID AppContainer** trên thư mục làm việc (ghi), thư mục cài Python gốc (đọc), gốc venv và `Scripts` (đọc). Không xoá quyền nào. Thư mục chứa PySide6 không bao giờ nhận quyền kế thừa; một quyền kế thừa sai trên venv (từ bản dev trước) được tự gỡ. - Credential: không đổi. Khi chặn, trạng thái đăng nhập M365 được đọc thẳng từ kho token trên máy, không dựng MSAL. ## Compatibility - [x] No breaking change - [ ] Breaking change documented Ghi chú: `block_network` mặc định đổi từ bật sang tắt cho cấu hình mới; máy đã lưu `true` thì giữ nguyên. Khi đang chặn, lệnh dùng công cụ cài trong thư mục người dùng (ngoài Program Files) có thể báo Access denied; thư viện trong venv của app không dùng được trong sandbox. ## Reviewer Notes - `infrastructure/sandbox/appcontainer_process.py` gọi Win32 bằng ctypes (CreateAppContainerProfile, CreateProcessW với SECURITY_CAPABILITIES) và dùng `icacls` để cấp quyền: nên xem kỹ phần cấp quyền. - `tests/conftest.py` thêm fixture autouse gỡ `network_guard` sau mỗi test, vì `build_context()` gắn cổng này ở mức process. - `core/task_executors.py` đang đúng bằng trần LOC nên `_run_script` được tách sang `core/task_script.py`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: minhanhpkpro <minhanhpkpro@gmail.com> Co-authored-by: Duy Le Huu <duylh19@fpt.com> Co-authored-by: thanhnv <thanhnv.ip@gmail.com> Reviewed-on: #13
This commit was merged in pull request #13.
This commit is contained in:
@@ -73,3 +73,17 @@ def _bind_checkout_as_package() -> None:
|
||||
|
||||
|
||||
_bind_checkout_as_package()
|
||||
|
||||
|
||||
import pytest # noqa: E402 - after the package binding above
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_network_guard():
|
||||
"""``build_context()`` binds the process-wide "Block network" gate to that
|
||||
context's config (default: blocked). Unbind after every test so one test
|
||||
that built a real context cannot silently block the network for the rest."""
|
||||
yield
|
||||
from cowork_local.application.network import network_guard
|
||||
|
||||
network_guard.bind(None)
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
"""Công tắc "Chặn mạng" phải chặn mọi đường ra mạng của app, TRỪ nhà cung cấp AI.
|
||||
|
||||
Bản đồ ``infura/network-map.html`` liệt kê các làn trước đây "không kiểm soát"
|
||||
hoặc chỉ chặn một phần: M365, Teams và nút Test, connector/MCP đang chạy,
|
||||
task script và link đính kèm task, tự cài thư viện, xem trước HTML, lệnh shell
|
||||
chỉ bị proxy giả. Mỗi làn có ít nhất một bài ở đây:
|
||||
|
||||
* **bật** — làn từ chối TRƯỚC khi chạm mạng (mọi lời gọi HTTP thật đều nổ);
|
||||
* **tắt** — đường cũ giữ nguyên, vì chặn một chiều là hỏng tính năng.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.application.network import network_guard
|
||||
|
||||
|
||||
def _no_network(*args, **kwargs):
|
||||
raise AssertionError("đã chạm mạng dù 'Chặn mạng' đang bật")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def chan_mang(monkeypatch):
|
||||
"""Bật công tắc qua cổng chung và làm nổ mọi lời gọi HTTP thật."""
|
||||
import requests
|
||||
|
||||
from cowork_local.core import tls_trust
|
||||
|
||||
for name in ("request", "get", "post", "put", "patch", "delete"):
|
||||
monkeypatch.setattr(requests, name, _no_network)
|
||||
monkeypatch.setattr(tls_trust, "request", _no_network)
|
||||
monkeypatch.setattr(tls_trust, "request_any_method", _no_network)
|
||||
network_guard.bind(lambda: True)
|
||||
yield
|
||||
network_guard.bind(None)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _go_cong_sau_moi_bai():
|
||||
yield
|
||||
network_guard.bind(None)
|
||||
|
||||
|
||||
# ---- cổng chung ------------------------------------------------------------
|
||||
|
||||
def test_chua_noi_day_thi_khong_chan():
|
||||
network_guard.bind(None)
|
||||
assert network_guard.is_blocked() is False
|
||||
network_guard.ensure_allowed("x")
|
||||
|
||||
|
||||
def test_doc_cong_tac_loi_thi_coi_nhu_dang_chan():
|
||||
"""Không đọc được công tắc thì đóng cửa, không mở toang."""
|
||||
def hong():
|
||||
raise KeyError("agent_security")
|
||||
|
||||
network_guard.bind(hong)
|
||||
with pytest.raises(network_guard.NetworkBlockedError):
|
||||
network_guard.ensure_allowed("Teams")
|
||||
|
||||
|
||||
def test_cong_doc_cau_hinh_song_khong_chup_lai(tmp_path):
|
||||
"""Đổi công tắc trong Settings có hiệu lực ngay, không cần mở lại app."""
|
||||
from cowork_local.presentation.shell.bootstrap import build_context
|
||||
|
||||
ctx = build_context(tmp_path / "config.json")
|
||||
ctx.config.agent_security["block_network"] = True
|
||||
assert network_guard.is_blocked() is True
|
||||
ctx.config.agent_security["block_network"] = False
|
||||
assert network_guard.is_blocked() is False
|
||||
|
||||
|
||||
# ---- Microsoft 365 -----------------------------------------------------------
|
||||
|
||||
def test_graph_tu_choi_bang_loi_ma_noi_goi_da_bat(chan_mang):
|
||||
from cowork_local.core import ms365_graph
|
||||
|
||||
with pytest.raises(ms365_graph.Ms365GraphError, match="Sandbox Security Layer"):
|
||||
ms365_graph.list_onedrive_files("token")
|
||||
|
||||
|
||||
def test_dang_nhap_va_lay_token_tu_choi(chan_mang, monkeypatch):
|
||||
from cowork_local.core import ms365_auth
|
||||
|
||||
monkeypatch.setattr(ms365_auth, "_app", _no_network)
|
||||
with pytest.raises(ms365_auth.Ms365AuthError, match="blocked"):
|
||||
ms365_auth.get_access_token("", "")
|
||||
with pytest.raises(ms365_auth.Ms365AuthError, match="blocked"):
|
||||
ms365_auth.sign_in_device_code("", "", lambda flow: None)
|
||||
|
||||
|
||||
def test_kiem_tra_da_dang_nhap_khong_dung_msal_khi_chan(chan_mang, monkeypatch):
|
||||
"""Dựng app MSAL là tải cấu hình OpenID của tenant — phải đọc kho token trực tiếp."""
|
||||
from cowork_local.core import ms365_auth
|
||||
|
||||
monkeypatch.setattr(ms365_auth, "_app", _no_network)
|
||||
monkeypatch.setattr(ms365_auth, "_cached_account_offline",
|
||||
lambda: {"username": "a@b.c"})
|
||||
assert ms365_auth.signed_in_account("", "") == {"username": "a@b.c"}
|
||||
|
||||
|
||||
def test_mail_canh_bao_khong_gui_khi_chan(chan_mang, monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
|
||||
from cowork_local.core import agent_security_alert, ms365_auth
|
||||
from cowork_local.core.agent_security_types import SecurityVerdict
|
||||
|
||||
monkeypatch.setattr(ms365_auth, "_app", _no_network)
|
||||
config = SimpleNamespace(data={"agent_security": {"admin_email": "admin@x.y"}}, ms365={})
|
||||
verdict = SecurityVerdict(allowed=False, layer="command", reason="test")
|
||||
sent, note = agent_security_alert.notify_admin(config, verdict)
|
||||
assert sent is False
|
||||
assert "blocked" in note
|
||||
|
||||
|
||||
# ---- Teams, connector REST, Jira, nút Test ---------------------------------
|
||||
|
||||
def test_teams_tu_choi(chan_mang):
|
||||
from cowork_local.core.teams import TeamsNotifier
|
||||
|
||||
ok, detail = TeamsNotifier("https://x.webhook.office.com/hook").send("t", "b")
|
||||
assert ok is False
|
||||
assert "blocked" in detail
|
||||
|
||||
|
||||
def test_connector_rest_va_nut_test_tu_choi(chan_mang):
|
||||
from cowork_local.core.ext_connectors import RestApiConnector
|
||||
|
||||
rc = RestApiConnector({"id": "erp", "name": "ERP", "base_url": "https://erp.example"})
|
||||
assert rc.call({"method": "GET", "path": "items"})["ok"] is False
|
||||
ok, message = rc.test_connection()
|
||||
assert ok is False and "blocked" in message
|
||||
|
||||
|
||||
def test_nut_test_jira_tu_choi(chan_mang):
|
||||
from cowork_local.core import jira_tool
|
||||
|
||||
cfg = {"base_url": "https://jira.example", "email": "a@b.c", "api_token": "t"}
|
||||
out = jira_tool.search(cfg, "order by created DESC", 1)
|
||||
assert out.startswith("Jira search failed") and "blocked" in out
|
||||
|
||||
|
||||
# ---- MCP -------------------------------------------------------------------
|
||||
|
||||
def test_mcp_khong_khoi_dong_va_khong_goi_khi_chan(chan_mang):
|
||||
from cowork_local.core.mcp_client import McpServerConnection, McpServerError
|
||||
|
||||
conn = McpServerConnection("srv", "definitely-not-run")
|
||||
with pytest.raises(McpServerError, match="blocked"):
|
||||
conn.start(timeout=1)
|
||||
assert conn.call_tool("srv__search", {})["ok"] is False
|
||||
|
||||
|
||||
def _context(tmp_path, block: bool):
|
||||
from cowork_local.config import AppConfig
|
||||
from cowork_local.state import AppContext
|
||||
|
||||
ctx = AppContext(AppConfig.load(tmp_path / "config.json"))
|
||||
ctx.config.agent_security["block_network"] = block
|
||||
return ctx
|
||||
|
||||
|
||||
def test_chuan_bi_connector_dung_mcp_dang_chay_va_giu_onedrive_cuc_bo(tmp_path, monkeypatch):
|
||||
from cowork_local.core import ms365_local
|
||||
|
||||
ctx = _context(tmp_path, block=True)
|
||||
stopped = []
|
||||
monkeypatch.setattr(ctx, "stop_mcp_connections", lambda: stopped.append(True))
|
||||
local = (["ms365_local__onedrive_list"], object())
|
||||
monkeypatch.setattr(ms365_local, "build_ms365_local_tools", lambda config: local)
|
||||
monkeypatch.setattr(ctx._mcp_manager, "ensure", _no_network)
|
||||
|
||||
assert ctx.build_mcp_tools() == local
|
||||
assert stopped == [True]
|
||||
|
||||
|
||||
# ---- task lập lịch -----------------------------------------------------------
|
||||
|
||||
def test_link_dinh_kem_task_khong_tai(chan_mang):
|
||||
from cowork_local.core.tasks import resolve_input_text
|
||||
|
||||
text = resolve_input_text({"input": {"mode": "empty", "links": ["https://example.com/a"]}})
|
||||
assert "not fetched" in text
|
||||
|
||||
|
||||
def test_task_script_chay_trong_tien_trinh_khong_mang(chan_mang, monkeypatch, tmp_path):
|
||||
from cowork_local.core import deps, task_script
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_run(command, **kwargs):
|
||||
calls.append(kwargs)
|
||||
return 0, "done", False, False, False
|
||||
|
||||
monkeypatch.setattr(deps, "run_cancellable", fake_run)
|
||||
assert task_script.run_script("echo hi", tmp_path, 30) == "done"
|
||||
assert calls and calls[0]["isolate_network"] is True
|
||||
|
||||
|
||||
def test_task_script_khong_co_lap_duoc_thi_khong_chay(chan_mang, monkeypatch, tmp_path):
|
||||
from cowork_local.core import deps, task_script
|
||||
|
||||
monkeypatch.setattr(deps, "run_cancellable",
|
||||
lambda command, **kw: (None, "no AppContainer", False, False, False))
|
||||
with pytest.raises(RuntimeError, match="could not be isolated"):
|
||||
task_script.run_script("echo hi", tmp_path, 30)
|
||||
|
||||
|
||||
def test_tat_chan_mang_thi_task_script_chay_nhu_cu(tmp_path):
|
||||
from cowork_local.core import task_script
|
||||
|
||||
assert "hi" in task_script.run_script("echo hi", tmp_path, 30)
|
||||
|
||||
|
||||
# ---- tự cài thư viện ---------------------------------------------------------
|
||||
|
||||
def test_khong_tu_cai_thu_vien_khi_chan(chan_mang, monkeypatch):
|
||||
from cowork_local.core import deps
|
||||
|
||||
monkeypatch.setattr(deps, "run_cancellable", _no_network)
|
||||
assert deps.ensure_module("khong_ton_tai_xyz_123") is None
|
||||
assert "khong_ton_tai_xyz_123" not in deps._FAILED # thử lại khi mở mạng
|
||||
ok, detail = deps.pip_install("requests")
|
||||
assert ok is False and "blocked" in detail
|
||||
|
||||
|
||||
def test_pptx_khong_bi_nho_la_thieu_khi_chi_do_chan_mang(chan_mang, monkeypatch):
|
||||
from cowork_local.application.workspaces import file_preview_helpers as fph
|
||||
from cowork_local.core import deps
|
||||
|
||||
monkeypatch.setattr(fph, "_PPTX_READY", None)
|
||||
monkeypatch.setattr(deps, "ensure_module", lambda *a, **k: None)
|
||||
assert fph.pptx_available() is False
|
||||
assert fph._PPTX_READY is None
|
||||
|
||||
|
||||
# ---- lệnh shell của agent ----------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("backend", ["direct", "integrity_job_wfp", "appcontainer", "windows_sandbox"])
|
||||
def test_moi_backend_deu_chay_co_lap_mang(monkeypatch, tmp_path, backend):
|
||||
"""Các backend cũ chỉ đặt biến proxy — khi chặn mạng, tất cả đi qua lớp cô lập của OS."""
|
||||
from cowork_local.core import deps
|
||||
from cowork_local.core.sandbox_manager import SandboxManager
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_run(command, **kwargs):
|
||||
calls.append(kwargs)
|
||||
return 0, "ok", False, False, False
|
||||
|
||||
monkeypatch.setattr(deps, "run_cancellable", fake_run)
|
||||
result = SandboxManager()._execute_with_backend(backend, "echo hi", str(tmp_path), True, 30)
|
||||
assert calls and calls[0]["isolate_network"] is True
|
||||
assert result["ok"] is True and result["sandbox"] == "network_isolated"
|
||||
|
||||
|
||||
def test_lenh_khong_co_lap_duoc_thi_bi_tu_choi(monkeypatch, tmp_path):
|
||||
from cowork_local.core import deps
|
||||
from cowork_local.core.sandbox_manager import SandboxManager
|
||||
|
||||
monkeypatch.setattr(deps, "run_cancellable",
|
||||
lambda command, **kw: (None, "no isolation", False, False, False))
|
||||
result = SandboxManager()._execute_with_backend("direct", "echo hi", str(tmp_path), True, 30)
|
||||
assert result["ok"] is False and result["sandbox"] == "blocked"
|
||||
|
||||
|
||||
def test_he_dieu_hanh_khong_ho_tro_thi_bao_loi_khong_chay(monkeypatch):
|
||||
from cowork_local.infrastructure.sandbox import network_isolation
|
||||
|
||||
monkeypatch.setattr(network_isolation.sys, "platform", "sunos5")
|
||||
with pytest.raises(network_isolation.NetworkIsolationUnavailable):
|
||||
network_isolation.spawn_without_network("echo hi", None, None)
|
||||
|
||||
|
||||
def _listening_socket():
|
||||
server = socket.socket()
|
||||
server.bind(("127.0.0.1", 0))
|
||||
server.listen(4)
|
||||
def serve():
|
||||
try:
|
||||
for _ in range(4):
|
||||
server.accept()
|
||||
except OSError:
|
||||
pass # socket closed at the end of the test
|
||||
|
||||
threading.Thread(target=serve, daemon=True).start()
|
||||
return server
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "win32", reason="AppContainer chỉ có trên Windows")
|
||||
def test_appcontainer_that_su_cat_mang_nhung_van_ghi_duoc_thu_muc(tmp_path):
|
||||
"""Bài thật, không giả lập: cùng một lệnh Python nối tới một cổng đang nghe,
|
||||
ngoài sandbox thì được, trong sandbox thì bị kernel từ chối."""
|
||||
from cowork_local.core.deps import run_cancellable
|
||||
|
||||
server = _listening_socket()
|
||||
port = server.getsockname()[1]
|
||||
probe = (f'"{sys.executable}" -c "import socket;'
|
||||
"print('PYTHON'+'_RAN');"
|
||||
f"socket.create_connection(('127.0.0.1',{port}),5);print('CONN'+'ECTED')\"")
|
||||
try:
|
||||
rc, out, *_ = run_cancellable(probe, cwd=str(tmp_path), timeout=60, shell=True)
|
||||
assert rc == 0 and "CONNECTED" in out
|
||||
|
||||
rc, out, *_ = run_cancellable(probe + " & echo written> marker.txt",
|
||||
cwd=str(tmp_path), timeout=60, isolate_network=True)
|
||||
assert "PYTHON_RAN" in out, out # Python itself starts in the sandbox
|
||||
assert "CONNECTED" not in out, out
|
||||
assert (Path(tmp_path) / "marker.txt").read_text().strip() == "written"
|
||||
finally:
|
||||
server.close()
|
||||
|
||||
|
||||
# ---- xem trước HTML ------------------------------------------------------------
|
||||
|
||||
class _FakeUrl:
|
||||
def __init__(self, scheme):
|
||||
self._scheme = scheme
|
||||
|
||||
def scheme(self):
|
||||
return self._scheme
|
||||
|
||||
|
||||
class _FakeRequest:
|
||||
def __init__(self, scheme):
|
||||
self._url = _FakeUrl(scheme)
|
||||
self.blocked = False
|
||||
|
||||
def requestUrl(self): # noqa: N802 - Qt name
|
||||
return self._url
|
||||
|
||||
def block(self, flag):
|
||||
self.blocked = flag
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scheme,expected", [
|
||||
("https", True), ("http", True), ("wss", True), ("file", False), ("data", False),
|
||||
])
|
||||
def test_xem_truoc_html_chan_tai_nguyen_web(chan_mang, scheme, expected):
|
||||
pytest.importorskip("PySide6.QtWebEngineCore")
|
||||
from cowork_local.presentation.folder.offline_web_page import RemoteRequestBlocker
|
||||
|
||||
req = _FakeRequest(scheme)
|
||||
RemoteRequestBlocker().interceptRequest(req)
|
||||
assert req.blocked is expected
|
||||
|
||||
|
||||
def test_xem_truoc_html_tai_binh_thuong_khi_tat():
|
||||
pytest.importorskip("PySide6.QtWebEngineCore")
|
||||
from cowork_local.presentation.folder.offline_web_page import RemoteRequestBlocker
|
||||
|
||||
req = _FakeRequest("https")
|
||||
RemoteRequestBlocker().interceptRequest(req)
|
||||
assert req.blocked is False
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "win32", reason="AppContainer chỉ có trên Windows")
|
||||
def test_khong_cap_quyen_ke_thua_len_thu_muc_chua_qt(tmp_path, monkeypatch):
|
||||
"""Quyền AppContainer kế thừa xuống Qt6WebEngineCore.dll làm tiến trình render
|
||||
của WebEngine không nạp được DLL — tab Graph và xem trước HTML trắng trơn."""
|
||||
from cowork_local.infrastructure.sandbox import appcontainer_process as ac
|
||||
|
||||
qt_dir = tmp_path / "venv" / "Lib" / "site-packages" / "PySide6"
|
||||
qt_dir.mkdir(parents=True)
|
||||
monkeypatch.setattr(ac, "_qt_package_dir", lambda: str(qt_dir))
|
||||
monkeypatch.setattr(ac, "_icacls", _no_network)
|
||||
|
||||
with pytest.raises(ac.NetworkIsolationUnavailable, match="Qt runtime"):
|
||||
ac.grant_access(str(tmp_path / "venv"), "S-1-15-2-1", "read")
|
||||
with pytest.raises(ac.NetworkIsolationUnavailable, match="Qt runtime"):
|
||||
ac.grant_access(str(tmp_path), "S-1-15-2-1", "write")
|
||||
|
||||
|
||||
def test_quyen_cho_python_khong_bao_gio_phu_len_thu_vien_qt(monkeypatch, tmp_path):
|
||||
from cowork_local.infrastructure.sandbox import appcontainer_process as ac
|
||||
|
||||
venv, base = tmp_path / "venv", tmp_path / "base"
|
||||
monkeypatch.setattr(ac.sys, "prefix", str(venv))
|
||||
monkeypatch.setattr(ac.sys, "base_prefix", str(base))
|
||||
for qt_dir in (venv / "Lib" / "site-packages" / "PySide6",
|
||||
base / "Lib" / "site-packages" / "PySide6"):
|
||||
monkeypatch.setattr(ac, "_qt_package_dir", lambda d=qt_dir: str(d))
|
||||
for folder, mode in ac._interpreter_grants():
|
||||
assert mode == "read_here" or not ac._covers(folder, str(qt_dir)), (folder, mode)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Xem trước HTML trong tab Folder phải hiện được ảnh lấy từ web khi mạng mở.
|
||||
|
||||
Tệp được nạp với base URL ``file://``; Qt mặc định cấm trang cục bộ tải bất kỳ
|
||||
tài nguyên web nào nếu ``LocalContentCanAccessRemoteUrls`` tắt, nên ảnh
|
||||
``<img src="https://...">`` không bao giờ hiện, kể cả khi đã mở Internet.
|
||||
Việc chặn khi bật "Chặn mạng" là của bộ chặn request, không phải của cờ này.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6.QtWebEngineWidgets", reason="cần Qt WebEngine")
|
||||
|
||||
from cowork_local.presentation.shared import HAS_WEB_ENGINE # noqa: E402
|
||||
|
||||
pytestmark = pytest.mark.skipif(not HAS_WEB_ENGINE, reason="WebEngine không dùng được ở đây")
|
||||
|
||||
|
||||
def test_trang_xem_truoc_cho_phep_tai_anh_tu_web(qapp):
|
||||
from PySide6.QtWebEngineCore import QWebEngineSettings
|
||||
from PySide6.QtWebEngineWidgets import QWebEngineView
|
||||
|
||||
from cowork_local.presentation.folder import offline_web_page as owp
|
||||
|
||||
view = QWebEngineView()
|
||||
try:
|
||||
owp.install_offline_page(view)
|
||||
profile = view.page().profile()
|
||||
assert profile is owp.preview_profile()
|
||||
assert profile.settings().testAttribute(
|
||||
QWebEngineSettings.WebAttribute.LocalContentCanAccessRemoteUrls)
|
||||
assert isinstance(owp._blocker, owp.RemoteRequestBlocker)
|
||||
finally:
|
||||
view.deleteLater()
|
||||
|
||||
|
||||
def test_profile_song_lau_hon_moi_trang(qapp):
|
||||
"""Profile do view sở hữu bị huỷ TRƯỚC trang: Qt cảnh báo "Release of
|
||||
profile requested but WebEnginePage still not deleted" rồi app có thể văng
|
||||
(0xc0000409 trong Qt6Core.dll) khi chuyển tab. Profile phải thuộc về app."""
|
||||
from PySide6.QtWebEngineWidgets import QWebEngineView
|
||||
|
||||
from cowork_local.presentation.folder import offline_web_page as owp
|
||||
|
||||
view = QWebEngineView()
|
||||
try:
|
||||
owp.install_offline_page(view)
|
||||
assert view.page().parent() is view
|
||||
assert owp.preview_profile().parent() is qapp
|
||||
finally:
|
||||
view.deleteLater()
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Ba hàng cuối thanh rail phải thẳng hàng: Dashboard, Giám sát, Cài đặt.
|
||||
|
||||
Dashboard và Giám sát là hàng của ``QTreeWidget`` (``#navrailBottom``), còn Cài
|
||||
đặt là một ``QPushButton`` tự dựng lấy icon + chữ trong ``top_bar.py``. Hai cách
|
||||
vẽ khác nhau nên không có gì tự giữ cho chúng thẳng hàng — phải chốt bằng test.
|
||||
|
||||
Lần lệch gần nhất: ``srow.setSpacing(_NAV_ROW_GAP + 4)`` (=10) cộng với 10px lề
|
||||
trái và hộp icon 22px đặt chữ "Cài đặt" ở x=42, trong khi delegate của cây đặt
|
||||
chữ ở x=35 — thụt phải 7px, thấy rõ bằng mắt trên thanh rail.
|
||||
|
||||
Cách đo: render thanh rail ra ảnh rồi tìm cột mực đầu tiên, vì đó đúng là thứ
|
||||
người dùng nhìn thấy. Trước khi đo, ba hàng được ép về **cùng một icon và cùng
|
||||
một chữ** — chữ khác nhau thì phần nhô trái của glyph đầu tiên ("D" so với "G"
|
||||
so với "C") đã lệch nhau vài pixel, và bài test sẽ đo hình dáng chữ chứ không
|
||||
đo bố cục.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6", reason="cần PySide6 để dựng cửa sổ thật")
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
#: Bỏ qua cột mực nằm sát mép trái: hàng đang được chọn có thêm vạch
|
||||
#: ``border-left: 2px solid $accent`` (theme/qss.py), không phải icon.
|
||||
_BO_QUA_MEP_TRAI = 5
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def window(qapp, tmp_path_factory):
|
||||
"""Cửa sổ thật, có nạp stylesheet đúng như ``app.py`` làm.
|
||||
|
||||
Không nạp thì ``QTreeWidget::item { padding: 6px 10px }`` không áp, hàng của
|
||||
cây thụt về 0 còn nút Cài đặt vẫn giữ lề 10px của layout — bài test sẽ đỏ vì
|
||||
thiếu theme chứ không vì lỗi bố cục.
|
||||
"""
|
||||
from cowork_local.presentation.shell.bootstrap import build_config, build_context
|
||||
from cowork_local.presentation.shell.main_window import MainWindow
|
||||
from cowork_local.theme import set_active_theme, stylesheet
|
||||
|
||||
config_path = tmp_path_factory.mktemp("cfg") / "config.json"
|
||||
build_config(config_path)
|
||||
ctx = build_context(config_path)
|
||||
css_cu = qapp.styleSheet()
|
||||
set_active_theme(ctx.config.theme)
|
||||
qapp.setStyleSheet(stylesheet(ctx.config.theme))
|
||||
win = MainWindow(ctx)
|
||||
win.resize(1280, 800)
|
||||
win.show()
|
||||
for _ in range(3):
|
||||
qapp.processEvents()
|
||||
yield win
|
||||
win.close()
|
||||
qapp.setStyleSheet(css_cu)
|
||||
|
||||
|
||||
def _cum_muc(img, y0: int, y1: int):
|
||||
"""Các cụm cột có mực trong dải ``[y0, y1)``, dạng ``[(x_đầu, x_cuối), ...]``.
|
||||
|
||||
Màu nền lấy ở cột sát mép phải cùng dòng y, nên hàng đang được tô nền chọn
|
||||
vẫn so sánh đúng.
|
||||
"""
|
||||
w = img.width()
|
||||
co_muc = [any(img.pixel(x, y) != img.pixel(w - 3, y) for y in range(y0, y1))
|
||||
for x in range(w)]
|
||||
cum, dau = [], None
|
||||
for x, c in enumerate(co_muc):
|
||||
if c and dau is None:
|
||||
dau = x
|
||||
elif not c and dau is not None:
|
||||
if x - dau >= 2:
|
||||
cum.append((dau, x - 1))
|
||||
dau = None
|
||||
if dau is not None:
|
||||
cum.append((dau, w - 1))
|
||||
return [c for c in cum if c[0] >= _BO_QUA_MEP_TRAI]
|
||||
|
||||
|
||||
def _ep_ba_hang_ve_cung_hinh(window):
|
||||
"""Cho ba hàng cùng icon và cùng chữ, để chỉ còn bố cục là khác biệt."""
|
||||
from cowork_local.ui.icons import icon as _icon
|
||||
|
||||
for i in range(2):
|
||||
it = window.nav_bottom.topLevelItem(i)
|
||||
it.setIcon(0, _icon("gear"))
|
||||
it.setText(0, "M")
|
||||
window._nav_settings_icon.setPixmap(_icon("gear").pixmap(16, 16))
|
||||
window._nav_settings_text.setText("M")
|
||||
|
||||
|
||||
def _vi_tri_ba_hang(qapp, window):
|
||||
"""``{tên hàng: (x_icon, x_chữ)}`` đo từ ảnh render của thanh rail."""
|
||||
from PySide6.QtCore import QPoint
|
||||
|
||||
_ep_ba_hang_ve_cung_hinh(window)
|
||||
for _ in range(3):
|
||||
qapp.processEvents()
|
||||
ref = window._nav_wrap
|
||||
img = ref.grab().toImage()
|
||||
|
||||
ket = {}
|
||||
for i, ten in ((0, "Dashboard"), (1, "Giám sát")):
|
||||
it = window.nav_bottom.topLevelItem(i)
|
||||
r = window.nav_bottom.visualItemRect(it)
|
||||
y = window.nav_bottom.viewport().mapTo(ref, QPoint(0, r.y())).y()
|
||||
cum = _cum_muc(img, y + 4, y + r.height() - 4)
|
||||
assert len(cum) >= 2, f"{ten}: không tìm thấy đủ icon và chữ để đo"
|
||||
ket[ten] = (cum[0][0], cum[1][0])
|
||||
|
||||
btn = window._nav_settings_btn
|
||||
y = btn.mapTo(ref, QPoint(0, 0)).y()
|
||||
cum = _cum_muc(img, y + 4, y + btn.height() - 4)
|
||||
assert len(cum) >= 2, "Cài đặt: không tìm thấy đủ icon và chữ để đo"
|
||||
ket["Cài đặt"] = (cum[0][0], cum[1][0])
|
||||
return ket
|
||||
|
||||
|
||||
def test_icon_ba_hang_thang_hang(qapp, window):
|
||||
"""Icon của ba hàng phải bắt đầu ở cùng một cột."""
|
||||
vi_tri = _vi_tri_ba_hang(qapp, window)
|
||||
x = {ten: v[0] for ten, v in vi_tri.items()}
|
||||
assert len(set(x.values())) == 1, f"icon lệch nhau: {x}"
|
||||
|
||||
|
||||
def test_chu_ba_hang_thang_hang(qapp, window):
|
||||
"""Chữ của ba hàng phải bắt đầu ở cùng một cột.
|
||||
|
||||
Đây là bài đỏ trước khi sửa: Cài đặt ở 42, hai hàng kia ở 35.
|
||||
"""
|
||||
vi_tri = _vi_tri_ba_hang(qapp, window)
|
||||
x = {ten: v[1] for ten, v in vi_tri.items()}
|
||||
assert len(set(x.values())) == 1, f"chữ lệch nhau: {x}"
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Nút "Sửa project" phải sáng ngay khi đã có project đang mở.
|
||||
|
||||
Triệu chứng: mở app, chọn một project rồi vào sub-tab Project — biểu mẫu hiện
|
||||
đủ tên, mô tả, thư mục làm việc, nhưng nút "Sửa project" vẫn mờ, không bấm được.
|
||||
|
||||
Nguyên nhân: trạng thái bật/mờ của ba nút chỉ được tính trong
|
||||
``set_project_editable``, mà đổi project KHÔNG đi qua hàm đó — ``_load_current``
|
||||
chỉ nạp lại biểu mẫu. ``_sync_project_buttons`` có chạy khi đổi sub-tab nhưng
|
||||
ngày trước chỉ chỉnh ẩn/hiện, nên nút hiện ra mang theo trạng thái mờ tính từ
|
||||
lúc dựng cửa sổ, khi chưa project nào được chọn.
|
||||
|
||||
Các bài dưới đây chốt cả bốn trạng thái: chưa có project → mờ; có project →
|
||||
sáng; đi vòng qua sub-tab khác rồi quay lại → vẫn sáng; đang sửa dở → mờ lại
|
||||
(nếu không thì "đang sửa" và "chưa sửa" trông giống hệt nhau).
|
||||
|
||||
Hai quy ước bắt buộc, lấy từ ``test_project_editing.py`` và
|
||||
``test_project_gate_subtabs.py`` ngay cạnh:
|
||||
|
||||
* **Không tạo, không xoá project nào.** ``core/projects.py`` gắn
|
||||
``PROJECTS_DIR`` vào ``~/.cowork_local`` THẬT, nên tạo project trong test là
|
||||
ghi vào dữ liệu đang dùng của người chạy test. Trạng thái "đã có project"
|
||||
được đặt thẳng vào ``_current_id`` — đúng biến mà ba nút đọc.
|
||||
* **Một cửa sổ cho cả module.** Dựng ``MainWindow`` cho từng bài làm cả bộ
|
||||
``tests/ui`` chết giữa chừng (Qt đổ stack trace, không phải test nào fail),
|
||||
nên fixture ở đây là ``scope="module"`` và mỗi bài tự đặt trạng thái đầu vào
|
||||
của mình.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6", reason="cần PySide6 để dựng cửa sổ thật")
|
||||
|
||||
_PID_GIA = "project-test-khong-ghi-dia"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ws(qapp, tmp_path_factory):
|
||||
"""Màn Workspace của một MainWindow thật, dùng chung cho cả module."""
|
||||
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)
|
||||
window = MainWindow(build_context(config_path))
|
||||
yield window.workspace
|
||||
window.close()
|
||||
|
||||
|
||||
def _nap_project(qapp, ws, pid: str) -> None:
|
||||
"""Đi đúng đường ``_load_current`` đi khi người dùng chọn một project.
|
||||
|
||||
Cố ý KHÔNG gọi ``set_project_editable``: chính vì ``_load_current`` không
|
||||
gọi nó mà lỗi mới tồn tại. Gọi nó ở đây là bài test tự tay bật lại nút rồi
|
||||
khẳng định nút đang bật — nó sẽ xanh cả trên bản chưa sửa.
|
||||
|
||||
``_project_editable`` đặt thẳng về ``False`` vì cửa sổ dùng chung cho cả
|
||||
module: bài trước có thể đã để form ở chế độ sửa, mà nạp một project mới
|
||||
thì form luôn ở chế độ chỉ-xem.
|
||||
"""
|
||||
ws.tabs.setCurrentIndex(ws._project_tab_idx)
|
||||
ws._project_editable = False
|
||||
ws._current_id = pid
|
||||
ws.project_selected.emit(pid)
|
||||
qapp.processEvents()
|
||||
|
||||
|
||||
def _chua_co_project(qapp, ws) -> None:
|
||||
"""Trạng thái chưa chọn project nào, đang ở sub-tab Project."""
|
||||
_nap_project(qapp, ws, "")
|
||||
|
||||
|
||||
def _mo_mot_project(qapp, ws) -> None:
|
||||
"""Trạng thái đang mở một project."""
|
||||
_nap_project(qapp, ws, _PID_GIA)
|
||||
|
||||
|
||||
def test_chua_co_project_thi_nut_sua_mo(qapp, ws):
|
||||
"""Chưa chọn project thì không có gì để sửa — đây là hành vi phải giữ."""
|
||||
_chua_co_project(qapp, ws)
|
||||
|
||||
assert ws._edit_btn.isEnabled() is False
|
||||
assert ws._edit_btn.isHidden() is True
|
||||
|
||||
|
||||
def test_da_co_project_thi_nut_sua_sang(qapp, ws):
|
||||
"""Bài đỏ trước khi sửa: nút hiện ra nhưng vẫn mờ."""
|
||||
_mo_mot_project(qapp, ws)
|
||||
|
||||
assert ws._edit_btn.isHidden() is False, "nút phải hiện khi đã có project"
|
||||
assert ws._edit_btn.isEnabled() is True, "nút phải bấm được khi đã có project"
|
||||
|
||||
|
||||
def test_quay_lai_tab_project_thi_nut_van_sang(qapp, ws):
|
||||
"""Đúng thao tác trong ảnh người dùng gửi: rời tab Project rồi quay lại."""
|
||||
if ws.tabs.count() < 2:
|
||||
pytest.skip("bản dựng này chỉ có một sub-tab, không đi vòng được")
|
||||
_mo_mot_project(qapp, ws)
|
||||
|
||||
ws.tabs.setCurrentIndex(1 if ws._project_tab_idx == 0 else 0)
|
||||
qapp.processEvents()
|
||||
ws.tabs.setCurrentIndex(ws._project_tab_idx)
|
||||
qapp.processEvents()
|
||||
|
||||
assert ws._edit_btn.isHidden() is False
|
||||
assert ws._edit_btn.isEnabled() is True
|
||||
|
||||
|
||||
def test_dang_sua_thi_nut_sua_mo_lai_va_nut_luu_sang(qapp, ws):
|
||||
"""Chống sửa quá tay: "Sửa project" chỉ sáng khi CHƯA ở chế độ sửa."""
|
||||
_mo_mot_project(qapp, ws)
|
||||
ws.set_project_editable(True)
|
||||
qapp.processEvents()
|
||||
|
||||
assert ws._edit_btn.isEnabled() is False
|
||||
assert ws._save_btn.isEnabled() is True
|
||||
assert ws._browse_btn.isEnabled() is True
|
||||
|
||||
|
||||
def test_bo_chon_project_thi_nut_sua_mo_lai(qapp, ws):
|
||||
"""Xoá project đang mở đưa ``_current_id`` về rỗng — nút phải mờ lại."""
|
||||
_mo_mot_project(qapp, ws)
|
||||
ws._current_id = ""
|
||||
ws.project_selected.emit("")
|
||||
qapp.processEvents()
|
||||
|
||||
assert ws._edit_btn.isEnabled() is False
|
||||
assert ws._edit_btn.isHidden() is True
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Mỗi thư mục làm việc chỉ được thuộc về MỘT project.
|
||||
|
||||
Thư mục làm việc vừa là sandbox (agent chỉ được đọc/ghi bên trong nó) vừa là
|
||||
kho kiến thức chung của project (file ở gốc thư mục được mọi đoạn chat tự đọc).
|
||||
Hai project trỏ vào cùng một thư mục là đọc lẫn dữ liệu của nhau và ghi đè lên
|
||||
nhau — đúng điều mà docstring đầu ``core/projects.py`` nói sandbox sinh ra để
|
||||
ngăn, nhưng trước đây không có gì chặn.
|
||||
|
||||
Hai nhóm bài:
|
||||
|
||||
* **Luật** — ``folder_conflict`` nhận diện trùng, kể cả khác hoa thường, khác
|
||||
kiểu dấu phân cách, và LỒNG NHAU (đứng ở thư mục cha thì vẫn với tới được
|
||||
file của project con).
|
||||
* **Giao diện** — nhãn cảnh báo dưới ô "Thư mục làm việc" hiện đúng lúc, vì dữ
|
||||
liệu cũ có thể đã trùng sẵn và luật mới chỉ chặn từ lúc chọn trở đi.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.core import projects as projects_mod
|
||||
from cowork_local.core.projects import Project, WORKSPACES_DIR, folder_conflict
|
||||
|
||||
|
||||
def _kho(monkeypatch, *ds: Project) -> None:
|
||||
"""Giả lập kho project, không đụng ``~/.cowork_local`` thật."""
|
||||
monkeypatch.setattr(projects_mod, "list_projects", lambda directory=None: list(ds))
|
||||
|
||||
|
||||
# ---- luật: nhận diện trùng ----------------------------------------------
|
||||
|
||||
def test_trung_y_het_thi_bi_bat(monkeypatch, tmp_path):
|
||||
_kho(monkeypatch, Project(project_id="a", name="A", output_dir=str(tmp_path)))
|
||||
|
||||
khac = folder_conflict(str(tmp_path), ignore_id="b")
|
||||
|
||||
assert khac is not None and khac.project_id == "a"
|
||||
|
||||
|
||||
def test_khac_hoa_thuong_va_dau_phan_cach_van_la_trung(monkeypatch, tmp_path):
|
||||
"""Trên Windows ``D:/Work`` và ``d:/work`` là cùng một thư mục."""
|
||||
_kho(monkeypatch, Project(project_id="a", name="A", output_dir=str(tmp_path)))
|
||||
|
||||
lech = str(tmp_path).replace(os.sep, "/")
|
||||
if os.name == "nt":
|
||||
lech = lech.upper()
|
||||
|
||||
assert folder_conflict(lech, ignore_id="b") is not None
|
||||
|
||||
|
||||
def test_thu_muc_con_nam_trong_thu_muc_cua_project_khac_la_trung(monkeypatch, tmp_path):
|
||||
"""Project kia đứng ở thư mục cha thì vẫn đọc/ghi được thư mục con này."""
|
||||
_kho(monkeypatch, Project(project_id="a", name="A", output_dir=str(tmp_path)))
|
||||
|
||||
assert folder_conflict(str(tmp_path / "con"), ignore_id="b") is not None
|
||||
|
||||
|
||||
def test_thu_muc_cha_chua_thu_muc_cua_project_khac_la_trung(monkeypatch, tmp_path):
|
||||
"""Chiều ngược lại cũng phải bắt: chọn thư mục cha là ôm trọn project kia."""
|
||||
_kho(monkeypatch, Project(project_id="a", name="A",
|
||||
output_dir=str(tmp_path / "con")))
|
||||
|
||||
assert folder_conflict(str(tmp_path), ignore_id="b") is not None
|
||||
|
||||
|
||||
def test_ten_na_na_nhung_khong_long_nhau_thi_khong_trung(monkeypatch, tmp_path):
|
||||
"""Bẫy của so sánh tiền tố: ``work2`` KHÔNG nằm trong ``work``."""
|
||||
_kho(monkeypatch, Project(project_id="a", name="A",
|
||||
output_dir=str(tmp_path / "work")))
|
||||
|
||||
assert folder_conflict(str(tmp_path / "work2"), ignore_id="b") is None
|
||||
|
||||
|
||||
def test_project_chua_dat_thu_muc_rieng_van_dang_chiem_thu_muc_quan_ly(monkeypatch):
|
||||
"""``output_dir`` rỗng không có nghĩa là "chưa chiếm chỗ nào": project vẫn
|
||||
đang dùng thư mục quản lý sẵn, và chính nó hay bị chọn nhầm."""
|
||||
_kho(monkeypatch, Project(project_id="a", name="A", output_dir=""))
|
||||
|
||||
assert folder_conflict(str(WORKSPACES_DIR / "a"), ignore_id="b") is not None
|
||||
|
||||
|
||||
def test_giu_nguyen_thu_muc_cua_chinh_no_thi_khong_phai_trung(monkeypatch, tmp_path):
|
||||
_kho(monkeypatch, Project(project_id="a", name="A", output_dir=str(tmp_path)))
|
||||
|
||||
assert folder_conflict(str(tmp_path), ignore_id="a") is None
|
||||
|
||||
|
||||
def test_thu_muc_chua_ai_dung_thi_di_qua(monkeypatch, tmp_path):
|
||||
_kho(monkeypatch, Project(project_id="a", name="A",
|
||||
output_dir=str(tmp_path / "cua-a")))
|
||||
|
||||
assert folder_conflict(str(tmp_path / "cua-b"), ignore_id="b") is None
|
||||
|
||||
|
||||
def test_duong_dan_rong_khong_bi_coi_la_trung(monkeypatch, tmp_path):
|
||||
"""Ô trống là "chưa chọn", không phải "trùng" — khác hẳn nhau."""
|
||||
_kho(monkeypatch, Project(project_id="a", name="A", output_dir=str(tmp_path)))
|
||||
|
||||
assert folder_conflict("", ignore_id="b") is None
|
||||
assert folder_conflict(" ", ignore_id="b") is None
|
||||
|
||||
|
||||
# ---- i18n: ba key mới phải đủ ba ngôn ngữ -------------------------------
|
||||
|
||||
@pytest.mark.parametrize("key", [
|
||||
"workspace.folder_taken_title", "workspace.folder_taken_body",
|
||||
"workspace.folder_shared_warning",
|
||||
])
|
||||
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}"
|
||||
|
||||
|
||||
# ---- giao diện: cảnh báo cho dữ liệu đã trùng sẵn -----------------------
|
||||
|
||||
pytest.importorskip("PySide6", reason="cần PySide6 để dựng cửa sổ thật")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ws(qapp, tmp_path_factory):
|
||||
"""Một cửa sổ cho cả module — dựng nhiều MainWindow làm Qt chết giữa chừng."""
|
||||
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)
|
||||
window = MainWindow(build_context(config_path))
|
||||
yield window.workspace
|
||||
window.close()
|
||||
|
||||
|
||||
def _mo_project(qapp, ws, monkeypatch, dang_mo: Project, *nhung_cai_khac: Project):
|
||||
"""Mở ``dang_mo`` trên biểu mẫu, với kho chứa cả các project còn lại."""
|
||||
_kho(monkeypatch, dang_mo, *nhung_cai_khac)
|
||||
monkeypatch.setattr(projects_mod, "load_project",
|
||||
lambda pid, directory=None: dang_mo if pid == dang_mo.project_id else None)
|
||||
ws._current_id = dang_mo.project_id
|
||||
ws.project_selected.emit(dang_mo.project_id)
|
||||
qapp.processEvents()
|
||||
|
||||
|
||||
def test_canh_bao_hien_khi_project_dang_dung_chung_thu_muc(qapp, ws, monkeypatch, tmp_path):
|
||||
"""Đúng trạng thái trong ảnh người dùng gửi: hai project cùng một thư mục."""
|
||||
_mo_project(qapp, ws, monkeypatch,
|
||||
Project(project_id="b", name="test3", output_dir=str(tmp_path)),
|
||||
Project(project_id="a", name="test2", output_dir=str(tmp_path)))
|
||||
|
||||
assert ws._folder_warn_lbl.isHidden() is False
|
||||
assert "test2" in ws._folder_warn_lbl.text()
|
||||
|
||||
|
||||
def test_khong_canh_bao_khi_thu_muc_rieng(qapp, ws, monkeypatch, tmp_path):
|
||||
_mo_project(qapp, ws, monkeypatch,
|
||||
Project(project_id="b", name="test3", output_dir=str(tmp_path / "b")),
|
||||
Project(project_id="a", name="test2", output_dir=str(tmp_path / "a")))
|
||||
|
||||
assert ws._folder_warn_lbl.isHidden() is True
|
||||
|
||||
|
||||
def test_nhan_canh_bao_nam_ngay_duoi_o_thu_muc_lam_viec(ws):
|
||||
"""Cảnh báo phải ở cạnh thứ nó nói tới, không rơi xuống cuối biểu mẫu."""
|
||||
from cowork_local.presentation.workspace.project_editing import _row_layout_of
|
||||
from cowork_local.presentation.workspace.project_folder_rules import _layout_chua
|
||||
|
||||
hang = _row_layout_of(ws.folder_lbl)
|
||||
layout, vi_tri = _layout_chua(ws.folder_lbl.parentWidget().layout(), hang)
|
||||
|
||||
assert layout.itemAt(vi_tri + 1).widget() is ws._folder_warn_lbl
|
||||
|
||||
# ---- hành vi: chọn thư mục đã thuộc project khác thì KHÔNG được ghi ------
|
||||
|
||||
def test_chon_thu_muc_trung_thi_khong_ghi_gi(qapp, ws, monkeypatch, tmp_path):
|
||||
"""Đây là cổng chặn thật, ở đúng nút "Đổi" mà người dùng bấm."""
|
||||
from PySide6.QtWidgets import QFileDialog, QMessageBox
|
||||
|
||||
from cowork_local.ui import workspace_tab as wt
|
||||
|
||||
cua_toi = Project(project_id="b", name="test3", output_dir=str(tmp_path / "b"))
|
||||
cua_nguoi_khac = Project(project_id="a", name="test2", output_dir=str(tmp_path / "a"))
|
||||
_mo_project(qapp, ws, monkeypatch, cua_toi, cua_nguoi_khac)
|
||||
|
||||
da_ghi = []
|
||||
monkeypatch.setattr(projects_mod, "save_project",
|
||||
lambda project, directory=None: da_ghi.append(project))
|
||||
da_bao = []
|
||||
monkeypatch.setattr(QMessageBox, "warning",
|
||||
staticmethod(lambda *a, **k: da_bao.append(a)))
|
||||
# Người dùng chọn đúng thư mục của project kia.
|
||||
monkeypatch.setattr(QFileDialog, "getExistingDirectory",
|
||||
staticmethod(lambda *a, **k: str(tmp_path / "a")))
|
||||
|
||||
wt.WorkspaceTab._pick_folder(ws)
|
||||
|
||||
assert da_ghi == [], "đã ghi đè output_dir dù thư mục thuộc project khác"
|
||||
assert cua_toi.output_dir == str(tmp_path / "b"), "thư mục cũ bị đổi mất"
|
||||
assert da_bao, "chặn im lặng — người dùng không biết vì sao không đổi được"
|
||||
|
||||
|
||||
def test_chon_thu_muc_tu_do_thi_van_doi_duoc(qapp, ws, monkeypatch, tmp_path):
|
||||
"""Chặn một chiều là hỏng tính năng — thư mục chưa ai dùng phải đổi được."""
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
|
||||
from cowork_local.ui import workspace_tab as wt
|
||||
|
||||
cua_toi = Project(project_id="b", name="test3", output_dir=str(tmp_path / "b"))
|
||||
cua_nguoi_khac = Project(project_id="a", name="test2", output_dir=str(tmp_path / "a"))
|
||||
_mo_project(qapp, ws, monkeypatch, cua_toi, cua_nguoi_khac)
|
||||
|
||||
da_ghi = []
|
||||
monkeypatch.setattr(projects_mod, "save_project",
|
||||
lambda project, directory=None: da_ghi.append(project))
|
||||
monkeypatch.setattr(QFileDialog, "getExistingDirectory",
|
||||
staticmethod(lambda *a, **k: str(tmp_path / "hoan-toan-moi")))
|
||||
|
||||
wt.WorkspaceTab._pick_folder(ws)
|
||||
|
||||
assert len(da_ghi) == 1
|
||||
assert cua_toi.output_dir == str(tmp_path / "hoan-toan-moi")
|
||||
Reference in New Issue
Block a user