TEST-K4A: DONE — 7/7 PASS (6 gốc + T7 finding-D) · Infra 569→576 · Domain 45 · tổng 621 · 0 hồi quy · 0 prod-code touched

# Lane test K4a — test-after 49 row menu KHKK 8-nhóm (S166, 2026-08-01)

Agent: test-specialist. Ghi-từng-mục TRONG LÚC LÀM (luật sống-còn bài K3 engine-exit).
Mục nào chưa chạy = ghi tiêu-đề + "CHƯA ĐO", KHÔNG điền số dự-đoán.

## §0 — Trạng thái khởi động

- [x] §0.1 ĐÃ đọc khuôn. Chốt harness: seeder là `private static` → REFLECTION.
  - `SeedMenuTreeAsync(ApplicationDbContext, ILogger)` — 2 tham số.
  - `SeedAllRolesReviewReadPermissionsAsync(ApplicationDbContext, RoleManager<Role>, ILogger)` — 3 tham số (site-2).
  - Khuôn invoke đã có sẵn ở `ContractCatalogTests.cs:459` (`InvokePrivateSeedAsync`, rm nullable) →
    dùng lại HÌNH, không chế fixture mới. Fixture = `IdentityFixture` (cần RoleManager cho site-2).
- [x] §0.2 ĐÃ đọc BE đã land (git diff, chưa commit):
  - `MenuKeys.cs:72-84` — `KhkkGroupNumbers=[2..8]` · `KhkkLeafActions=[WfView,List,Create,Pending,Approved,Deleted]`
    · `KhkkGroupMenuKeys` = SelectMany(group → 6 leaf) · spread `.. KhkkGroupMenuKeys` vào `All:244`.
  - `DbInitializer.cs:2115-2157` — vòng seed 49 row menu (parent `KeHoachKyKet`, Order nhóm = n,
    leaf Order 1..6) + `:2235` labelBackfill `Khkk_G1` + `:2442-2446` site-2 `KhkkKeys().Concat(KhkkGroupMenuKeys)`.
  - Đếm BY-SOURCE `All` = 113 (tự cộng tay từng dòng initializer). **Chưa phải số ĐO** — sẽ đo runtime ở T1.
- [x] §0.3 CHỒNG LẤN ĐÃ CÓ (không viết lại): `ContractCatalogTests.cs:445` T6 K1 assert
  `MenuKeys.All.Except(menuKeysInDb).Should().BeEmpty()` sau `SeedMenuTreeAsync` ⇒ invariant
  "49 key mới ĐỀU có menu-row" ĐÃ được test cũ phủ tự động (nó là phép thử của claim by-construction).
  Lane K4a KHÔNG nhân bản; thay vào đó đo cái test cũ KHÔNG đo: hình bộ sinh · đếm 56 row họ Khkk ·
  idempotent ×2 · labelBackfill · grant site-2.
- [x] §0.4 **Baseline Infra ĐO TƯƠI 2026-08-01 (trước khi thêm file): 569 PASS / 0 FAIL** (42 s).
  Đo trên cây có BE K4a đã land ⇒ test T6-K1 cũ (`All.Except(menuKeysInDb)`) XANH với 49 key mới
  = bằng chứng độc lập rằng vòng seed 49 row khớp đúng bộ key trong `All`.

## §1..§4 — File test đã LAND: `tests/SolutionErp.Infrastructure.Tests/Application/KhkkGroupMenuSeedTests.cs` (6 test)

Harness: reflection `private static` + `IdentityFixture` (không fixture mới). Không đụng prod code.

