# sub-test-specialist-0 — W1 test-before RED (2 bom per-type của ApprovalWorkflowsV2 với type-3)

> Run `2026-08-10-S187-duyet-hd-tu-khkk` · vai `test-specialist` (WRITE độc quyền `tests/**`) · 2026-08-10.
> Ghi TỪNG MỤC trong lúc làm (chống #53). Mục nào CHƯA chạy thì chỉ có tiêu đề + chữ "CHƯA ĐO" — cấm điền số kỳ vọng trước.

## §0 — Ground-truth ĐO TƯƠI trước khi viết (không chép số từ đề bài)

| Thứ đo | Lệnh | Kết quả |
|---|---|---|
| Cây làm việc | `git status --porcelain` | `M ContractFeatures.cs` · `M ContractDtos.cs` · `M fe-admin/src/types/contracts.ts` (= **lane W2 đang chạy song song**) + 3 file doc/governance. `tests/**` SẠCH ⇒ mọi thay đổi test dưới đây là của tôi |
| HEAD | `git log --oneline -1` | `3a311c04 wal: flush 20260810T1726` |
| Build | `dotnet build tests/SolutionErp.Infrastructure.Tests` | **Build succeeded, 0 Warning, 0 Error** (9.19s) — tức edit dở-dang của lane W2 vẫn biên dịch được, RED của tôi sẽ không lẫn với lỗi biên dịch của người khác |
| Pre-baseline file đích | `dotnet test --filter FullyQualifiedName~UpdateAwDefinitionTests` | **Passed: 27, Failed: 0** (18s) |

🔴 **KHÔNG đo full-suite**: lane W2 đang sửa `ContractFeatures.cs`/`ContractDtos.cs` giữa chừng ⇒ con số full-suite đo lúc này là snapshot dở (gotcha #68), và nó KHÔNG phải số của tôi. Phán quyết cuối = LEAD chạy sau khi mọi lane dừng tay. Số tôi chịu trách nhiệm = filter theo file đích (đo được, tách bạch).

## §1 — Đọc code thật: 2 bom, xác nhận từng dòng

**Bom-1 `CreateAwDefinitionCommandHandler`** — `src/Backend/SolutionErp.Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs:370-377`:

```csharp
var actives = typeEnum == ApprovalWorkflowApplicableType.ContractSigningPlan
    ? await db.ApprovalWorkflows.Where(w => w.Code == code && w.ApplicableType == typeEnum && w.IsActive).ToListAsync(ct)
    : await db.ApprovalWorkflows.Where(w => w.ApplicableType == typeEnum && w.IsActive).ToListAsync(ct);
foreach (var old in actives) old.IsActive = false;
```
⇒ type-3 (`Contract`) rơi nhánh **per-TYPE** `:374-376`. Comment `:363` tự khai "các loại khác: per-TYPE như cũ — 1 active/type là **bất biến của chúng**" ⇒ sau W3 (7 quy trình `HD-*` sống song song cùng type-3) câu đó thành SAI, và một cú "Tạo phiên bản mới" trên panel một loại HĐ tắt IsActive của 6 loại còn lại. R-6 `:365-368` **CẤM** biến-thể regex.

**Bom-2 `GetAwAdminOverviewQueryHandler`** — cùng file `:205-215`: `versions.FirstOrDefault(v => v.IsActive)` cho mỗi `ApplicableType` ⇒ N active cùng type chỉ hiện 1.

**Vì sao "giấu 6" là THẬT chứ không phải chuyện thẩm mỹ** (đo ở FE, chỉ ĐỌC — không sửa):
- `fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx:381-389` render thẻ "đang áp dụng" từ `t.active` (một ô đơn);
- `:485-492` render danh sách lịch sử bằng `type.history.filter(d => !d.isActive)` ⇒ **một quy trình ĐANG ACTIVE mà chỉ nằm trong `history` thì KHÔNG lên màn hình ở đâu cả**;
- `:317` `overview.data?.types.find(t => t.applicableType === selectedTypeInt)` ⇒ FE lấy **một** bucket per type (`find`);
- `:321-339` KHKK type-10 đang tự cắt lại theo Code **ở FE** (`history.filter(d => d.code === wantCode)`) — tức tiền lệ hiện tại là FE tự xử, BE giữ 1 bucket/type.

⇒ Hệ quả cho thiết kế test bom-2 (ghi rõ để lane sau không bị test ép vào một hình dạng): test **KHÔNG được** đòi hình dạng cụ thể của bản vá. Xem §2-③.

## §2 — Thiết kế 4 ca (và những gì CỐ Ý không viết)

Đặt trong `tests/SolutionErp.Infrastructure.Tests/Application/UpdateAwDefinitionTests.cs` — **chọn theo HÀNG XÓM + FIXTURE, không theo tên file**:
- File này đã chứa đúng bộ anh em của bom-1: `T1/T2/T3` (S182 W1b) là mệnh đề per-Code cho **type-10**; ca của tôi là mệnh đề đó cho **type-3** ⇒ để cạnh nhau thì người sau đọc một mạch.
- Nó đã có sẵn toàn bộ đồ nghề: `NewHarness` (IdentityFixture — BẮT BUỘC vì `ApprovalWorkflowLevel.ApproverUserId` FK Restrict → User), `SeedWorkflowAsync(code:, version:)`, `NewVersionCommand`, `ReadWorkflowsAsync`, `WfRow`. Viết file mới = nhân bản ~60 LOC harness (bài S166).

**① Chồng lấn đã tra TRƯỚC khi viết** (`grep CreateAwDefinitionCommand|GetAwAdminOverviewQuery tests/`):
- `UpdateAwDefinitionTests.cs:1112/1152/1188` = T1/T2/T3 phủ **type-10**; `ContractSigningPlanApprovalTests.cs:1129` dùng POST để dựng cờ, không đo phạm vi deactivate.
- `GetAwAdminOverviewQueryHandler`: **0 hit trong toàn bộ `tests/`** ⇒ handler overview hôm nay 0 test.
- **KHÔNG viết lại** ca "type-1 tạo mã mới vẫn tắt mã cũ cùng type" — `T2 (:1152)` đã phủ y hệt (và còn kèm chứng nhân xuyên-type sang 8 nhóm KHKK). Nó chính là rào chống bản vá "nới per-Code cho MỌI type"; nhân bản cho type-3 chỉ làm dày file, không thêm sức phân biệt.

**② Ca CỐ Ý không viết + lý do** (khai để không ai tưởng đã phủ):
- Vế `ApplicableType == typeEnum` trong nhánh per-Code (3 vế của R-6) **không dựng được ca phân biệt**: muốn chứng "quên vế type thì tắt nhầm workflow type khác" phải có 2 workflow **cùng Code khác type**, mà `ApprovalWorkflowConfiguration.cs:23` là `HasIndex(Code, Version).IsUnique()` **toàn cục** (không kèm type) ⇒ cùng Code buộc phải khác Version, và `nextVersion` `:351-353` cũng tính per-Code toàn cục ⇒ ca dựng ra méo mó so với prod. Ghi lại là **vùng không phủ**, không giả vờ phủ.
- Hành vi FE (`find` 1 bucket, `history.filter(!isActive)`): ngoài lãnh địa của tôi, chỉ trích dẫn làm căn cứ.

**③ Cách đo bom-2 mà KHÔNG ép hình dạng bản vá** — `ActiveChannel(summary)` bằng reflection:
> "Kênh ĐƯƠNG NHIỆM" của một `AwTypeSummaryDto` = **mọi** ngả trỏ tới `AwDefinitionDto` (đơn hoặc tập hợp), **TRỪ `History`**. Trừ `History` vì nó là KHO PHIÊN BẢN (chứa cả bản đã tắt) và FE render nó bằng `filter(d => !d.isActive)` ⇒ nằm trong đó không đồng nghĩa với "được hiển thị là đang áp dụng".
Nhờ vậy ca này XANH với **cả hai** hình dạng bản vá hợp lệ: (a) thêm một trường tập hợp mới cạnh `Active`; (b) tách mỗi Code thành một mục riêng cùng `applicableType`. Hôm nay reflection chỉ thấy `Active` (đơn) ⇒ ĐỎ vì HÀNH VI, không phải vì biên dịch.
🔸 Lưu ý cho lane sau (không phải yêu cầu của test): hình (b) sẽ đụng `ApprovalWorkflowsV2Page.tsx:317` `.find(...)` — FE lấy đúng 1 bucket ⇒ hình (a) rẻ hơn. Test không quyết hộ.

## §3 — 4 ca đã viết

File DUY NHẤT tôi ghi: `tests/SolutionErp.Infrastructure.Tests/Application/UpdateAwDefinitionTests.cs` — `git diff --stat` = **295 insertions(+), 0 deletions** (1 file) ⇒ bằng chứng cấu trúc là tôi KHÔNG sửa dòng nào của 27 ca cũ.

| # | Tên ca | Dòng | Đo cái gì | Kỳ vọng hôm nay |
|---|---|---|---|---|
| 1 | `W1a_CreateNewVersionForOneContractCode_DeactivatesOnlyThatCode_SixOtherContractTypesStayActive` | `:1648` | 7 Code type-3 active → POST v02 cho `HD-MB`: 1 dòng mới · HD-MB v01 tắt / v02 bật · **mọi Code khác GIỮ active** · số LOẠI HĐ còn quy trình = 7 | **ĐỎ** (bom-1) |
| 2 | `W1a_CreateType3_WithOutOfFamilyCode_LeavesSevenContractCodesActive_AndStillRatchetsItsOwnCode` | `:1691` | (a) tạo mã type-3 NGOÀI họ `QT-HD-V2-001` ⇒ 7 loại bất biến, tổng 8 mã active; (b) tạo LẠI chính mã đó ⇒ v01 tắt / v02 bật, vẫn 8 | **ĐỎ** ở chân (a) |
| 3 | `W1b_OverviewForContractType_SurfacesEveryActiveWorkflowOfThatType_NotJustTheFirst` | `:1734` | overview type-3 (CẢ 2 nhánh `typesToReturn` `:201-203`): kênh đương-nhiệm = đủ 7 Code · chỉ chứa bản `IsActive` · HD-MB phải là **v02** · kho phiên bản vẫn đủ 8 (Code,Version) | **ĐỎ** (bom-2) |
| 4 | `W1b_OverviewForSingleActiveType_KeepsExactlyOneCurrentDesignation_AndDoesNotLeakOtherTypes` | `:1789` | type-1 có v01 tắt + v02 bật, cạnh type-2 và 7 quy trình type-3: kênh đương-nhiệm type-1 = ĐÚNG 1 (v02, không phải v01) · 0 rò rỉ type khác · kho phiên bản đủ 2 | **XANH** — control, phải XANH cả trước lẫn sau |

Đồ nghề mới (đều `private static`, dùng lại của file): `Hd :1566` · `ContractFamilyCodes :1573` (7 mã, chỉ là DỮ LIỆU — W3 chốt mã thật) · `SeedSevenContractWorkflowsAsync` · `ContractCodesWithActiveVersionAsync` (đơn vị = **số LOẠI HĐ còn quy trình để pin**, mirror `KhkkGroupsWithActiveVersionAsync :1033`) · `EnsureBudgetEditorRolesAsync :1593` (handler overview hỏi 3 role `:166-168` — thiếu role là ĐỎ hạ tầng, không phải ĐỎ hành vi) · `SurfacedAsCurrent :1614` + `ActiveChannel :1631` + `HistoryChannel`.

## §4 — Chạy: RED có đúng lý-do-hành-vi không

`dotnet test --filter "FullyQualifiedName~UpdateAwDefinitionTests.W1"` → **Failed: 3, Passed: 1, Total: 4**. Trích nguyên văn (đã cắt bớt phần dump dài):

```
Failed ...W1a_CreateNewVersionForOneContractCode_...
  Expected rows.Where(r => r.Code != "HD-MB") to contain only items matching r.IsActive
  because 🔴 tập bị tắt phải lọc theo CODE trong type-3, không theo TYPE..., but
  { WfRow { Code = "HD-TP", IsActive = False, Version = 1 }, ... "HD-NT-DV", IsActive = False ... } do(es) not match.

Failed ...W1a_CreateType3_WithOutOfFamilyCode_...
  Expected (ContractCodesWithActiveVersionAsync(h.Db)) to be 8 because 🔴 R-6: ... , but found 1.

Failed ...W1b_OverviewForContractType_...
  Expected filteredChannel.Select(d => d.Code) to be a collection with 7 item(s) ..., but {"HD-MB"}
  contains 6 item(s) less than {"HD-TP","HD-GK","HD-NCC","HD-DV","HD-MB","HD-NT-NCC","HD-NT-DV"}.
```

**ĐỎ ĐÚNG LÝ-DO-HÀNH-VI, 3 chứng cứ:**
1. Không có lỗi biên dịch nào (build succeeded ở §0 và ở mọi lượt chạy); thông điệp là assert nghiệp vụ, không phải `CS####`.
2. ĐỎ rơi **đúng dòng đang tranh chấp**: ca 1 qua hết các assert "1 dòng mới / v01 tắt / v02 bật" rồi mới gãy ở vế PHẠM VI ⇒ ratchet per-Code của chính Code đó vốn đã đúng, chỉ mỗi tập bị tắt là sai. Ca 3 qua tiền-đề "7 loại đang active" rồi mới gãy ⇒ seed đúng, chỉ đường TRẢ VỀ là sai.
3. Ca 4 (control) XANH trong cùng lượt ⇒ hạ tầng (IdentityFixture, role, `GetAwAdminOverviewQueryHandler`) chạy được; 3 ĐỎ kia không phải "harness hỏng".

**Chứng "ĐỎ này SỬA ĐƯỢC" (không phải test bất khả thi) — 2 nguồn, 0 chạm production:**
- **Bom-1 — nhân chứng SỐNG trong cùng suite, 0 mimic:** `T1 :1113` và `T3 :1188` là ĐÚNG bộ assert này cho type-10 và đang **XANH** trên chính handler thật. Cặp `W1a-1 ⟂ T1` khác nhau ĐÚNG MỘT BIẾN — `ApplicableType` 3 vs 10 — tức thế giới "per-Code" đã được chứng minh thoả mãn được bộ assert này bởi mã production hiện hành.
- **Bom-2 — PROBE VI-PHÂN (dựng, đo, xoá):** tạm thêm `[Fact] PROBE_TEMP_ActiveChannel_AggregatesAcrossBuckets` **gọi CHÍNH `ActiveChannel`/`HistoryChannel` thật** (0 bản sao logic), nạp một `AwAdminOverviewDto` dựng tay theo hình bản-vá (b) (7 mục cùng `applicableType=3`, mỗi mục một `Active`) → **Passed: 1, Failed: 0** ⇒ cả 4 vế assert của ca 3 thoả được. Xoá probe xong verify HAI CHIỀU: `grep -c PROBE_TEMP` = **0** và tổng ca của file về lại **31** (không phải 32).
  - Nhánh `case IEnumerable<AwDefinitionDto>` (hình bản-vá (a) — thêm trường tập hợp) không probe được vì cần một kiểu chưa tồn tại; nó là ngữ nghĩa mẫu-khớp của C# (`List<AwDefinitionDto>` khớp `IEnumerable<AwDefinitionDto>`). Nhánh `Active == null` thì ĐÃ chạy thật trong ca 4 (truy vấn không lọc đi qua các type 4..9 rỗng) và không nổ.

## §5 — Hồi quy: 27 ca cũ trong file có bị tôi làm đỏ không

`dotnet test --filter "FullyQualifiedName~UpdateAwDefinitionTests"` → **Failed: 3, Passed: 28, Total: 31** (trước khi tôi viết: **27/27 xanh**, đo ở §0).
- 31 = 27 cũ + 4 mới ✓ · 28 xanh = 27 cũ + 1 control mới ✓ · 3 đỏ = đúng 3 ca test-before của tôi, in đích danh tên trong output ✓.
- `git status --porcelain` cuối lượt: file tracked duy nhất tôi đụng = `tests/SolutionErp.Infrastructure.Tests/Application/UpdateAwDefinitionTests.cs`. Các file `M` còn lại (`ContractFeatures.cs`, `ContractDtos.cs`, `fe-*/types/contracts.ts`, `fe-user/.../MyContractsPage.tsx`) là của **lane W2/W7 chạy song song** — không phải của tôi. 0 file `Migrations/` đổi.
- **KHÔNG chạm production code**: 0 sửa `src/Backend/**`, 0 sửa `fe-*/**`. 0 bug production mới phát hiện ngoài đúng 2 bom đã biết.

== HẾT ==
