'identity'], ), baselineAvailability: 'available', currentStateAvailability: 'available', compareState: CompareState::Drift, trustLevel: 'limited_confidence', evidenceQuality: 'meta', severityRecommendation: 'medium', findingCandidate: new CompareFindingCandidate( changeType: 'different_version', severity: 'medium', fingerprintBasis: [ 'policy_type' => 'conditionalAccessPolicy', 'subject_key' => 'cap-1', 'change_type' => 'different_version', ], evidencePayload: [ 'summary' => ['kind' => 'control_snapshot'], ], ), diagnostics: [ 'strategy_key' => 'future_control', ], ); $payload = $result->toArray(); expect($result->hasFindingCandidate())->toBeTrue() ->and($result->isGapState())->toBeFalse() ->and($payload['compare_state'])->toBe(CompareState::Drift->value) ->and($payload['baseline_availability'])->toBe('available') ->and($payload['current_state_availability'])->toBe('available') ->and($payload['projection']['platform_subject_class'])->toBe('control') ->and($payload['projection']['summary_kind'])->toBe('control_snapshot') ->and($payload['finding_candidate']['change_type'])->toBe('different_version') ->and($payload['finding_candidate']['severity'])->toBe('medium') ->and($payload['diagnostics']['strategy_key'])->toBe('future_control'); }); it('requires a finding candidate for drift results', function (): void { expect(fn (): CompareSubjectResult => new CompareSubjectResult( subjectIdentity: new CompareSubjectIdentity('intune', 'policy', 'deviceConfiguration', 'policy-1', 'policy-1'), projection: new CompareSubjectProjection('policy', 'intune', 'deviceConfiguration', 'Policy 1'), baselineAvailability: 'available', currentStateAvailability: 'missing', compareState: CompareState::Drift, trustLevel: 'limited_confidence', evidenceQuality: 'meta', ))->toThrow(InvalidArgumentException::class, 'require a finding candidate'); }); it('rejects non-drift results that still try to write findings', function (): void { expect(fn (): CompareSubjectResult => new CompareSubjectResult( subjectIdentity: new CompareSubjectIdentity('entra', 'control', 'conditionalAccessPolicy', 'cap-1', 'cap-1'), projection: new CompareSubjectProjection('control', 'entra', 'conditionalAccessPolicy', 'CAP 1'), baselineAvailability: 'available', currentStateAvailability: 'unknown', compareState: CompareState::Incomplete, trustLevel: 'unusable', evidenceQuality: 'missing', findingCandidate: new CompareFindingCandidate( changeType: 'different_version', severity: 'high', fingerprintBasis: ['subject_key' => 'cap-1'], evidencePayload: [], ), ))->toThrow(InvalidArgumentException::class, 'Only drift compare subject results'); }); it('treats unsupported, incomplete, ambiguous, and failed states as gap states', function (): void { $states = [ CompareState::Unsupported, CompareState::Incomplete, CompareState::Ambiguous, CompareState::Failed, ]; foreach ($states as $state) { $result = new CompareSubjectResult( subjectIdentity: new CompareSubjectIdentity('entra', 'control', 'conditionalAccessPolicy', 'cap-1', 'cap-1'), projection: new CompareSubjectProjection('control', 'entra', 'conditionalAccessPolicy', 'CAP 1'), baselineAvailability: 'available', currentStateAvailability: 'unknown', compareState: $state, trustLevel: 'unusable', evidenceQuality: 'missing', diagnostics: [ 'reason_code' => 'strategy_failed', 'gap_record' => ['reason_code' => 'strategy_failed'], ], ); expect($result->isGapState())->toBeTrue() ->and($result->gapReasonCode())->toBe('strategy_failed') ->and($result->gapRecord())->toBe(['reason_code' => 'strategy_failed']); } });