- Authoritative candidate capture with server-issued artifacts and raw-byte
immutability hashing (source_response_hash server-owned)
- Closed-period lifecycle: request-hash bound approvals, persisted closure
immutability violations, byte-for-byte catalog stability on reclosure
- Verification runs: persisted VerificationRun model + FK migration,
publish gate (block_publish), scheduled observability runs (02:00 UTC)
- FR-013 baseline inheritance: prior_release_id migration, plan_inheritance/
execute_inheritance classification and re-extraction, API endpoints
- Visual executor bound to release-deployment environment; caller mismatch
rejected; visual SSIM/reconciliation modules
- Query execution decomposed: envelope/model/executor split, no direct SQL
- AgentRun approvals extracted to submodule; evidence adapter; _utils
- Dashboard testing service decomposed into 30+ modules (all <400 LOC)
- Five Feature-037 agent tools with permission guards (tools_037.py)
- API readiness endpoint; Alembic env/migrations; test fixture repos
- Specs 036/037 contracts, openapi.yaml, schema.json, tasks/traceability
updated; semantic index rebuilt with 0 parse warnings
- Fix ADR-0003 parser ambiguity: remove [DEF🆔ADR] prose example
- Add axiom-mcp-agent-feedback.md: agent findings for MCP rework plan
- Tests: 298 service + 1464 API + 45 agent passing; ruff clean
514 lines
21 KiB
Python
514 lines
21 KiB
Python
# #region Test.DashboardTesting.VisualBaselineLifecycle [C:3] [TYPE Module] [SEMANTICS testing,baseline,visual,cross-kind,staleness,orchestrator]
|
|
# @defgroup Tests for visual baseline lifecycle — cross-kind guard, staleness detection, orchestrator integration.
|
|
# @LAYER Test
|
|
# @RELATION VERIFIES -> [BaselineEngine.Visual.Compare]
|
|
# @RELATION VERIFIES -> [BaselineEngine.Visual.DetectStaleness]
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
import hashlib
|
|
import io
|
|
from uuid import uuid4
|
|
|
|
import numpy as np
|
|
from PIL import Image
|
|
|
|
from src.schemas.dashboard_testing import (
|
|
ApprovalInfo,
|
|
ComparisonPolicy,
|
|
ComparisonPolicyType,
|
|
ComparisonStatus,
|
|
NormalizedFilterContext,
|
|
Provenance,
|
|
VisualBaselineEntry,
|
|
VisualFingerprints,
|
|
)
|
|
from src.services.dashboard_testing.visual_baseline import (
|
|
compare_visual_baseline,
|
|
compute_layout_fingerprint,
|
|
)
|
|
from src.services.dashboard_testing.visual_ssim import (
|
|
compare_visual_perceptual,
|
|
compute_ssim,
|
|
)
|
|
|
|
|
|
def _make_image_fixture(width: int, height: int, fill: int) -> bytes:
|
|
"""Generate a PNG image from a fixed fill value."""
|
|
arr = np.full((height, width), fill, dtype=np.uint8)
|
|
buf = io.BytesIO()
|
|
Image.fromarray(arr, mode="L").save(buf, format="PNG")
|
|
return buf.getvalue()
|
|
|
|
|
|
def _make_checkerboard(width: int, height: int, tile_size: int = 8) -> bytes:
|
|
"""Generate a checkerboard PNG from a fixed pixel pattern."""
|
|
arr = np.zeros((height, width), dtype=np.uint8)
|
|
for y in range(height):
|
|
for x in range(width):
|
|
arr[y, x] = 0 if ((x // tile_size) + (y // tile_size)) % 2 == 0 else 255
|
|
buf = io.BytesIO()
|
|
Image.fromarray(arr, mode="L").save(buf, format="PNG")
|
|
return buf.getvalue()
|
|
|
|
|
|
def _make_modified(original_bytes: bytes, x: int, y: int, new_val: int) -> bytes:
|
|
"""Modify a single pixel and re-encode as PNG (for near-identical images)."""
|
|
buf = io.BytesIO(original_bytes)
|
|
img = Image.open(buf).convert("L")
|
|
arr = np.array(img, dtype=np.uint8)
|
|
if y < arr.shape[0] and x < arr.shape[1]:
|
|
arr[y, x] = new_val
|
|
buf_out = io.BytesIO()
|
|
Image.fromarray(arr, mode="L").save(buf_out, format="PNG")
|
|
return buf_out.getvalue()
|
|
|
|
|
|
def _make_gradient(width: int, height: int) -> bytes:
|
|
"""Generate a horizontal gradient PNG from a fixed pixel pattern."""
|
|
arr = np.zeros((height, width), dtype=np.uint8)
|
|
for x in range(width):
|
|
val = round((x / max(width - 1, 1)) * 255)
|
|
arr[:, x] = val
|
|
buf = io.BytesIO()
|
|
Image.fromarray(arr, mode="L").save(buf, format="PNG")
|
|
return buf.getvalue()
|
|
|
|
|
|
def _make_visual_entry(**overrides) -> VisualBaselineEntry:
|
|
"""Build a VisualBaselineEntry for tests with sensible defaults (feature-037: release pinning)."""
|
|
now = datetime.now(UTC)
|
|
params = {
|
|
"baseline_id": uuid4(),
|
|
"release_version": "v1.0.0",
|
|
"release_commit_hash": "a" * 40,
|
|
"dashboard_id": 42,
|
|
"normalized_filters": NormalizedFilterContext(
|
|
filters=[],
|
|
filters_hash="sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
),
|
|
"tab_identifier": "TAB-main",
|
|
"expected_image_sha256": "e" * 64,
|
|
"source_response_hash": "s" * 64,
|
|
"captured_at": now,
|
|
"policy": ComparisonPolicy(type=ComparisonPolicyType.VISUAL_EXACT),
|
|
"status": "approved",
|
|
"fingerprints": VisualFingerprints(
|
|
query="a" * 64,
|
|
dataset="b" * 64,
|
|
filter="c" * 64,
|
|
layout="d" * 64,
|
|
),
|
|
"provenance": Provenance(environment="ss-preprod", actor="qa"),
|
|
"approval": ApprovalInfo(by="qa_analyst", at=now),
|
|
"created_at": now,
|
|
"updated_at": now,
|
|
}
|
|
params.update(overrides)
|
|
return VisualBaselineEntry(**params)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# Cross-kind guard + stale detection (compare_visual_baseline orchestrator)
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
# #region Test.DashboardTesting.VisualBaseline.CrossKind [C:3] [TYPE Function] [SEMANTICS testing,cross-kind,staleness]
|
|
def test_metric_policy_on_visual_is_inconclusive():
|
|
"""T047: Metric policy (exact) applied to visual baseline → inconclusive."""
|
|
policy = ComparisonPolicy(type=ComparisonPolicyType.EXACT)
|
|
result = compare_visual_baseline(
|
|
actual_image_sha256="sha1",
|
|
expected_image_sha256="sha2",
|
|
policy=policy,
|
|
)
|
|
assert result.status == ComparisonStatus.INCONCLUSIVE
|
|
assert any("CROSS_KIND" in w.code for w in result.warnings)
|
|
|
|
|
|
def test_visual_policy_works_exact():
|
|
"""T047: Visual exact policy routes to hash comparison."""
|
|
policy = ComparisonPolicy(type=ComparisonPolicyType.VISUAL_EXACT)
|
|
result = compare_visual_baseline(
|
|
actual_image_sha256="abc",
|
|
expected_image_sha256="abc",
|
|
policy=policy,
|
|
)
|
|
assert result.status == ComparisonStatus.PASS
|
|
|
|
|
|
def test_visual_policy_works_perceptual():
|
|
"""Visual perceptual policy routes to SSIM comparison with image bytes."""
|
|
img = _make_image_fixture(64, 64, 128)
|
|
policy = ComparisonPolicy(type=ComparisonPolicyType.VISUAL_PERCEPTUAL, amount="0.95")
|
|
result = compare_visual_baseline(
|
|
actual_image_data=img,
|
|
expected_image_data=img,
|
|
policy=policy,
|
|
)
|
|
assert result.status == ComparisonStatus.PASS
|
|
|
|
|
|
def test_stale_visual_baseline():
|
|
"""T047: Stale layout dimensions → stale_visual_baseline status (derived from fingerprints)."""
|
|
now = datetime.now(UTC)
|
|
baseline = VisualBaselineEntry(
|
|
baseline_id=uuid4(),
|
|
release_version="v1.0.0",
|
|
release_commit_hash="a" * 40,
|
|
dashboard_id=42,
|
|
normalized_filters=NormalizedFilterContext(filters=[], filters_hash="sha256:" + "a" * 64),
|
|
tab_identifier="TAB-main",
|
|
expected_image_sha256="abc",
|
|
source_response_hash="s" * 64,
|
|
captured_at=now,
|
|
policy=ComparisonPolicy(type=ComparisonPolicyType.VISUAL_EXACT),
|
|
fingerprints=VisualFingerprints(query="q_fp", dataset="d_fp", filter="f_fp", layout="baseline_layout"),
|
|
provenance=Provenance(environment="ss-preprod", actor="qa"),
|
|
approval=ApprovalInfo(by="qa_analyst", at=now),
|
|
created_at=now,
|
|
)
|
|
policy = ComparisonPolicy(type=ComparisonPolicyType.VISUAL_EXACT)
|
|
# Current layout fingerprint differs from baseline => staleness derived from fingerprints
|
|
result = compare_visual_baseline(
|
|
actual_image_sha256="abc",
|
|
expected_image_sha256="abc",
|
|
policy=policy,
|
|
visual_baseline=baseline,
|
|
current_layout_fingerprint="current_layout",
|
|
)
|
|
assert result.status == ComparisonStatus.STALE_VISUAL_BASELINE
|
|
assert "layout" in result.stale_dimensions
|
|
|
|
|
|
def test_stale_dimensions_propagate():
|
|
"""Multiple stale dimensions all appear in result (derived from fingerprints, not caller-supplied)."""
|
|
now = datetime.now(UTC)
|
|
baseline = VisualBaselineEntry(
|
|
baseline_id=uuid4(),
|
|
release_version="v1.0.0",
|
|
release_commit_hash="a" * 40,
|
|
dashboard_id=42,
|
|
normalized_filters=NormalizedFilterContext(filters=[], filters_hash="sha256:" + "a" * 64),
|
|
tab_identifier="TAB-main",
|
|
expected_image_sha256="abc",
|
|
source_response_hash="s" * 64,
|
|
captured_at=now,
|
|
policy=ComparisonPolicy(type=ComparisonPolicyType.VISUAL_EXACT),
|
|
fingerprints=VisualFingerprints(query="b_q", dataset="d_fp", filter="f_fp", layout="b_l"),
|
|
provenance=Provenance(environment="ss-preprod", actor="qa"),
|
|
approval=ApprovalInfo(by="qa_analyst", at=now),
|
|
created_at=now,
|
|
)
|
|
policy = ComparisonPolicy(type=ComparisonPolicyType.VISUAL_EXACT)
|
|
result = compare_visual_baseline(
|
|
actual_image_sha256="abc",
|
|
expected_image_sha256="abc",
|
|
policy=policy,
|
|
visual_baseline=baseline,
|
|
current_layout_fingerprint="c_l",
|
|
current_query_fingerprint="c_q",
|
|
)
|
|
assert result.status == ComparisonStatus.STALE_VISUAL_BASELINE
|
|
assert "layout" in result.stale_dimensions
|
|
assert "query" in result.stale_dimensions
|
|
# #endregion Test.DashboardTesting.VisualBaseline.CrossKind
|
|
|
|
|
|
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# Orchestrator integration (compare_visual_baseline with real image data)
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
# #region Test.DashboardTesting.VisualBaseline.Orchestrator [C:3] [TYPE Function] [SEMANTICS testing,orchestrator,integration]
|
|
def test_compare_baseline_exact_with_image_bytes():
|
|
"""compare_visual_baseline with VISUAL_EXACT uses hash from image bytes."""
|
|
img = _make_image_fixture(32, 32, 128)
|
|
h = hashlib.sha256(img).hexdigest()
|
|
|
|
policy = ComparisonPolicy(type=ComparisonPolicyType.VISUAL_EXACT)
|
|
result = compare_visual_baseline(
|
|
actual_image_sha256=h,
|
|
expected_image_sha256=h,
|
|
policy=policy,
|
|
)
|
|
assert result.status == ComparisonStatus.PASS
|
|
|
|
|
|
def test_compare_baseline_exact_fail_different_images():
|
|
"""compare_visual_baseline with VISUAL_EXACT fails for different images."""
|
|
img1 = _make_image_fixture(32, 32, 128)
|
|
img2 = _make_image_fixture(32, 32, 200)
|
|
h1 = hashlib.sha256(img1).hexdigest()
|
|
h2 = hashlib.sha256(img2).hexdigest()
|
|
|
|
policy = ComparisonPolicy(type=ComparisonPolicyType.VISUAL_EXACT)
|
|
result = compare_visual_baseline(
|
|
actual_image_sha256=h1,
|
|
expected_image_sha256=h2,
|
|
policy=policy,
|
|
)
|
|
assert result.status == ComparisonStatus.FAIL
|
|
assert len(result.diff) > 0
|
|
|
|
|
|
def test_compare_baseline_perceptual_pass():
|
|
"""compare_visual_baseline with VISUAL_PERCEPTUAL passes for identical images."""
|
|
img = _make_image_fixture(64, 64, 128)
|
|
policy = ComparisonPolicy(type=ComparisonPolicyType.VISUAL_PERCEPTUAL, amount="0.95")
|
|
|
|
result = compare_visual_baseline(
|
|
actual_image_data=img,
|
|
expected_image_data=img,
|
|
policy=policy,
|
|
)
|
|
assert result.status == ComparisonStatus.PASS
|
|
|
|
|
|
def test_compare_baseline_perceptual_fail():
|
|
"""compare_visual_baseline with VISUAL_PERCEPTUAL fails for different images."""
|
|
check_img = _make_checkerboard(64, 64)
|
|
grad_img = _make_gradient(64, 64)
|
|
policy = ComparisonPolicy(type=ComparisonPolicyType.VISUAL_PERCEPTUAL, amount="0.95")
|
|
|
|
result = compare_visual_baseline(
|
|
actual_image_data=check_img,
|
|
expected_image_data=grad_img,
|
|
policy=policy,
|
|
)
|
|
assert result.status == ComparisonStatus.FAIL
|
|
|
|
|
|
def test_compare_baseline_perceptual_fallback_hash_pass():
|
|
"""VISUAL_PERCEPTUAL with hash data but no image data falls back to hash comparison."""
|
|
img = _make_image_fixture(64, 64, 128)
|
|
h = hashlib.sha256(img).hexdigest()
|
|
policy = ComparisonPolicy(type=ComparisonPolicyType.VISUAL_PERCEPTUAL, amount="0.95")
|
|
|
|
result = compare_visual_baseline(
|
|
actual_image_sha256=h,
|
|
expected_image_sha256=h,
|
|
policy=policy,
|
|
)
|
|
assert result.status == ComparisonStatus.PASS
|
|
|
|
|
|
def test_compare_baseline_perceptual_fallback_hash_fail():
|
|
"""VISUAL_PERCEPTUAL fallback passes same hashes, is inconclusive for different."""
|
|
policy = ComparisonPolicy(type=ComparisonPolicyType.VISUAL_PERCEPTUAL, amount="0.95")
|
|
|
|
result = compare_visual_baseline(
|
|
actual_image_sha256="hash_a",
|
|
expected_image_sha256="hash_b",
|
|
policy=policy,
|
|
)
|
|
assert result.status == ComparisonStatus.INCONCLUSIVE
|
|
|
|
|
|
def test_compare_baseline_no_data_inconclusive():
|
|
"""compare_visual_baseline returns INCONCLUSIVE when no data provided."""
|
|
policy = ComparisonPolicy(type=ComparisonPolicyType.VISUAL_PERCEPTUAL)
|
|
result = compare_visual_baseline(policy=policy)
|
|
assert result.status == ComparisonStatus.INCONCLUSIVE
|
|
|
|
|
|
def test_compare_baseline_stale_takes_priority():
|
|
"""Stale dimensions take priority over comparison logic (derived from fingerprints)."""
|
|
now = datetime.now(UTC)
|
|
baseline = VisualBaselineEntry(
|
|
baseline_id=uuid4(),
|
|
release_version="v1.0.0",
|
|
release_commit_hash="a" * 40,
|
|
dashboard_id=42,
|
|
normalized_filters=NormalizedFilterContext(filters=[], filters_hash="sha256:" + "a" * 64),
|
|
tab_identifier="TAB-main",
|
|
expected_image_sha256="abc",
|
|
source_response_hash="s" * 64,
|
|
captured_at=now,
|
|
policy=ComparisonPolicy(type=ComparisonPolicyType.VISUAL_EXACT),
|
|
fingerprints=VisualFingerprints(query="q", dataset="d", filter="f", layout="baseline_fp"),
|
|
provenance=Provenance(environment="ss-preprod", actor="qa"),
|
|
approval=ApprovalInfo(by="qa_analyst", at=now),
|
|
created_at=now,
|
|
)
|
|
img1 = _make_image_fixture(32, 32, 128)
|
|
img2 = _make_image_fixture(32, 32, 200)
|
|
h1 = hashlib.sha256(img1).hexdigest()
|
|
h2 = hashlib.sha256(img2).hexdigest()
|
|
|
|
policy = ComparisonPolicy(type=ComparisonPolicyType.VISUAL_EXACT)
|
|
result = compare_visual_baseline(
|
|
actual_image_sha256=h1,
|
|
expected_image_sha256=h2,
|
|
policy=policy,
|
|
visual_baseline=baseline,
|
|
current_layout_fingerprint="current_fp",
|
|
)
|
|
assert result.status == ComparisonStatus.STALE_VISUAL_BASELINE
|
|
# #endregion Test.DashboardTesting.VisualBaseline.Orchestrator
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# Feature-037: Anti-correlated fixture + ssim_min/pixel_diff_threshold validation
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
# #region Test.DashboardTesting.VisualBaseline.AntiCorrelated [C:3] [TYPE Function] [SEMANTICS testing,ssim,anti-correlated,threshold]
|
|
def test_ssim_anti_correlated_patterns():
|
|
"""Feature-037: Two complementary checkerboard patterns (inverse of each other)
|
|
produce SSIM ≈ 0 (anti-correlated)."""
|
|
w, h = 64, 64
|
|
# Pattern A: standard checkerboard
|
|
a = np.zeros((h, w), dtype=np.uint8)
|
|
for y in range(h):
|
|
for x in range(w):
|
|
a[y, x] = 0 if ((x // 8) + (y // 8)) % 2 == 0 else 255
|
|
# Pattern B: inverse of A
|
|
b = np.zeros((h, w), dtype=np.uint8)
|
|
for y in range(h):
|
|
for x in range(w):
|
|
b[y, x] = 255 if ((x // 8) + (y // 8)) % 2 == 0 else 0
|
|
ssim = compute_ssim(a, b)
|
|
assert ssim == 0.0, f"Anti-correlated patterns should yield SSIM=0, got {ssim}"
|
|
|
|
|
|
def test_ssim_clamped_to_zero_one():
|
|
"""Feature-037: SSIM is always clamped to [0, 1] even with extreme inputs."""
|
|
# Two identical images: SSIM = 1.0
|
|
a = np.full((10, 10), 128, dtype=np.uint8)
|
|
assert compute_ssim(a, a) == 1.0
|
|
# Two opposite images: SSIM = 0.0 (clamped)
|
|
black = np.zeros((10, 10), dtype=np.uint8)
|
|
white = np.full((10, 10), 255, dtype=np.uint8)
|
|
ssim = compute_ssim(black, white)
|
|
assert 0.0 <= ssim <= 1.0
|
|
assert ssim < 0.01
|
|
# #endregion Test.DashboardTesting.VisualBaseline.AntiCorrelated
|
|
|
|
|
|
# #region Test.DashboardTesting.VisualBaseline.ThresholdValidation [C:3] [TYPE Function] [SEMANTICS testing,ssim_min,threshold,validation]
|
|
def test_ssim_min_out_of_range_high():
|
|
"""Feature-037: ssim_min > 1.0 returns INCONCLUSIVE."""
|
|
img = _make_image_fixture(32, 32, 128)
|
|
status, _diff = compare_visual_perceptual(img, img, ssim_min=1.5)
|
|
assert status == ComparisonStatus.INCONCLUSIVE
|
|
|
|
|
|
def test_ssim_min_out_of_range_low():
|
|
"""Feature-037: ssim_min < 0 returns INCONCLUSIVE."""
|
|
img = _make_image_fixture(32, 32, 128)
|
|
status, _diff = compare_visual_perceptual(img, img, ssim_min=-0.1)
|
|
assert status == ComparisonStatus.INCONCLUSIVE
|
|
|
|
|
|
def test_pixel_diff_threshold_negative():
|
|
"""Feature-037: Negative pixel_diff_threshold returns INCONCLUSIVE."""
|
|
base = _make_image_fixture(32, 32, 128)
|
|
modified = _make_modified(base, 0, 0, 200)
|
|
status, _diff = compare_visual_perceptual(base, modified, ssim_min=0.95, pixel_diff_threshold=-0.01)
|
|
assert status == ComparisonStatus.INCONCLUSIVE
|
|
|
|
|
|
def test_pixel_diff_threshold_gt_one():
|
|
"""Feature-037: pixel_diff_threshold > 1.0 returns INCONCLUSIVE (must be in [0,1])."""
|
|
base = _make_image_fixture(32, 32, 128)
|
|
modified = _make_modified(base, 0, 0, 200)
|
|
status, _diff = compare_visual_perceptual(base, modified, ssim_min=0.95, pixel_diff_threshold=1.5)
|
|
assert status == ComparisonStatus.INCONCLUSIVE
|
|
|
|
|
|
def test_pixel_diff_threshold_ok():
|
|
"""Feature-037: pixel_diff_threshold >= actual diff ratio => PASS."""
|
|
base = _make_image_fixture(32, 32, 200)
|
|
# Modify one pixel
|
|
modified = _make_modified(base, 0, 0, 201)
|
|
status, _diff = compare_visual_perceptual(base, modified, ssim_min=0.95, pixel_diff_threshold=0.01)
|
|
# SSIM is near 1, pixel diff is 1/(32*32) ≈ 0.001 < 0.01 => PASS
|
|
assert status == ComparisonStatus.PASS
|
|
|
|
|
|
def test_pixel_diff_threshold_fail():
|
|
"""Feature-037: pixel_diff_threshold below actual diff ratio => FAIL."""
|
|
base = _make_image_fixture(32, 32, 128)
|
|
# Make half the image different
|
|
buf = io.BytesIO(base)
|
|
img = Image.open(buf).convert("L")
|
|
arr = np.array(img, dtype=np.uint8)
|
|
arr[:, 16:] = 0 # Right half black
|
|
buf_out = io.BytesIO()
|
|
Image.fromarray(arr, mode="L").save(buf_out, format="PNG")
|
|
modified = buf_out.getvalue()
|
|
# pixel_diff ~0.5, threshold 0.01 => FAIL
|
|
status, _diff = compare_visual_perceptual(base, modified, ssim_min=0.0, pixel_diff_threshold=0.01)
|
|
assert status == ComparisonStatus.FAIL
|
|
# #endregion Test.DashboardTesting.VisualBaseline.ThresholdValidation
|
|
|
|
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# Feature-037: Layout fingerprint — tab/region hierarchy
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
# #region Test.DashboardTesting.VisualBaseline.LayoutTabHierarchy [C:3] [TYPE Function] [SEMANTICS testing,layout,fingerprint,tab-hierarchy]
|
|
def test_layout_fingerprint_includes_tab_hierarchy():
|
|
"""Feature-037: Tab hierarchy is included in layout fingerprint."""
|
|
position = {
|
|
"TAB-1": {"meta": {"children": ["CHART-128", "CHART-129"]}},
|
|
"CHART-128": {"meta": {"chartId": 128, "width": 6, "height": 12}, "parent_id": "TAB-1"},
|
|
"CHART-129": {"meta": {"chartId": 129, "width": 12, "height": 4}, "parent_id": "TAB-1"},
|
|
}
|
|
fp = compute_layout_fingerprint(position, [128, 129])
|
|
assert len(fp) == 64 # SHA-256 hex
|
|
|
|
|
|
def test_layout_fingerprint_tab_reorder_changes():
|
|
"""Feature-037: Reordering tabs changes fingerprint even if chart positions unchanged.
|
|
Tab children are inserted in order; reordering produces a different fingerprint."""
|
|
# Two tabs with identical chart structure but different order
|
|
pos_a = {
|
|
"TAB-A": {"meta": {"children": ["CHART-1"]}},
|
|
"TAB-B": {"meta": {"children": ["CHART-2"]}},
|
|
"CHART-1": {"meta": {"chartId": 1, "width": 6, "height": 6}, "parent_id": "TAB-A"},
|
|
"CHART-2": {"meta": {"chartId": 2, "width": 6, "height": 6}, "parent_id": "TAB-B"},
|
|
}
|
|
pos_b = {
|
|
"TAB-B": {"meta": {"children": ["CHART-2"]}},
|
|
"TAB-A": {"meta": {"children": ["CHART-1"]}},
|
|
"CHART-1": {"meta": {"chartId": 1, "width": 6, "height": 6}, "parent_id": "TAB-A"},
|
|
"CHART-2": {"meta": {"chartId": 2, "width": 6, "height": 6}, "parent_id": "TAB-B"},
|
|
}
|
|
fp_a = compute_layout_fingerprint(pos_a, [1, 2])
|
|
fp_b = compute_layout_fingerprint(pos_b, [1, 2])
|
|
# Insertion order is preserved (not sorted), so reordered tabs produce different fingerprints
|
|
assert fp_a != fp_b, (
|
|
"Tab reordering must produce different fingerprint "
|
|
"(insertion order preserved, not sorted alphabetically)"
|
|
)
|
|
|
|
|
|
def test_layout_fingerprint_chart_geometry_oriented():
|
|
"""Feature-037: Chart geometry (row/col/width/height) changes fingerprint."""
|
|
pos1 = {
|
|
"CHART-1": {"meta": {"chartId": 1, "width": 6, "height": 6, "row": 0, "col": 0}},
|
|
}
|
|
pos2 = {
|
|
"CHART-1": {"meta": {"chartId": 1, "width": 12, "height": 6, "row": 0, "col": 0}},
|
|
}
|
|
fp1 = compute_layout_fingerprint(pos1, [1])
|
|
fp2 = compute_layout_fingerprint(pos2, [1])
|
|
assert fp1 != fp2
|
|
# #endregion Test.DashboardTesting.VisualBaseline.LayoutTabHierarchy
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# #endregion Test.DashboardTesting.VisualBaselineLifecycle
|