| # | Test | Đo cái gì | Chống vacuous bằng |
|---|---|---|---|
| T1 | `KhkkGroupMenuKeys_Is49Keys_GroupImmediatelyFollowedBySixLeaves` | 49 key · `Equal` với **mảng 49 chuỗi VIẾT TAY** (không gọi lại factory ⇒ không tautology) · liền-kề `Khkk_G{n}` ngay trước `Khkk_G{n}_WfView` · pin 6 action | kỳ vọng là literal, sai 1 ký tự là đỏ |
| T2 | `MenuKeys_All_ContainsThe49_NoDuplicate_AndKeepsGroup1Outside` | All ⊇ 49 · `OnlyHaveUniqueItems` · **`HaveCount(113)` = số ĐO runtime** · `KhkkGroupNumbers == [2..8]` · 7 key nhóm 1 KHÔNG nằm trong All | vế cuối = guard thật: thêm số 1 vào bộ sinh ⇒ `tree.Add("Khkk_G1")` lần 2 ⇒ trùng PK lúc boot |
| T3 | `SeedMenuTree_RunTwice_Has56KhkkRows_NoDuplication_AndCorrectTreeShape` | seed ×2: **56 row họ Khkk** (7 nhóm-1 + 49) · lần 2 danh sách key Y NGUYÊN · **tổng bảng MenuItems bất động** · 8 group parent=`KeHoachKyKet` Order=n · mỗi group đúng 6 con · 8 nhãn nhóm phân biệt | đếm bằng Ordinal in-memory (không phụ thuộc collation LIKE); đã kiểm KHÔNG có `HasData` cho MenuItems ⇒ 56 row đến TỪ seeder, không phải EF seed |
| T4 | `SeedMenuTree_NewGroupLeaves_MirrorGroup1_LabelIconOrder` | 42 leaf mới khớp **nhãn + icon + Order** của leaf nhóm 1 tương ứng (đo DB↔DB, không bảng hardcode) | chốt trước "6 nhãn nhóm 1 PHÂN BIỆT" — nếu chúng trùng nhau, phép so mirror đúng ở mọi thế giới |
| T5 | `SeedMenuTree_LabelBackfill_HealsGroup1Label_WithoutAddingRows_ControlKeyStaysDirty` | **MA TRẬN cùng-ruột-khác-1-biến**: bôi hỏng nhãn CẢ `Khkk_G1` (ca thật: về chuỗi skeleton S159) LẪN `Khkk_G2` trong cùng 1 lần → seed lại → G1 **tự lành**, G2 **giữ chữ hỏng**; tổng row bất động | 2 kết quả TRÁI NGƯỢC trong 1 lần chạy; khác biệt duy nhất = có/không entry `labelBackfill` ⇒ chứng đúng cơ chế backfill, không phải upsert "tiện tay" ghi Label (gotcha #11) |
| T6 | `SeedAllRolesReviewRead_GrantsReadOnNew49KhkkKeys_ReadOnly_WithPeContrast` | site-2 phát CanRead cho cả 49 key (nêu tên `Khkk_G2_List`) · C/U/D=false · 7 key nhóm 1 GIỮ quyền (`.Concat` mở rộng chứ không thay thế) | **ĐỐI CHỨNG CÙNG LƯỢT**: cùng role đó `Pe_DuyetNcc_List` có CanCreate=true ⇒ chuỗi "false" trên Khkk không phải vì seeder no-op. Chạy `SeedMenuTreeAsync` trước = smoke FK cho TOÀN grant-set |

Đường vào duy nhất của 49 key ở site-2 = `KhkkKeys().Concat(MenuKeys.KhkkGroupMenuKeys)` (K4a) —
`All.Where(StartsWith "Pe_")` không bắt `Khkk_*` ⇒ gỡ `.Concat` là T6 ĐỎ.

## §5 — Chạy filtered — ĐÃ ĐO

`dotnet test tests/SolutionErp.Infrastructure.Tests --filter "FullyQualifiedName~KhkkGroupMenuSeed" --nologo`
→ **Failed: 0, Passed: 6, Total: 6** (3 s).
Ghi chú số: `MenuKeys.All` runtime = **113** ĐÚNG như đếm by-source của lane BE (assert xanh) ⇒
`Policies` DERIVED = 113 × 4 = **452**.

## §6 — Full suite — ĐÃ ĐO

- **Infra: Failed 0, Passed 575, Total 575** (30 s) — baseline 569 + 6 = 575 ⇒ **0 hồi quy**.
- **Domain: Failed 0, Passed 45** (311 ms) — đo tươi, không lấy từ trí nhớ.
- **Tổng suite = 45 + 575 = 620.**

Caveat trung thực: giữa lượt làm có commit `7a903cf` (lane F-1, FE + `ContractSigningPlanFeatures.cs`)
land vào repo. Đã kiểm: `git diff --stat -- src/` vẫn đúng 42/79 dòng của riêng K4a như lúc mở lượt
⇒ byte nguồn K4a KHÔNG đổi trong lúc đo; file lane F-1 đã nằm sẵn trên đĩa TRƯỚC lần chạy full
(nó là ` M` từ đầu, commit chỉ chuyển trạng thái index) ⇒ con số 575 đo đúng trạng thái đĩa hiện tại.

## §7 — Rào đã giữ

- KHÔNG chạm production code: `git status` cho thấy dưới `tests/` chỉ có **1 file MỚI**
  `KhkkGroupMenuSeedTests.cs`; 2 file `src/` đang ` M` là diff của lane BE K4a (có từ trước lượt này).
- KHÔNG đụng `ContractSigningPlan*` / `KhkkWorkflowPanel` / `khkk.ts`.
- KHÔNG commit, KHÔNG push.
- KHÔNG sửa `SolutionErp.slnx` (project SDK-style tự glob `.cs`; test đã build + chạy).

## §8 — Quan sát gửi lead (KHÔNG phải bug, không tự sửa)

1. **Chồng lấn có lợi:** test T6 lane K1 (`ContractCatalogTests.cs:445`) nay là phép thử tự động cho
   claim "49 key ∈ All đều có menu-row" — nó XANH ở baseline 569 ⇒ claim by-construction của K4a đã
   có chứng nhân độc lập, lane K4a không cần lặp lại.
2. **Nhãn G2..G8 KHÔNG tự lành** (đo ở T5, đối chứng): nếu owner đổi chữ 7 nhãn nhóm sau khi prod đã
   seed thì sửa trong `khkkGroupLabels` là VÔ TÁC DỤNG — phải thêm entry `labelBackfill`. Comment
   BE `:2233-2234` đã khai đúng điều này; T5 nay khoá nó thành bất-biến có răng.
3. **Số canonical cần đổi kèm K4a:** `MenuKeys.All` ĐO runtime = 113 ⇒ `docs/STATUS.md` row
   `Menu keys` = 113 · `Policies` = 452. (T2 sẽ ĐỎ nếu 2 số này drift khỏi mã.)

## §D — RESUME gate K4a, finding D (MAJOR): bất-biến "revoker KHÔNG đụng `Khkk_*`"

Lệnh lead: +1 Fact `Revoke_DoesNotTouch_KhkkGroups`. Lý do: ca ĐÃ VỠ THẬT trên prod
(Run #423 — 447/494 row rơi, 11/13 role về 0; gotcha #84) mà hiện 0 test ⇒ thêm 1 chữ vào
bộ lọc revoke thì suite vẫn xanh.

- [x] §D.1 Đọc PREDICATE THẬT từ mã (`DbInitializer.cs:2554-2568`), không theo prompt:
  `RoleId != adminRoleId && (MenuKey.StartsWith("Hrm") || MenuKey.StartsWith("Off")
  || MenuKey == Personal) && (bất kỳ cờ nào đang true)` → set cả 4 cờ = false.
  🔴 **Bẫy vacuous-pass phát hiện khi đọc mã:** `:2546-2551` — **Admin role chưa seed ⇒ revoker
  RETURN SỚM, no-op**. Test không seed Admin sẽ XANH vì revoker CHƯA TỪNG CHẠY, chứ không
  phải vì `Khkk_*` được tha. Bắt buộc seed Admin + có **control dương**.
- [x] §D.2 Chọn chỗ đặt: `KhkkGroupMenuSeedTests.cs` (file của lane K4a) — đã có sẵn mảng
  `Expected49` + 7 key nhóm 1 + harness seeder; đặt ở `AdminOnlyModulesRevokeTests.cs` sẽ phải
  chép lại danh sách 49 key (nguồn thứ 2 = nguy cơ lệch). Vẫn MIRROR khuôn 2-vế của file đó.
- [x] §D.3 ĐÃ viết `T7 Revoke_DoesNotTouch_KhkkGroups` (cuối `KhkkGroupMenuSeedTests.cs`).
  Kịch bản 1 lượt, 3 chiều phân biệt:
  1. seed menu THẬT + site-2 grant THẬT cho 3 role (Admin + Drafter + DeptManager);
  2. cấp tay 2 key **trong** tập ẩn (`Off_DanhBa`, `Personal`) cho cả 3 role = **CONTROL DƯƠNG**;
  3. tick thêm `CanCreate` cho `Khkk_G5_Create` (mô phỏng admin bật cờ ghi trên ma trận);
  4. chạy `RevokeTemporarilyHiddenModulesAsync`;
  5. **Vế 1** (control dương): 2 key kia của 2 role non-Admin về **false cả 4 cờ** ⇒ chứng
     revoker CÓ CHẠY (nếu đỏ = phép đo hỏng, không phải Khkk hỏng);
     **Admin giữ true** ⇒ chứng vế `RoleId != adminRoleId` còn sống;
     **Vế 2**: 56 key Khkk × 2 role = **112 row sống sót**, `CanRead` true, row KHÔNG bị xoá,
     cờ ghi đã tick vẫn true.
  Đếm bằng ĐƠN VỊ của sự cố thật (row sống sót) để thông điệp lỗi cùng ngôn ngữ Run #423.
- [x] §D.4 **CHỨNG T7 CÓ RĂNG bằng PROBE VI-PHÂN** (không được chạm prod ⇒ không fault-inject
  vào `DbInitializer`): file tạm `ZzProbeTeethTemp.cs` dựng lại ĐÚNG hình câu truy vấn revoke
  (`:2554-2568`) + thêm **đúng 1 vế** `|| MenuKey.StartsWith("Khkk")` rồi đo trên DB thật.
  Kết quả ĐO: **trước = 112 row Khkk CanRead=true · sau bộ-lọc-bị-nới = 0** ⇒ đúng tập row mà
  T7 assert sẽ rơi hết ở thế giới hỏng ⇒ T7 phân biệt được 2 thế giới, KHÔNG phải assert đúng
  ở mọi thế giới. Probe chạy 1/1 PASS rồi **XOÁ**; verify 2 chiều: `git status -- tests/` chỉ
  còn 1 file `??` (KhkkGroupMenuSeedTests.cs) và suite = 569+**7** (không phải +8) ⇒ 0 dư lượng.

## §9 — SỐ CHỐT (đo tươi, lần cuối, sau khi đã xoá probe)

- Filtered `~KhkkGroupMenuSeed`: **7/7 PASS**.
- **Infra: 576 PASS / 0 FAIL** (baseline 569 + 7) · **Domain: 45 PASS / 0 FAIL**.
- **Tổng suite = 621.**

END sub-test-k4a — VERDICT=7/7 PASS (+7 test, suite 621 = 45 Domain + 576 Infra, 0 hồi quy, 0 prod-code touched)
