feat: add quant engine WBS verification harness

This commit is contained in:
2026-07-12 10:58:22 +09:00
parent a274ef448a
commit e7d1069222
39 changed files with 2888 additions and 287 deletions
View File
@@ -0,0 +1,148 @@
import { test, expect } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
test.describe('QE-M1-02: Collection Run List & Detail Verification', () => {
// Login before each test
test.beforeEach(async ({ page }) => {
await page.goto('/Account/Login');
await page.waitForLoadState('domcontentloaded');
// Fill login form with credentials (admin/admin)
const usernameInput = page.locator('#username');
const passwordInput = page.locator('#password');
const loginButton = page.locator('#loginBtn');
await usernameInput.fill('admin');
await passwordInput.fill('admin');
await loginButton.click();
// Wait for login to complete
await page.waitForLoadState('domcontentloaded');
});
test('QE-M1-02: Collection run renders in list with API-derived expected values', async ({ page }) => {
// Step 1: Fetch expected values from API (source of truth)
const apiResponse = await page.request.get('/api/collection/runs?limit=20');
expect(apiResponse.ok()).toBeTruthy();
const responseJson = await apiResponse.json();
const runs = (responseJson as any).runs || [];
// Fail if no collection runs exist in database
if (runs.length === 0) {
throw new Error(
'No collection runs in DB — run the daily-collection job first. ' +
'Expected at least 1 run from kis_collection_runs table.'
);
}
// Extract expected values from most recent run (first in list)
const expectedRun = runs[0];
const expectedRunId = expectedRun.runId;
const expectedTotalSnapshots = expectedRun.totalSnapshots ?? 0;
const expectedStatus = expectedRun.status; // e.g., "completed", "running", "failed"
// Map status to Korean text (same logic as Index.cshtml — unknown statuses
// like COMPLETED_WITH_ERRORS render the raw status string in a secondary badge)
let expectedStatusText = String(expectedStatus ?? '');
if (expectedStatus?.toLowerCase() === 'completed') {
expectedStatusText = '완료';
} else if (expectedStatus?.toLowerCase() === 'running') {
expectedStatusText = '진행 중';
} else if (expectedStatus?.toLowerCase() === 'failed') {
expectedStatusText = '실패';
}
console.log(
`\n=== QE-M1-02 Test Started ===\n` +
`Expected RunId: ${expectedRunId}\n` +
`Expected TotalSnapshots: ${expectedTotalSnapshots}\n` +
`Expected Status: ${expectedStatus} (rendered as: ${expectedStatusText})\n`
);
// Step 2: Navigate to Collection admin page
await page.goto('/Admin/Collection');
await page.waitForLoadState('domcontentloaded');
// Step 3: Verify page title contains "데이터 수집" (collection)
const pageTitle = await page.title();
expect(pageTitle).toContain('데이터 수집');
// Step 4: Assert that a row containing the expected runId is visible
const runIdCell = page.locator(`td:has-text("${expectedRunId}")`);
await expect(runIdCell).toBeVisible();
console.log(`✓ RunId row found and visible: ${expectedRunId}`);
// Step 5: Find the row containing this runId and verify the snapshot count
const tableRow = runIdCell.locator('xpath=ancestor::tr');
// Within the row, find all td elements and map to columns
// Columns: 실행 ID (0), 시작 시간 (1), 종료 시간 (2), 상태 (3), 스냅샷 수 (4), 오류 수 (5)
const cells = tableRow.locator('td');
const cellCount = await cells.count();
expect(cellCount).toBeGreaterThanOrEqual(5); // At least 5 columns
// Cell 4 (index 4) is "스냅샷 수" (total snapshots)
const snapshotCell = cells.nth(4);
const snapshotText = await snapshotCell.textContent();
expect(snapshotText?.trim()).toBe(String(expectedTotalSnapshots));
console.log(`✓ Snapshot count matches: ${snapshotText?.trim()} == ${expectedTotalSnapshots}`);
// Cell 3 (index 3) is "상태" (status badge)
const statusCell = cells.nth(3);
const statusBadge = statusCell.locator('span.badge');
const statusBadgeText = await statusBadge.textContent();
expect(statusBadgeText?.trim()).toBe(expectedStatusText);
console.log(`✓ Status badge matches: ${statusBadgeText?.trim()} == ${expectedStatusText}`);
// Step 6: Create screenshot directory and take screenshot of collection list
const screenshotDir = path.join(process.cwd(), 'Temp', 'evidence', 'QE-M1-02', 'screenshots');
fs.mkdirSync(screenshotDir, { recursive: true });
await page.screenshot({
path: path.join(screenshotDir, '01-collection-page.png'),
fullPage: true,
});
console.log(`✓ Screenshot saved: 01-collection-page.png`);
// Step 7: Navigate to the run detail page
// The detail page route is /Admin/Collection/{runId}
await page.goto(`/Admin/Collection/${expectedRunId}`);
await page.waitForLoadState('domcontentloaded');
// Step 8: Verify detail page title contains the runId
const detailPageTitle = await page.title();
expect(detailPageTitle).toContain('수집 실행 상세');
// Step 9: Verify that the RunId is displayed on the detail page
// The page title shows: "수집 실행 상세 - {runId}"
const pageHeading = page.locator('h2.page-title');
const headingText = await pageHeading.textContent();
expect(headingText).toContain(expectedRunId);
console.log(`✓ Detail page title contains RunId: ${headingText}`);
// Step 10: Verify snapshots count is displayed on detail page
// The snapshot count appears in a card with "스냅샷 수" as the title
const snapshotCountCard = page.locator('h4.card-title:has-text("스냅샷 수")');
await expect(snapshotCountCard).toBeVisible();
// The value is in a div with class h6 after the title
const snapshotCountValue = snapshotCountCard.locator('xpath=following-sibling::div[1]');
const countText = await snapshotCountValue.textContent();
expect(countText?.trim()).toBe(String(expectedTotalSnapshots));
console.log(`✓ Detail page snapshot count matches: ${countText?.trim()} == ${expectedTotalSnapshots}`);
// Step 11: Take screenshot of detail page
await page.screenshot({
path: path.join(screenshotDir, '02-run-detail.png'),
fullPage: true,
});
console.log(`✓ Screenshot saved: 02-run-detail.png`);
console.log(
`\n=== QE-M1-02 Test Completed Successfully ===\n` +
`Evidence files saved to: ${screenshotDir}\n`
);
});
});
@@ -0,0 +1,498 @@
"""
Tests for verify_wbs_task_v1.py and validate_quant_engine_wbs_v1.py
"""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
import pytest
import yaml
# Import the modules to test
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from tools.verify_wbs_task_v1 import parse_dotnet_connection_string
class TestParseDotnetConnectionString:
"""Test .NET connection string parsing."""
def test_parse_basic_connection_string(self):
"""Test parsing a basic connection string."""
s = "Host=localhost;Port=5432;Database=testdb;Username=user;Password=pass;"
result = parse_dotnet_connection_string(s)
assert result["host"] == "localhost"
assert result["port"] == "5432"
assert result["dbname"] == "testdb"
assert result["user"] == "user"
assert result["password"] == "pass"
assert "options" not in result
def test_parse_with_search_path(self):
"""Test parsing with Search Path."""
s = "Host=localhost;Database=testdb;Username=user;Password=pass;Search Path=myschema;"
result = parse_dotnet_connection_string(s)
assert result["host"] == "localhost"
assert result["dbname"] == "testdb"
assert result["options"] == "-c search_path=myschema"
def test_parse_case_insensitive(self):
"""Test case-insensitive key handling."""
s = "HOST=localhost;DATABASE=testdb;USERNAME=user;PASSWORD=pass;"
result = parse_dotnet_connection_string(s)
assert result["host"] == "localhost"
assert result["dbname"] == "testdb"
assert result["user"] == "user"
assert result["password"] == "pass"
def test_parse_unknown_keys_ignored(self):
"""Test that unknown keys are ignored."""
s = "Host=localhost;UnknownKey=value;Database=testdb;"
result = parse_dotnet_connection_string(s)
assert result["host"] == "localhost"
assert result["dbname"] == "testdb"
assert "UnknownKey" not in result and "unknownkey" not in result
def test_parse_empty_string(self):
"""Test parsing empty string."""
result = parse_dotnet_connection_string("")
assert result == {}
def test_parse_with_spaces(self):
"""Test parsing with extra spaces."""
s = " Host = localhost ; Database = testdb ; "
result = parse_dotnet_connection_string(s)
assert result["host"] == "localhost"
assert result["dbname"] == "testdb"
class TestJsonGateComparison:
"""Test json_gate comparison operator parsing."""
def test_json_gate_exact_equality(self, tmp_path):
"""Test exact equality in json_gate."""
repo_root = tmp_path / "repo"
repo_root.mkdir()
spec_path = repo_root / "spec" / "60_quant_engine_wbs.yaml"
spec_path.parent.mkdir()
spec = {
"meta": {"formula_id": "QUANT_ENGINE_WBS_V1"},
"tasks": {
"TEST-01": {
"title": "Test task",
"status": "PENDING",
"depends_on": [],
"success_criteria": {
"expected_success_value": {},
"evidence_artifacts": [],
"verification_commands": []
},
"evidence_checks": [
{
"type": "json_gate",
"path": "Temp/test_output.json",
"expect": {"gate": "PASS", "count": 5}
}
]
}
}
}
spec_path.write_text(yaml.dump(spec), encoding="utf-8")
# Create test artifact
artifact_path = repo_root / "Temp" / "test_output.json"
artifact_path.parent.mkdir(parents=True)
artifact_path.write_text(json.dumps({"gate": "PASS", "count": 5}), encoding="utf-8")
# Run verify_wbs_task_v1
tools_dir = Path(__file__).resolve().parents[2] / "tools"
result = subprocess.run(
[sys.executable, str(tools_dir / "verify_wbs_task_v1.py"), "--task", "TEST-01",
"--repo-root", str(repo_root), "--spec", str(spec_path)],
cwd=repo_root,
capture_output=True,
text=True
)
assert result.returncode == 0, result.stderr
def test_json_gate_greater_equal_comparison(self, tmp_path):
"""Test >= comparison in json_gate."""
repo_root = tmp_path / "repo"
repo_root.mkdir()
spec_path = repo_root / "spec" / "60_quant_engine_wbs.yaml"
spec_path.parent.mkdir()
spec = {
"meta": {"formula_id": "QUANT_ENGINE_WBS_V1"},
"tasks": {
"TEST-02": {
"title": "Test task",
"status": "PENDING",
"depends_on": [],
"success_criteria": {
"expected_success_value": {},
"evidence_artifacts": [],
"verification_commands": []
},
"evidence_checks": [
{
"type": "json_gate",
"path": "Temp/test_output.json",
"expect": {"compared_count": ">=20"}
}
]
}
}
}
spec_path.write_text(yaml.dump(spec), encoding="utf-8")
# Create test artifact - PASS case
artifact_path = repo_root / "Temp" / "test_output.json"
artifact_path.parent.mkdir(parents=True)
artifact_path.write_text(json.dumps({"compared_count": 25}), encoding="utf-8")
tools_dir = Path(__file__).resolve().parents[2] / "tools"
result = subprocess.run(
[sys.executable, str(tools_dir / "verify_wbs_task_v1.py"), "--task", "TEST-02",
"--repo-root", str(repo_root), "--spec", str(spec_path)],
cwd=repo_root,
capture_output=True,
text=True
)
assert result.returncode == 0, result.stderr
def test_json_gate_greater_equal_fail(self, tmp_path):
"""Test >= comparison fails when below threshold."""
repo_root = tmp_path / "repo"
repo_root.mkdir()
spec_path = repo_root / "spec" / "60_quant_engine_wbs.yaml"
spec_path.parent.mkdir()
spec = {
"meta": {"formula_id": "QUANT_ENGINE_WBS_V1"},
"tasks": {
"TEST-03": {
"title": "Test task",
"status": "PENDING",
"depends_on": [],
"success_criteria": {
"expected_success_value": {},
"evidence_artifacts": [],
"verification_commands": []
},
"evidence_checks": [
{
"type": "json_gate",
"path": "Temp/test_output.json",
"expect": {"compared_count": ">=20"}
}
]
}
}
}
spec_path.write_text(yaml.dump(spec), encoding="utf-8")
# Create test artifact - FAIL case (value too low)
artifact_path = repo_root / "Temp" / "test_output.json"
artifact_path.parent.mkdir(parents=True)
artifact_path.write_text(json.dumps({"compared_count": 15}), encoding="utf-8")
tools_dir = Path(__file__).resolve().parents[2] / "tools"
result = subprocess.run(
[sys.executable, str(tools_dir / "verify_wbs_task_v1.py"), "--task", "TEST-03",
"--repo-root", str(repo_root), "--spec", str(spec_path)],
cwd=repo_root,
capture_output=True,
text=True
)
assert result.returncode == 1, f"Expected failure, got: {result.stdout}"
class TestLogPatternCheck:
"""Test log_pattern check type."""
def test_log_pattern_min_matches_pass(self, tmp_path):
"""Test log_pattern with min_matches that passes."""
repo_root = tmp_path / "repo"
repo_root.mkdir()
spec_path = repo_root / "spec" / "60_quant_engine_wbs.yaml"
spec_path.parent.mkdir()
spec = {
"meta": {"formula_id": "QUANT_ENGINE_WBS_V1"},
"tasks": {
"TEST-04": {
"title": "Test task",
"status": "PENDING",
"depends_on": [],
"success_criteria": {
"expected_success_value": {},
"evidence_artifacts": [],
"verification_commands": []
},
"evidence_checks": [
{
"type": "log_pattern",
"file_glob": "Temp/test.log",
"pattern": "SUCCESS",
"expect": {"min_matches": 1}
}
]
}
}
}
spec_path.write_text(yaml.dump(spec), encoding="utf-8")
# Create log file with matches
log_path = repo_root / "Temp" / "test.log"
log_path.parent.mkdir(parents=True)
log_path.write_text("Operation SUCCESS\nAnother line\nOperation SUCCESS\n", encoding="utf-8")
tools_dir = Path(__file__).resolve().parents[2] / "tools"
result = subprocess.run(
[sys.executable, str(tools_dir / "verify_wbs_task_v1.py"), "--task", "TEST-04",
"--repo-root", str(repo_root), "--spec", str(spec_path)],
cwd=repo_root,
capture_output=True,
text=True
)
assert result.returncode == 0, result.stderr
def test_log_pattern_max_matches_zero(self, tmp_path):
"""Test log_pattern with max_matches=0 (no file should match)."""
repo_root = tmp_path / "repo"
repo_root.mkdir()
spec_path = repo_root / "spec" / "60_quant_engine_wbs.yaml"
spec_path.parent.mkdir()
spec = {
"meta": {"formula_id": "QUANT_ENGINE_WBS_V1"},
"tasks": {
"TEST-05": {
"title": "Test task",
"status": "PENDING",
"depends_on": [],
"success_criteria": {
"expected_success_value": {},
"evidence_artifacts": [],
"verification_commands": []
},
"evidence_checks": [
{
"type": "log_pattern",
"file_glob": "Temp/nonexistent.log",
"pattern": "ERROR",
"expect": {"max_matches": 0}
}
]
}
}
}
spec_path.write_text(yaml.dump(spec), encoding="utf-8")
tools_dir = Path(__file__).resolve().parents[2] / "tools"
result = subprocess.run(
[sys.executable, str(tools_dir / "verify_wbs_task_v1.py"), "--task", "TEST-05",
"--repo-root", str(repo_root), "--spec", str(spec_path)],
cwd=repo_root,
capture_output=True,
text=True
)
# Should pass because file doesn't exist = 0 matches
assert result.returncode == 0, result.stderr
class TestFileExistsCheck:
"""Test file_exists check type."""
def test_file_exists_with_min_bytes_fail(self, tmp_path):
"""Test file_exists fails when file is too small."""
repo_root = tmp_path / "repo"
repo_root.mkdir()
spec_path = repo_root / "spec" / "60_quant_engine_wbs.yaml"
spec_path.parent.mkdir()
spec = {
"meta": {"formula_id": "QUANT_ENGINE_WBS_V1"},
"tasks": {
"TEST-06": {
"title": "Test task",
"status": "PENDING",
"depends_on": [],
"success_criteria": {
"expected_success_value": {},
"evidence_artifacts": [],
"verification_commands": []
},
"evidence_checks": [
{
"type": "file_exists",
"paths": ["Temp/small_file.txt"],
"expect": {"min_bytes": 100}
}
]
}
}
}
spec_path.write_text(yaml.dump(spec), encoding="utf-8")
# Create a file smaller than min_bytes
file_path = repo_root / "Temp" / "small_file.txt"
file_path.parent.mkdir(parents=True)
file_path.write_text("small", encoding="utf-8")
tools_dir = Path(__file__).resolve().parents[2] / "tools"
result = subprocess.run(
[sys.executable, str(tools_dir / "verify_wbs_task_v1.py"), "--task", "TEST-06",
"--repo-root", str(repo_root), "--spec", str(spec_path)],
cwd=repo_root,
capture_output=True,
text=True
)
assert result.returncode == 1, f"Expected failure, got: {result.stdout}"
class TestValidatorLogic:
"""Test validate_quant_engine_wbs_v1.py logic."""
def test_validator_done_task_with_passing_verdict(self, tmp_path):
"""Test validator passes when DONE task has PASS verdict."""
repo_root = tmp_path / "repo"
repo_root.mkdir()
spec_path = repo_root / "spec" / "60_quant_engine_wbs.yaml"
spec_path.parent.mkdir()
spec = {
"meta": {"formula_id": "QUANT_ENGINE_WBS_V1"},
"tasks": {
"TEST-07": {
"title": "Test task",
"status": "DONE",
"depends_on": [],
"success_criteria": {
"expected_success_value": {},
"evidence_artifacts": ["Temp/evidence/TEST-07/verdict.json"],
"verification_commands": []
},
"evidence_checks": [{"type": "file_exists", "paths": ["spec/60_quant_engine_wbs.yaml"]}]
}
}
}
spec_path.write_text(yaml.dump(spec), encoding="utf-8")
# Create a passing verdict
verdict_path = repo_root / "Temp" / "evidence" / "TEST-07" / "verdict.json"
verdict_path.parent.mkdir(parents=True)
verdict_path.write_text(
json.dumps({
"task_id": "TEST-07",
"formula_id": "QUANT_ENGINE_WBS_TASK_V1",
"gate": "PASS",
"checks": []
}),
encoding="utf-8"
)
# Create roadmap pointer
roadmap_path = repo_root / "docs" / "ROADMAP_WBS.md"
roadmap_path.parent.mkdir(parents=True)
roadmap_path.write_text("# Roadmap\n\nQUANT_ENGINE_WBS_V1 is the standard.\n", encoding="utf-8")
tools_dir = Path(__file__).resolve().parents[2] / "tools"
result = subprocess.run(
[sys.executable, str(tools_dir / "validate_quant_engine_wbs_v1.py"),
"--repo-root", str(repo_root), "--spec", str(spec_path)],
cwd=repo_root,
capture_output=True,
text=True
)
assert result.returncode == 0, result.stderr
def test_validator_done_task_without_verdict(self, tmp_path):
"""Test validator fails when DONE task is missing verdict."""
repo_root = tmp_path / "repo"
repo_root.mkdir()
spec_path = repo_root / "spec" / "60_quant_engine_wbs.yaml"
spec_path.parent.mkdir()
spec = {
"meta": {"formula_id": "QUANT_ENGINE_WBS_V1"},
"tasks": {
"TEST-08": {
"title": "Test task",
"status": "DONE",
"depends_on": [],
"success_criteria": {
"expected_success_value": {},
"evidence_artifacts": [],
"verification_commands": []
},
"evidence_checks": []
}
}
}
spec_path.write_text(yaml.dump(spec), encoding="utf-8")
# Create roadmap pointer
roadmap_path = repo_root / "docs" / "ROADMAP_WBS.md"
roadmap_path.parent.mkdir(parents=True)
roadmap_path.write_text("# Roadmap\n\nQUANT_ENGINE_WBS_V1 is the standard.\n", encoding="utf-8")
tools_dir = Path(__file__).resolve().parents[2] / "tools"
result = subprocess.run(
[sys.executable, str(tools_dir / "validate_quant_engine_wbs_v1.py"),
"--repo-root", str(repo_root), "--spec", str(spec_path)],
cwd=repo_root,
capture_output=True,
text=True
)
assert result.returncode == 1, f"Expected failure, got: {result.stdout}"
def test_validator_pending_tasks_no_verdict_required(self, tmp_path):
"""Test validator passes for PENDING tasks without verdicts."""
repo_root = tmp_path / "repo"
repo_root.mkdir()
spec_path = repo_root / "spec" / "60_quant_engine_wbs.yaml"
spec_path.parent.mkdir()
spec = {
"meta": {"formula_id": "QUANT_ENGINE_WBS_V1"},
"tasks": {
"TEST-09": {
"title": "Test task",
"status": "PENDING",
"depends_on": [],
"success_criteria": {
"expected_success_value": {},
"evidence_artifacts": [],
"verification_commands": []
},
"evidence_checks": [
{"type": "file_exists", "paths": ["spec/60_quant_engine_wbs.yaml"]}
]
}
}
}
spec_path.write_text(yaml.dump(spec), encoding="utf-8")
# Create roadmap pointer
roadmap_path = repo_root / "docs" / "ROADMAP_WBS.md"
roadmap_path.parent.mkdir(parents=True)
roadmap_path.write_text("# Roadmap\n\nQUANT_ENGINE_WBS_V1 is the standard.\n", encoding="utf-8")
tools_dir = Path(__file__).resolve().parents[2] / "tools"
result = subprocess.run(
[sys.executable, str(tools_dir / "validate_quant_engine_wbs_v1.py"),
"--repo-root", str(repo_root), "--spec", str(spec_path)],
cwd=repo_root,
capture_output=True,
text=True
)
assert result.returncode == 0, result.stderr
if __name__ == "__main__":
pytest.main([__file__, "-v"])