refactor(config): AppConfig thành vỏ mỏng trên repository + vá 3 chỗ gán im lặng hỏng
config.py 623 -> 377 dòng (qua ngưỡng 400 của CASAN Check 2).
Class AppConfig 278 dòng giờ còn 30: mọi lối vào dẫn tới JsonConfigRepository.
Không xoá hẳn vì cái tên còn nằm ở 41 file — 23 checker trong tools/ và 18 file
test, trong đó có test của cả ba người. Sửa 41 chỗ trong một commit là đổi thứ
không cần đổi và làm review không đọc nổi. Giữ tên, đổi ruột.
Thêm JsonConfigRepository.from_data() cho dạng AppConfig(data=..., path=...) mà
13 file test đang dùng: dựng thẳng từ dict, không đọc đĩa, không chạy migration
trên dữ liệu test.
MỘT LỖI TÔI GÂY RA HÔM 25/08, HÔM NAY MỚI LỘ
---------------------------------------------
Lúc tráo R02 tôi có đối chiếu API và kết luận "đủ 34/34 thành viên, thay được".
Đối chiếu đó chỉ so TÊN, không so việc một property có setter hay không.
AppConfig cũ là dataclass nên `config.language = "vi"` chạy bình thường.
Repository để language là property chỉ đọc -> gán vào là AttributeError. Ba chỗ
trong app.py đang gán: đổi ngôn ngữ, đổi giao diện, đổi provider trên thanh bên.
Khó thấy vì cả ba nằm trong slot của Qt, mà Qt NUỐT ngoại lệ trong slot. Không
traceback, không thông báo — bấm đổi ngôn ngữ thì không có gì xảy ra. 709 test
đơn vị vẫn xanh suốt. Chỉ check_nav bắt được vì nó bấm thật vào combo rồi kiểm.
Thêm setter cho theme/language/active_provider, và tests/test_config_gan_duoc.py
đi ngược từ mã nguồn: quét cả repo tìm mọi chỗ `config.X = ...` rồi thử gán
thật. Đã kiểm ngược — bỏ setter đi thì 2 bài đỏ.
BẮC CẦU CHO 55 CONTROL MONITORING
----------------------------------
check_controls_alive so với mốc git 291a611 và đòi 55 control ov_* của Tổng
quan phải còn tới được. Sau khi Hiệp tách 8 tab, chúng về đúng tab/thẻ của mình
và rụng tiền tố -> 3 checker đỏ.
Control còn đủ, chỉ đổi chỗ ở. Bắc cầu bằng __getattr__ định tuyến theo tiền tố
(ov_perm_ -> permissions_card, ov_sbx_ -> sandbox_card, ov_price_/ov_pricing_ ->
pricing_panel, còn lại -> overview_tab), cộng 3 hộp nhóm mà bản thân widget con
chính là hộp đó.
Định tuyến theo tiền tố chứ không dò mờ: overview_tab và permissions_card đều
có network_lbl — một cái là mức dùng mạng, một cái là quyền truy cập mạng. Bản
dò mờ đầu tiên tôi viết vớ nhầm cái đầu tiên tìm thấy.
714 test xanh. 24/24 checker qua (3 cái đã đỏ từ trước khi tôi bắt đầu, do phần
monitoring, nay xanh lại). CASAN Check 1 sạch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
72ed3b4147
commit
bc282c71d0
@@ -16,6 +16,8 @@ import json
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from .infrastructure.config.json_config_repository import JsonConfigRepository
|
||||
from typing import Any, Dict, List
|
||||
|
||||
CONFIG_DIR = Path.home() / ".cowork_local"
|
||||
@@ -343,281 +345,33 @@ def _migrate_connectors(data: Dict[str, Any]) -> None:
|
||||
data["mcp_servers"] = [] # migrated — the UI no longer manages this
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppConfig:
|
||||
"""In-memory view of the configuration with load/save helpers."""
|
||||
class AppConfig(JsonConfigRepository):
|
||||
"""Vỏ tương thích — R02 đã thay lớp này bằng :class:`JsonConfigRepository`.
|
||||
|
||||
data: Dict[str, Any] = field(default_factory=lambda: copy.deepcopy(DEFAULT_CONFIG))
|
||||
path: Path = CONFIG_PATH
|
||||
Ngày 25/08 app chuyển hẳn sang repository (ghi nguyên tử, khoá nằm trong
|
||||
kho bí mật của hệ điều hành). Nhưng cái tên ``AppConfig`` còn nằm ở 41 file
|
||||
— 23 checker trong ``tools/`` và 18 file test, trong đó có test của cả ba
|
||||
người. Sửa hết 41 chỗ trong một commit là đổi thứ không cần đổi và làm
|
||||
review không đọc nổi.
|
||||
|
||||
Nên giữ tên, đổi ruột: mọi lối vào đều dẫn tới repository.
|
||||
|
||||
Bỏ hẳn được khi ``tools/`` và ``tests/`` chuyển sang gọi
|
||||
``presentation.shell.bootstrap.build_context()``.
|
||||
"""
|
||||
|
||||
def __init__(self, data=None, path: Path = CONFIG_PATH, **kw):
|
||||
if data is None:
|
||||
super().__init__(Path(path), **kw)
|
||||
return
|
||||
# Dạng AppConfig(data=..., path=...) mà 13 file test đang dùng: dựng
|
||||
# thẳng từ dict, không đụng đĩa.
|
||||
built = JsonConfigRepository.from_data(data, Path(path))
|
||||
self.__dict__.update(built.__dict__)
|
||||
|
||||
# ---- persistence -------------------------------------------------
|
||||
@classmethod
|
||||
def load(cls, path: Path = CONFIG_PATH) -> "AppConfig":
|
||||
merged = copy.deepcopy(DEFAULT_CONFIG)
|
||||
if path.exists():
|
||||
try:
|
||||
stored = json.loads(path.read_text(encoding="utf-8"))
|
||||
merged = _deep_merge(merged, stored)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
# Corrupt config should never block startup.
|
||||
merged = copy.deepcopy(DEFAULT_CONFIG)
|
||||
merged = _apply_env_overrides(merged)
|
||||
# "unlocked" is a runtime-only Settings-panel state (see the "ms365"
|
||||
# comment in DEFAULT_CONFIG) — never trust a stored/hand-edited value,
|
||||
# every launch starts locked.
|
||||
merged.setdefault("ms365", {})["unlocked"] = False
|
||||
_migrate_connectors(merged) # office→ms365 + legacy mcp_servers→other
|
||||
return cls(data=merged, path=path)
|
||||
|
||||
def save(self) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
to_write = self.data
|
||||
if self.data.get("ms365", {}).get("unlocked"):
|
||||
# Defense in depth: even if some caller saves without having gone
|
||||
# through the Settings dialog's own auto-lock-after-save flow, the
|
||||
# unlock state must never reach disk.
|
||||
to_write = copy.deepcopy(self.data)
|
||||
to_write["ms365"]["unlocked"] = False
|
||||
self.path.write_text(
|
||||
json.dumps(to_write, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
|
||||
# ---- convenience accessors --------------------------------------
|
||||
@property
|
||||
def active_provider(self) -> str:
|
||||
# Migrate configs that still point at a removed provider (e.g. an older
|
||||
# install saved "ollama") to a supported one, so the app never tries to
|
||||
# build an unknown provider.
|
||||
val = self.data.get("active_provider", "openai_compat")
|
||||
return val if val in PROVIDER_LABELS else "openai_compat"
|
||||
|
||||
@active_provider.setter
|
||||
def active_provider(self, value: str) -> None:
|
||||
self.data["active_provider"] = value
|
||||
|
||||
def provider_conf(self, name: str | None = None) -> Dict[str, Any]:
|
||||
name = name or self.active_provider
|
||||
return self.data["providers"].get(name, {})
|
||||
|
||||
@property
|
||||
def ca_bundle(self) -> str:
|
||||
"""Path to a custom CA/certificate PEM file, or '' for normal validation.
|
||||
|
||||
Used as ``requests``' ``verify=`` argument for every outbound HTTPS call
|
||||
— see the "tls_ca_bundle" comment above for when this is needed."""
|
||||
return (self.data.get("tls_ca_bundle") or "").strip()
|
||||
|
||||
@ca_bundle.setter
|
||||
def ca_bundle(self, value: str) -> None:
|
||||
self.data["tls_ca_bundle"] = (value or "").strip()
|
||||
|
||||
# ---- Microsoft 365 connections (Settings-panel lock, see DEFAULT_CONFIG) --
|
||||
@property
|
||||
def ms365(self) -> Dict[str, Any]:
|
||||
return self.data.setdefault("ms365", copy.deepcopy(DEFAULT_CONFIG["ms365"]))
|
||||
|
||||
# ---- Login / RBAC / shared cross-machine store (see DEFAULT_CONFIG) ------
|
||||
@property
|
||||
def auth(self) -> Dict[str, Any]:
|
||||
return self.data.setdefault("auth", copy.deepcopy(DEFAULT_CONFIG["auth"]))
|
||||
|
||||
@property
|
||||
def shared_dir(self) -> str:
|
||||
return (self.auth.get("shared_dir") or "").strip()
|
||||
|
||||
def ms365_try_unlock(self, code: str) -> bool:
|
||||
"""Unlock the MS365 Settings group for this session if ``code`` matches.
|
||||
|
||||
This is a client-side UI lock (prevents casually toggling a sensitive
|
||||
section), NOT Microsoft authentication — see the DEFAULT_CONFIG
|
||||
comment. Never persisted as unlocked; see ``save()``."""
|
||||
if (code or "") and code == self.ms365.get("unlock_code", ""):
|
||||
self.data["ms365"]["unlocked"] = True
|
||||
return True
|
||||
return False
|
||||
|
||||
def ms365_lock(self) -> None:
|
||||
self.data.setdefault("ms365", {})["unlocked"] = False
|
||||
|
||||
@property
|
||||
def theme(self) -> str:
|
||||
return self.data.get("theme", "dark")
|
||||
|
||||
@theme.setter
|
||||
def theme(self, value: str) -> None:
|
||||
self.data["theme"] = value
|
||||
|
||||
@property
|
||||
def language(self) -> str:
|
||||
from .i18n import DEFAULT_LANGUAGE, LANGUAGES
|
||||
val = self.data.get("language", DEFAULT_LANGUAGE)
|
||||
return val if val in LANGUAGES else DEFAULT_LANGUAGE
|
||||
|
||||
@language.setter
|
||||
def language(self, value: str) -> None:
|
||||
self.data["language"] = value
|
||||
|
||||
@property
|
||||
def code(self) -> Dict[str, Any]:
|
||||
return self.data["code"]
|
||||
|
||||
@property
|
||||
def tools_disabled(self) -> list:
|
||||
"""Built-in agent tool names the admin has turned off (Monitoring → Tools)."""
|
||||
return self.data.setdefault("tools", {}).setdefault("disabled", [])
|
||||
|
||||
def set_tool_enabled(self, name: str, enabled: bool) -> None:
|
||||
"""Enable/disable a built-in agent tool by name and persist it."""
|
||||
disabled = set(self.tools_disabled)
|
||||
if enabled:
|
||||
disabled.discard(name)
|
||||
else:
|
||||
disabled.add(name)
|
||||
self.data.setdefault("tools", {})["disabled"] = sorted(disabled)
|
||||
self.save()
|
||||
|
||||
@property
|
||||
def connect_external(self) -> bool:
|
||||
"""Master switch (Monitoring → Tools → Connector): when off, the agent
|
||||
connects to NO external connectors (CAD/CAE/MS365/Other MCP + REST).
|
||||
Defaults ON so existing setups keep working."""
|
||||
return bool(self.data.setdefault("tools", {}).get("connect_external", True))
|
||||
|
||||
def set_connect_external(self, enabled: bool) -> None:
|
||||
self.data.setdefault("tools", {})["connect_external"] = bool(enabled)
|
||||
self.save()
|
||||
|
||||
# ---- one-time seeding bookkeeping (built-in skill library / flows) -------
|
||||
@property
|
||||
def seeded_library_skills(self) -> List[str]:
|
||||
"""Slugs of bundled library skills already seeded into the user's Skill
|
||||
Manager — so a user-deleted one is never silently re-seeded."""
|
||||
return list(self.data.setdefault("seeded_library_skills", []))
|
||||
|
||||
@seeded_library_skills.setter
|
||||
def seeded_library_skills(self, slugs) -> None:
|
||||
self.data["seeded_library_skills"] = list(dict.fromkeys(slugs or []))
|
||||
|
||||
@property
|
||||
def seeded_builtin_flows(self) -> List[str]:
|
||||
"""Ids of built-in Co4E flows already seeded (same respect-user-deletion
|
||||
rule as seeded_library_skills)."""
|
||||
return list(self.data.setdefault("seeded_builtin_flows", []))
|
||||
|
||||
@seeded_builtin_flows.setter
|
||||
def seeded_builtin_flows(self, ids) -> None:
|
||||
self.data["seeded_builtin_flows"] = list(dict.fromkeys(ids or []))
|
||||
|
||||
@property
|
||||
def teams(self) -> Dict[str, Any]:
|
||||
return self.data["teams"]
|
||||
|
||||
@property
|
||||
def history(self) -> Dict[str, Any]:
|
||||
return self.data["history"]
|
||||
|
||||
@property
|
||||
def codebase_memory(self) -> Dict[str, Any]:
|
||||
return self.data["codebase_memory"]
|
||||
|
||||
@property
|
||||
def agent_security(self) -> Dict[str, Any]:
|
||||
return self.data["agent_security"]
|
||||
|
||||
@property
|
||||
def mcp_servers(self) -> List[Dict[str, Any]]:
|
||||
return self.data.setdefault("mcp_servers", [])
|
||||
|
||||
@property
|
||||
def ext_connectors(self) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Unified Connectors (MCP), grouped by category CAD/CAE/MS365/Other —
|
||||
see core/ext_connectors.py for the per-entry shape and CATEGORIES."""
|
||||
d = self.data.setdefault("ext_connectors", {"cad": [], "cae": [], "ms365": [], "other": []})
|
||||
for cat in ("cad", "cae", "ms365", "other"):
|
||||
d.setdefault(cat, [])
|
||||
return d
|
||||
|
||||
@property
|
||||
def cowork(self) -> Dict[str, Any]:
|
||||
return self.data["cowork"]
|
||||
|
||||
@property
|
||||
def routing(self) -> Dict[str, Any]:
|
||||
"""Auto Model Assessment & Routing behaviour config (see DEFAULT_CONFIG).
|
||||
|
||||
Always returns a dict with every expected key present, backfilling any
|
||||
missing sub-keys from the defaults so older configs upgrade seamlessly."""
|
||||
d = self.data.setdefault("routing", copy.deepcopy(DEFAULT_CONFIG["routing"]))
|
||||
for k, v in DEFAULT_CONFIG["routing"].items():
|
||||
d.setdefault(k, copy.deepcopy(v))
|
||||
d.setdefault("surface_modes", {})
|
||||
for surface in ("cowork", "co4e", "ai_edit"):
|
||||
d["surface_modes"].setdefault(surface, "")
|
||||
return d
|
||||
|
||||
# The routing modes a surface may be in. "fallback" joined the set in
|
||||
# R03-T03 (keep the selected model; re-route only when it cannot serve the
|
||||
# turn) — see application/model_routing/routing_models.py::RoutingMode,
|
||||
# which is the authority on what each mode means.
|
||||
ROUTING_MODES = ("off", "auto", "manual", "fallback")
|
||||
|
||||
def routing_mode_for(self, surface: str) -> str:
|
||||
"""Effective Off/Auto/Manual/Fallback mode for a chat surface.
|
||||
|
||||
A per-surface override wins; an empty override falls back to the global
|
||||
``switch_mode``. Anything unrecognised degrades to "off" so routing
|
||||
stays opt-in even with a hand-edited config."""
|
||||
routing = self.routing
|
||||
override = (routing.get("surface_modes", {}) or {}).get(surface, "")
|
||||
mode = override or routing.get("switch_mode", "off")
|
||||
return mode if mode in self.ROUTING_MODES else "off"
|
||||
|
||||
def set_routing_mode_for(self, surface: str, mode: str) -> None:
|
||||
"""Persist a chat surface's routing toggle selection."""
|
||||
mode = mode if mode in self.ROUTING_MODES else "off"
|
||||
self.routing.setdefault("surface_modes", {})[surface] = mode
|
||||
self.save()
|
||||
|
||||
@property
|
||||
def structure(self) -> Dict[str, Any]:
|
||||
return self.data.setdefault("structure", {"max_nodes": 400, "max_edges": 400})
|
||||
|
||||
@property
|
||||
def monitoring_visibility(self) -> Dict[str, bool]:
|
||||
return self.data.setdefault(
|
||||
"monitoring_visibility", copy.deepcopy(DEFAULT_CONFIG["monitoring_visibility"]))
|
||||
|
||||
def cowork_output_dir(self) -> Path:
|
||||
"""Where Cowork saves generated files (OneDrive folder by default)."""
|
||||
custom = (self.cowork.get("output_dir") or "").strip()
|
||||
if custom:
|
||||
return Path(custom).expanduser()
|
||||
from . import paths # local import avoids any import cycle
|
||||
root = paths.primary_onedrive_root()
|
||||
if root is not None:
|
||||
return root / "CoworkLocal" / "output"
|
||||
return CONFIG_DIR / "output" / "cowork"
|
||||
|
||||
def history_dir(self) -> Path:
|
||||
"""Resolve where conversation history is stored.
|
||||
|
||||
When a project is open, its history is stored INSIDE the project's
|
||||
workspace folder (``_project_history_dir``, set by the Workspace screen)
|
||||
so that sharing/syncing that folder shares the history — another machine
|
||||
opening the same folder sees the conversations and can continue them.
|
||||
Otherwise: Local (default) or OneDrive."""
|
||||
rt = getattr(self, "_project_history_dir", None)
|
||||
if rt:
|
||||
return Path(rt)
|
||||
custom = (self.history.get("custom_dir") or "").strip()
|
||||
if custom:
|
||||
return Path(custom).expanduser()
|
||||
if self.history.get("location") == "onedrive":
|
||||
from . import paths # local import avoids any import cycle
|
||||
root = paths.primary_onedrive_root()
|
||||
if root is not None:
|
||||
return root / "CoworkLocal" / "history"
|
||||
return HISTORY_DIR
|
||||
|
||||
def model_label(self) -> str:
|
||||
return str(self.provider_conf().get("model", "?"))
|
||||
def load(cls, path: Path = CONFIG_PATH) -> "JsonConfigRepository":
|
||||
"""Điểm vào cũ. Giờ đi qua Composition Root nên checker và app dùng
|
||||
chung một đường dựng — kể cả phần ráp kho bí mật."""
|
||||
from .presentation.shell.bootstrap import build_config
|
||||
return build_config(Path(path))
|
||||
|
||||
@@ -47,6 +47,23 @@ class JsonConfigRepository:
|
||||
self._env_overrides = env_overrides
|
||||
self.data: Dict[str, Any] = self._load()
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data: Dict[str, Any], path: Path):
|
||||
"""Dựng từ dict có sẵn — KHÔNG đọc đĩa, KHÔNG nâng cấp schema.
|
||||
|
||||
Dành cho test: chúng dựng cấu hình trong bộ nhớ rồi mới ghi. Đi qua
|
||||
``__init__`` thường thì nó đọc file (chưa có) và có thể chạy migration
|
||||
trên dữ liệu test, tức là test đo nhầm thứ khác.
|
||||
"""
|
||||
obj = cls.__new__(cls)
|
||||
obj._file = AtomicJsonFile(Path(path))
|
||||
obj._secrets = None
|
||||
from ... import config as legacy
|
||||
obj._defaults = legacy.DEFAULT_CONFIG
|
||||
obj._env_overrides = legacy._apply_env_overrides
|
||||
obj.data = data
|
||||
return obj
|
||||
|
||||
# ---- nạp ------------------------------------------------------------
|
||||
def _load(self) -> Dict[str, Any]:
|
||||
merged = copy.deepcopy(self._defaults)
|
||||
@@ -74,6 +91,14 @@ class JsonConfigRepository:
|
||||
def active_provider(self) -> str:
|
||||
return self.data.get("active_provider", "")
|
||||
|
||||
@active_provider.setter
|
||||
def active_provider(self, name: str) -> None:
|
||||
"""``AppConfig`` cũ cho gán thẳng, và 3 chỗ trong app.py đang gán. Bỏ
|
||||
setter đi thì Qt nuốt AttributeError trong slot và triệu chứng là
|
||||
"bấm không ăn", không có lỗi nào hiện ra — mất hẳn một buổi mới truy
|
||||
ra. Refactor thì hành vi nhìn từ ngoài phải y hệt."""
|
||||
self.data["active_provider"] = name
|
||||
|
||||
def set_active_provider(self, name: str) -> None:
|
||||
self.data["active_provider"] = name
|
||||
|
||||
@@ -135,6 +160,10 @@ class JsonConfigRepository:
|
||||
def theme(self) -> str:
|
||||
return self.data.get("theme", "dark")
|
||||
|
||||
@theme.setter
|
||||
def theme(self, value: str) -> None:
|
||||
self.data["theme"] = value
|
||||
|
||||
def set_theme(self, value: str) -> None:
|
||||
self.data["theme"] = value
|
||||
|
||||
@@ -142,6 +171,10 @@ class JsonConfigRepository:
|
||||
def language(self) -> str:
|
||||
return self.data.get("language", "vi")
|
||||
|
||||
@language.setter
|
||||
def language(self, value: str) -> None:
|
||||
self.data["language"] = value
|
||||
|
||||
def set_language(self, value: str) -> None:
|
||||
self.data["language"] = value
|
||||
|
||||
|
||||
@@ -35,6 +35,54 @@ _UNBOUNDED_PAGE_SIZE = 100_000
|
||||
class MonitoringTab(QWidget):
|
||||
status_message = Signal(str)
|
||||
|
||||
# ---- cầu tương thích sau khi tách 8 tab (R08-T08) --------------------
|
||||
# Trước khi tách, 55 control của Tổng quan treo thẳng trên MonitoringTab với
|
||||
# tiền tố ov_. Tách xong mỗi cái về đúng tab/thẻ của nó và rụng tiền tố.
|
||||
#
|
||||
# tools/check_controls_alive.py so với mốc git 291a611 và đòi cả 55 cái phải
|
||||
# còn tới được — đó chính là việc của nó: bắt control biến mất trong lúc bóc
|
||||
# tách. Lần này control còn đủ, chỉ đổi chỗ ở, nên bắc cầu theo tiền tố.
|
||||
#
|
||||
# Định tuyến theo tiền tố chứ không dò mờ: overview_tab và permissions_card
|
||||
# có những tên trùng nhau (network_lbl nằm ở cả hai — một cái là mức dùng
|
||||
# mạng, một cái là quyền truy cập mạng). Dò mờ vớ nhầm cái đầu tiên tìm thấy.
|
||||
_OV_TIEN_TO = (
|
||||
("ov_perm_", lambda s: s.overview_tab.sandbox_card.permissions_card),
|
||||
("ov_sbx_", lambda s: s.overview_tab.sandbox_card),
|
||||
("ov_price_", lambda s: s.overview_tab.pricing_panel),
|
||||
("ov_pricing_", lambda s: s.overview_tab.pricing_panel),
|
||||
("ov_", lambda s: s.overview_tab),
|
||||
)
|
||||
#: Ba hộp nhóm: bản thân widget con CHÍNH LÀ hộp đó, không phải thuộc tính.
|
||||
_OV_CHINH_NO = {
|
||||
"ov_pricing_group": lambda s: s.overview_tab.pricing_panel,
|
||||
"ov_sandbox_details_group": lambda s: s.overview_tab.sandbox_card,
|
||||
"ov_permissions_group": lambda s: s.overview_tab.sandbox_card.permissions_card,
|
||||
}
|
||||
|
||||
#: Vài control không mang tiền tố ov_ nhưng cũng đã dời đi.
|
||||
_KHAC = {
|
||||
"status_table": lambda s: s.status_tab.table,
|
||||
}
|
||||
|
||||
def __getattr__(self, name):
|
||||
lay_khac = self._KHAC.get(name)
|
||||
if lay_khac is not None:
|
||||
return lay_khac(self)
|
||||
# Qt gọi __getattr__ rất nhiều lúc khởi tạo; chặn sớm cho rẻ.
|
||||
if not name.startswith("ov_"):
|
||||
raise AttributeError(name)
|
||||
lay = self._OV_CHINH_NO.get(name)
|
||||
if lay is not None:
|
||||
return lay(self)
|
||||
for tien_to, chu in self._OV_TIEN_TO:
|
||||
if name.startswith(tien_to):
|
||||
try:
|
||||
return getattr(chu(self), name[len(tien_to):])
|
||||
except AttributeError:
|
||||
continue
|
||||
raise AttributeError(name)
|
||||
|
||||
def __init__(self, ctx: AppContext, cowork=None, structure=None, task_scheduler=None):
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Mọi chỗ trong repo gán ``config.X = ...`` thì repository phải nhận được.
|
||||
|
||||
Bài này sinh ra từ một lỗi thật ngày 26/08.
|
||||
|
||||
R02 tráo ``config.py::AppConfig`` bằng ``JsonConfigRepository``. Trước khi
|
||||
tráo tôi có đối chiếu API: đếm đủ 34/34 thành viên công khai, không thiếu cái
|
||||
nào, nên kết luận là thay được. Đối chiếu đó **chỉ so tên**, không so việc một
|
||||
``property`` có setter hay không.
|
||||
|
||||
``AppConfig`` cũ là dataclass, ``config.language = "vi"`` chạy bình thường.
|
||||
Repository để ``language`` là property chỉ đọc, gán vào là ``AttributeError``.
|
||||
Ba chỗ trong ``app.py`` đang gán như thế: đổi ngôn ngữ, đổi giao diện, đổi
|
||||
provider trên thanh bên.
|
||||
|
||||
Điều làm nó khó thấy: cả ba đều nằm trong slot của Qt, mà Qt **nuốt ngoại lệ
|
||||
trong slot**. Không có traceback, không có thông báo — người dùng bấm đổi ngôn
|
||||
ngữ thì không có gì xảy ra. Bộ test đơn vị vẫn 709 xanh; chỉ ``check_nav`` bắt
|
||||
được vì nó bấm thật vào combo rồi kiểm ngôn ngữ có đổi không.
|
||||
|
||||
Nên bài này đi ngược từ mã nguồn: tìm mọi chỗ gán, rồi thử gán thật.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
|
||||
#: ``config.X = ...`` nhưng không phải ``==``.
|
||||
GAN = re.compile(r"\bconfig\.([a-z_][a-z_0-9]*)\s*=(?!=)")
|
||||
|
||||
#: Không phải thuộc tính cấu hình — là chỗ chứa chính đối tượng config.
|
||||
BO_QUA = {"data", "config"}
|
||||
|
||||
|
||||
def _cho_gan() -> set[str]:
|
||||
out = subprocess.run(["git", "ls-files", "*.py"], cwd=REPO,
|
||||
capture_output=True, text=True, encoding="utf-8",
|
||||
errors="replace").stdout.split()
|
||||
ten: set[str] = set()
|
||||
for f in out:
|
||||
p = REPO / f
|
||||
if not p.is_file():
|
||||
continue
|
||||
for m in GAN.finditer(p.read_text(encoding="utf-8", errors="replace")):
|
||||
if m.group(1) not in BO_QUA and not m.group(1).startswith("_"):
|
||||
ten.add(m.group(1))
|
||||
return ten
|
||||
|
||||
|
||||
def _repo(tmp_path):
|
||||
from cowork_local.config import DEFAULT_CONFIG
|
||||
from cowork_local.infrastructure.config.json_config_repository import (
|
||||
JsonConfigRepository,
|
||||
)
|
||||
return JsonConfigRepository.from_data(copy.deepcopy(DEFAULT_CONFIG),
|
||||
tmp_path / "config.json")
|
||||
|
||||
|
||||
def test_tim_duoc_cho_gan():
|
||||
"""Bảo vệ chính bài test: biểu thức tìm kiếm hỏng thì nó lặng lẽ xanh."""
|
||||
ten = _cho_gan()
|
||||
assert ten, "không tìm thấy chỗ nào gán config.X — kiểm lại GAN"
|
||||
assert "language" in ten, f"phải thấy config.language (thấy: {sorted(ten)})"
|
||||
|
||||
|
||||
def test_moi_thuoc_tinh_bi_gan_deu_gan_duoc(tmp_path):
|
||||
cfg = _repo(tmp_path)
|
||||
hong = []
|
||||
for ten in sorted(_cho_gan()):
|
||||
if not hasattr(type(cfg), ten) and not hasattr(cfg, ten):
|
||||
continue # thuộc tính của lớp khác, không phải config
|
||||
cu = getattr(cfg, ten, None)
|
||||
try:
|
||||
setattr(cfg, ten, cu)
|
||||
except AttributeError:
|
||||
hong.append(ten)
|
||||
|
||||
assert not hong, (
|
||||
"Repository không nhận gán, nhưng trong mã nguồn có chỗ gán:\n "
|
||||
+ "\n ".join("config.%s = ..." % t for t in hong)
|
||||
+ "\nQt nuốt AttributeError trong slot, nên chỗ đó sẽ im lặng không "
|
||||
"làm gì. Thêm @<tên>.setter vào JsonConfigRepository."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ten,gia_tri", [("language", "ja"), ("theme", "light"),
|
||||
("active_provider", "ollama")])
|
||||
def test_ba_cho_app_py_dang_gan(tmp_path, ten, gia_tri):
|
||||
"""Chốt riêng ba cái app.py gán, để bài trên có hỏng thì vẫn còn lưới."""
|
||||
cfg = _repo(tmp_path)
|
||||
setattr(cfg, ten, gia_tri)
|
||||
assert getattr(cfg, ten) == gia_tri
|
||||
Reference in New Issue
Block a user