fix(graphrag): đổi thư mục project thì quét lại đồ thị theo thư mục mới

Sau khi đường dẫn trên thanh đã trỏ đúng thư mục mới, các node giữa màn vẫn là
của thư mục cũ: không có lệnh quét lại nào được phát ra.

Cùng một họ sai lầm với hai mảnh trước — câu hỏi "có gì đổi không" trả lời bằng
project id chứ không bằng thứ quyết định kết quả quét:

    project_changed = pid != self._active_project_id

Đổi thư mục giữ nguyên id, nên project_changed là False và cả khối phát tín
hiệu lẫn khối gọi _scan() đều bị bỏ qua, trong khi dòng đặt path_edit lại nằm
ngoài khối đó — thanh địa chỉ đúng mà đồ thị đứng yên.

Tách khối "project sandbox lock" sang graph_project_lock.py: graph_renderer.py
đang ở 399/400 dòng, đúng một dòng trước trần của scripts/check_loc.py, và cổng
đó nói rõ cách duy nhất đúng khi chạm trần là tách file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-22 08:39:22 +09:00
co-authored by Claude Opus 5
parent 76225aa118
commit b500b3e57d
3 changed files with 254 additions and 58 deletions
@@ -0,0 +1,167 @@
"""Đổi thư mục project thì đồ thị giữa màn GraphRAG phải quét lại.
Nối tiếp ``test_graphrag_follows_folder_change.py``. Sau khi đường dẫn trên
thanh đã trỏ đúng thư mục mới, các node ở giữa màn vẫn là của thư mục cũ: không
có lệnh quét lại nào được phát ra.
Nguyên nhân cùng một họ với hai mảnh trước — câu hỏi "có gì đổi không" được trả
lời bằng project id chứ không bằng thứ thật sự quyết định kết quả quét:
project_changed = pid != self._active_project_id
Đổi thư mục làm việc ở màn Project giữ nguyên id, nên ``project_changed`` là
False và cả khối phát tín hiệu lẫn khối gọi ``_scan()`` đều bị bỏ qua.
Cố ý KHÔNG dựng ``GraphRenderer`` thật: nó kéo theo QtWebEngine, dựng trong bộ
``tests/ui`` làm cả bộ chết giữa chừng (xem docstring của
``test_graphrag_follows_folder_change.py``). ``_on_project_changed`` là Python
thuần trên các thuộc tính của chính nó, nên gọi thẳng với một ``self`` giả là đủ
và đúng hơn — bài test chốt luồng quyết định, không chốt phần vẽ.
"""
from __future__ import annotations
import pytest
pytest.importorskip("PySide6", reason="cần PySide6 để nạp module renderer")
from cowork_local.core import projects as projects_mod
from cowork_local.core.projects import Project
from cowork_local.presentation.graph.graph_renderer import GraphRenderer
class _O:
"""Vật thể ghi lại lời gọi, thay cho một widget Qt."""
def __init__(self, **thuoc_tinh):
self.__dict__.update(thuoc_tinh)
self.da_goi = []
def __getattr__(self, ten):
def ghi(*args, **kwargs):
self.da_goi.append((ten, args))
return ghi
class _ComboGia:
def __init__(self, pid):
self._pid = pid
def currentData(self):
return self._pid
class _OGia:
"""Ô nhập đường dẫn: giữ được chữ, và ghi lại việc bị khoá."""
def __init__(self, text=""):
self._text = text
self.read_only = False
def text(self):
return self._text
def setText(self, value):
self._text = value
def setReadOnly(self, value):
self.read_only = value
class _Renderer:
"""``self`` giả cho ``GraphRenderer._on_project_changed``."""
def __init__(self, pid, active_id="", active_path="", hien=True):
self.project_combo = _ComboGia(pid)
self.path_edit = _OGia()
self._pick_btn = _O()
self.project_changed = _O()
self._active_project_id = active_id
self._active_path = active_path
self._needs_scan = False
self._hien = hien
self.lan_quet = 0
def isVisible(self):
return self._hien
def _scan(self):
self.lan_quet += 1
# -- tiện cho khẳng định --------------------------------------------- #
@property
def so_lan_bao_doi(self):
"""Số lần phát tín hiệu "đã đổi mục tiêu"."""
return sum(1 for ten, _ in self.project_changed.da_goi if ten == "emit")
@pytest.fixture
def du_an(monkeypatch, tmp_path):
"""Một project duy nhất trong kho giả, trỏ vào ``tmp_path/cu``."""
p = Project(project_id="p1", name="test", output_dir=str(tmp_path / "cu"))
monkeypatch.setattr(projects_mod, "load_project",
lambda pid, directory=None: p if pid == "p1" else None)
return p
def _chay(renderer):
"""Gọi đúng hàm thật với ``self`` giả."""
GraphRenderer._on_project_changed(renderer, 0)
def test_cung_project_thu_muc_moi_thi_quet_lai(du_an, tmp_path):
"""Đây là chỗ hỏng người dùng báo: đường dẫn đổi mà node giữa màn thì không."""
r = _Renderer("p1")
_chay(r) # lần đầu: khoá vào project
assert r.lan_quet == 1
du_an.output_dir = str(tmp_path / "moi") # người dùng bấm "Đổi" ở tab Project
_chay(r)
assert r.path_edit.text() == str(tmp_path / "moi")
assert r.lan_quet == 2, "đổi thư mục xong nhưng không quét lại — node vẫn của thư mục cũ"
assert r.so_lan_bao_doi == 2, (
"phải báo đổi để khung hỏi-đáp bỏ phần trích xuất của thư mục cũ")
def test_cung_project_cung_thu_muc_thi_khong_quet_lai(du_an):
"""Bảo vệ sẵn có: ``_bind_project`` chạy lại mỗi lần vào lại màn Workspace,
quét lại vô cớ là vừa giật vừa tốn."""
r = _Renderer("p1")
_chay(r)
assert r.lan_quet == 1
_chay(r)
_chay(r)
assert r.lan_quet == 1
def test_doi_sang_project_khac_van_quet_lai(du_an, tmp_path, monkeypatch):
"""Hành vi vốn có, không được mất."""
khac = Project(project_id="p2", name="khac", output_dir=str(tmp_path / "cua-p2"))
monkeypatch.setattr(projects_mod, "load_project",
lambda pid, directory=None: du_an if pid == "p1" else khac)
r = _Renderer("p1")
_chay(r)
r.project_combo._pid = "p2"
_chay(r)
assert r.lan_quet == 2
assert r.path_edit.text() == str(tmp_path / "cua-p2")
def test_man_dang_an_thi_hoan_quet_chu_khong_quet_ngay(du_an, tmp_path):
"""Đổi thư mục từ màn Project trong khi GraphRAG đang ẩn: đánh dấu để quét
ở lần vào sau, đúng luật hoãn mà ``auto_scan_and_fit`` dựa vào."""
r = _Renderer("p1", hien=False)
_chay(r)
# Lần khoá đầu tiên đã đặt cờ rồi; xoá đi để bài này thật sự kiểm được
# lần ĐỔI THƯ MỤC, chứ không xanh nhờ cờ còn sót của lần trước.
r._needs_scan = False
du_an.output_dir = str(tmp_path / "moi")
_chay(r)
assert r.lan_quet == 0
assert r._needs_scan is True