232 lines
8.5 KiB
PHP
232 lines
8.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\OperationRun;
|
|
use App\Models\Tenant;
|
|
use App\Support\OpsUx\GovernanceRunDiagnosticSummaryBuilder;
|
|
use App\Support\OpsUx\SummaryCountsNormalizer;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
|
|
|
|
uses(RefreshDatabase::class, BuildsGovernanceArtifactTruthFixtures::class);
|
|
|
|
it('derives a blocked baseline capture summary with prerequisite-focused next steps', function (): void {
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
$run = OperationRun::factory()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'type' => 'baseline_capture',
|
|
'status' => 'completed',
|
|
'outcome' => 'blocked',
|
|
'context' => [
|
|
'reason_code' => 'missing_capability',
|
|
'baseline_capture' => [
|
|
'subjects_total' => 0,
|
|
'gaps' => [
|
|
'count' => 0,
|
|
],
|
|
],
|
|
],
|
|
'failure_summary' => [[
|
|
'reason_code' => 'missing_capability',
|
|
'message' => 'A required capability is missing for this run.',
|
|
]],
|
|
'completed_at' => now(),
|
|
]);
|
|
|
|
$summary = app(GovernanceRunDiagnosticSummaryBuilder::class)->build($run);
|
|
|
|
expect($summary)->not->toBeNull()
|
|
->and($summary?->headline)->toContain('No baseline was captured')
|
|
->and($summary?->dominantCause['label'])->toBe('No governed subjects captured')
|
|
->and($summary?->nextActionCategory)->toBe('refresh_prerequisite_data')
|
|
->and($summary?->affectedScaleCue['label'])->toBe('Capture scope')
|
|
->and($summary?->affectedScaleCue['value'])->toContain('0 governed subjects');
|
|
});
|
|
|
|
it('derives an ambiguous baseline compare summary with affected scale and scope review guidance', function (): void {
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
$run = OperationRun::factory()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'type' => 'baseline_compare',
|
|
'status' => 'completed',
|
|
'outcome' => 'partially_succeeded',
|
|
'context' => [
|
|
'baseline_compare' => [
|
|
'reason_code' => 'ambiguous_subjects',
|
|
'subjects_total' => 12,
|
|
'evidence_gaps' => [
|
|
'count' => 4,
|
|
],
|
|
'coverage' => [
|
|
'proof' => false,
|
|
],
|
|
],
|
|
],
|
|
'summary_counts' => [
|
|
'total' => 0,
|
|
'processed' => 0,
|
|
'errors_recorded' => 2,
|
|
],
|
|
'completed_at' => now(),
|
|
]);
|
|
|
|
$summary = app(GovernanceRunDiagnosticSummaryBuilder::class)->build($run);
|
|
|
|
expect($summary)->not->toBeNull()
|
|
->and($summary?->headline)->toContain('ambiguous subject matching')
|
|
->and($summary?->dominantCause['label'])->toBe('Ambiguous matches')
|
|
->and($summary?->nextActionCategory)->toBe('review_scope_or_ambiguous_matches')
|
|
->and($summary?->affectedScaleCue['label'])->toBe('Affected subjects')
|
|
->and($summary?->affectedScaleCue['value'])->toContain('4 governed subjects');
|
|
});
|
|
|
|
it('keeps execution outcome separate from artifact impact for stale evidence snapshot runs', function (): void {
|
|
$tenant = Tenant::factory()->create();
|
|
[, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
|
|
|
$run = $this->makeArtifactTruthRun($tenant, 'tenant.evidence.snapshot.generate');
|
|
|
|
$this->makeStaleArtifactTruthEvidenceSnapshot(
|
|
tenant: $tenant,
|
|
snapshotOverrides: [
|
|
'operation_run_id' => (int) $run->getKey(),
|
|
],
|
|
);
|
|
|
|
$summary = app(GovernanceRunDiagnosticSummaryBuilder::class)->build($run->fresh());
|
|
|
|
expect($summary)->not->toBeNull()
|
|
->and($summary?->executionOutcomeLabel)->toBe('Completed successfully')
|
|
->and($summary?->artifactImpactLabel)->not->toBe($summary?->executionOutcomeLabel)
|
|
->and($summary?->headline)->toContain('stale')
|
|
->and($summary?->nextActionCategory)->toBe('refresh_prerequisite_data');
|
|
});
|
|
|
|
it('derives resume capture or generation when a compare run records a resume token', function (): void {
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
$run = OperationRun::factory()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'type' => 'baseline_compare',
|
|
'status' => 'completed',
|
|
'outcome' => 'partially_succeeded',
|
|
'context' => [
|
|
'baseline_compare' => [
|
|
'resume_token' => 'resume-token-220',
|
|
'evidence_gaps' => [
|
|
'count' => 2,
|
|
],
|
|
],
|
|
],
|
|
'completed_at' => now(),
|
|
]);
|
|
|
|
$summary = app(GovernanceRunDiagnosticSummaryBuilder::class)->build($run);
|
|
|
|
expect($summary)->not->toBeNull()
|
|
->and($summary?->nextActionCategory)->toBe('resume_capture_or_generation')
|
|
->and($summary?->headline)->toContain('evidence capture still needs to resume');
|
|
});
|
|
|
|
it('keeps deterministic multi-cause ordering for degraded review composition runs', function (): void {
|
|
$tenant = Tenant::factory()->create();
|
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
|
|
|
$run = $this->makeArtifactTruthRun($tenant, 'tenant.review.compose');
|
|
$snapshot = $this->makeStaleArtifactTruthEvidenceSnapshot($tenant, [
|
|
'operation_run_id' => null,
|
|
]);
|
|
|
|
$this->makeArtifactTruthReview(
|
|
tenant: $tenant,
|
|
user: $user,
|
|
snapshot: $snapshot,
|
|
reviewOverrides: [
|
|
'operation_run_id' => (int) $run->getKey(),
|
|
'completeness_state' => 'partial',
|
|
],
|
|
summaryOverrides: [
|
|
'section_state_counts' => [
|
|
'complete' => 4,
|
|
'partial' => 1,
|
|
'missing' => 1,
|
|
'stale' => 0,
|
|
],
|
|
],
|
|
);
|
|
|
|
$builder = app(GovernanceRunDiagnosticSummaryBuilder::class);
|
|
$first = $builder->build($run->fresh());
|
|
$second = $builder->build($run->fresh());
|
|
|
|
expect($first)->not->toBeNull()
|
|
->and($second)->not->toBeNull()
|
|
->and($first?->dominantCause['label'])->toBe('Missing sections')
|
|
->and($first?->secondaryCauses[0]['label'] ?? null)->toBe('Stale evidence basis')
|
|
->and($first?->secondaryCauses)->toEqual($second?->secondaryCauses)
|
|
->and($first?->headline)->toContain('missing sections and stale evidence');
|
|
});
|
|
|
|
it('derives no further action for publishable review pack runs', function (): void {
|
|
$tenant = Tenant::factory()->create();
|
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
|
|
|
$run = $this->makeArtifactTruthRun($tenant, 'tenant.review_pack.generate');
|
|
$snapshot = $this->makeArtifactTruthEvidenceSnapshot($tenant, [
|
|
'operation_run_id' => null,
|
|
]);
|
|
$review = $this->makeArtifactTruthReview($tenant, $user, $snapshot, [
|
|
'operation_run_id' => null,
|
|
]);
|
|
|
|
$this->makeArtifactTruthReviewPack(
|
|
tenant: $tenant,
|
|
user: $user,
|
|
snapshot: $snapshot,
|
|
review: $review,
|
|
packOverrides: [
|
|
'operation_run_id' => (int) $run->getKey(),
|
|
],
|
|
);
|
|
|
|
$summary = app(GovernanceRunDiagnosticSummaryBuilder::class)->build($run->fresh());
|
|
|
|
expect($summary)->not->toBeNull()
|
|
->and($summary?->nextActionCategory)->toBe('no_further_action')
|
|
->and($summary?->nextActionText)->toBe('No action needed.');
|
|
});
|
|
|
|
it('does not invent new summary count keys while deriving scale cues', function (): void {
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
$run = OperationRun::factory()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'type' => 'baseline_compare',
|
|
'status' => 'completed',
|
|
'outcome' => 'partially_succeeded',
|
|
'summary_counts' => [
|
|
'total' => 7,
|
|
'custom_noise' => 99,
|
|
],
|
|
'context' => [
|
|
'baseline_compare' => [],
|
|
],
|
|
'completed_at' => now(),
|
|
]);
|
|
|
|
$summary = app(GovernanceRunDiagnosticSummaryBuilder::class)->build($run);
|
|
|
|
expect($summary)->not->toBeNull()
|
|
->and(array_keys(SummaryCountsNormalizer::normalize(is_array($run->summary_counts) ? $run->summary_counts : [])))
|
|
->toBe(['total'])
|
|
->and($summary?->affectedScaleCue['source'])->toBe('summary_counts')
|
|
->and($summary?->affectedScaleCue['label'])->toBe('Total');
|
|
});
|