satisfy(ActionSurfaceSlot::ListHeader) ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) ->satisfy(ActionSurfaceSlot::ListRowMoreMenu) ->satisfy(ActionSurfaceSlot::ListBulkMoreGroup) ->satisfy(ActionSurfaceSlot::ListEmptyState) ->satisfy(ActionSurfaceSlot::DetailHeader); } } final class ActionSurfaceValidatorMissingSlotStub { public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::CrudListFirstResource) ->satisfy(ActionSurfaceSlot::ListHeader) ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value); } } final class ActionSurfaceValidatorRunLogNoExportStub { public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::RunLog, ActionSurfaceType::ReadOnlyRegistryReport) ->withDefaults(new \App\Support\Ui\ActionSurface\ActionSurfaceDefaults( moreGroupLabel: 'More', exportIsDefaultBulkActionForReadOnly: false, )) ->satisfy(ActionSurfaceSlot::ListHeader) ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) ->satisfy(ActionSurfaceSlot::ListBulkMoreGroup) ->satisfy(ActionSurfaceSlot::ListEmptyState) ->satisfy(ActionSurfaceSlot::DetailHeader); } } final class ActionSurfaceValidatorExemptSlotWithoutReasonStub { public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::CrudListFirstResource) ->setSlot(ActionSurfaceSlot::ListHeader, ActionSurfaceSlotRequirement::exempt()) ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) ->satisfy(ActionSurfaceSlot::ListRowMoreMenu) ->satisfy(ActionSurfaceSlot::ListBulkMoreGroup) ->satisfy(ActionSurfaceSlot::ListEmptyState) ->satisfy(ActionSurfaceSlot::DetailHeader); } } final class ActionSurfaceValidatorMissingSurfaceTypeStub { public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, version: 2) ->satisfy(ActionSurfaceSlot::ListHeader) ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) ->satisfy(ActionSurfaceSlot::ListRowMoreMenu) ->satisfy(ActionSurfaceSlot::ListBulkMoreGroup) ->satisfy(ActionSurfaceSlot::ListEmptyState) ->satisfy(ActionSurfaceSlot::DetailHeader); } } final class ActionSurfaceValidatorIncompatibleInspectAffordanceStub { public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::CrudListFirstResource) ->satisfy(ActionSurfaceSlot::ListHeader) ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value) ->satisfy(ActionSurfaceSlot::ListRowMoreMenu) ->satisfy(ActionSurfaceSlot::ListBulkMoreGroup) ->satisfy(ActionSurfaceSlot::ListEmptyState) ->satisfy(ActionSurfaceSlot::DetailHeader); } } final class ActionSurfaceValidatorPrimaryLinkColumnWithoutReasonStub { public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport) ->satisfy(ActionSurfaceSlot::ListHeader) ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::PrimaryLinkColumn->value) ->satisfy(ActionSurfaceSlot::ListRowMoreMenu) ->satisfy(ActionSurfaceSlot::ListBulkMoreGroup) ->satisfy(ActionSurfaceSlot::ListEmptyState); } } final class ActionSurfaceValidatorNoDeclarationStub {} function actionSurfaceComponent(string $className): ActionSurfaceDiscoveredComponent { return new ActionSurfaceDiscoveredComponent( className: $className, componentType: ActionSurfaceComponentType::Resource, panelScopes: [ActionSurfacePanelScope::Tenant], ); } /** * @return array */ function repositoryDiscoveredActionSurfaceClasses(): array { return array_map( static fn (ActionSurfaceDiscoveredComponent $component): string => $component->className, ActionSurfaceValidator::withBaselineExemptions()->discoveredComponents(), ); } /** * @param array $issues */ function formatActionSurfaceIssues(array $issues): string { return implode("\n", array_map( static fn ($issue): string => $issue->format(), $issues, )); } it('passes when all required slots are declared', function (): void { $validator = new ActionSurfaceValidator( profileDefinition: new ActionSurfaceProfileDefinition, exemptions: new ActionSurfaceExemptions([]), ); $result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorCompleteStub::class)]); expect($result->hasIssues())->toBeFalse($result->formatForAssertion()); }); it('fails when a required slot is missing', function (): void { $validator = new ActionSurfaceValidator( profileDefinition: new ActionSurfaceProfileDefinition, exemptions: new ActionSurfaceExemptions([]), ); $result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorMissingSlotStub::class)]); expect($result->hasIssues())->toBeTrue(); expect($result->formatForAssertion())->toContain('Required slot is not declared'); }); it('fails missing declarations when no baseline exemption exists', function (): void { $validator = new ActionSurfaceValidator( profileDefinition: new ActionSurfaceProfileDefinition, exemptions: new ActionSurfaceExemptions([]), ); $result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorNoDeclarationStub::class)]); expect($result->hasIssues())->toBeTrue(); expect($result->formatForAssertion())->toContain('Missing action-surface declaration'); }); it('fails behavior-aware declarations when surface type is missing', function (): void { $validator = new ActionSurfaceValidator( profileDefinition: new ActionSurfaceProfileDefinition, exemptions: new ActionSurfaceExemptions([]), ); $result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorMissingSurfaceTypeStub::class)]); expect($result->hasIssues())->toBeTrue(); expect($result->formatForAssertion())->toContain('Behavior-aware declarations must define a surface type'); }); it('fails behavior-aware declarations when surface type and inspect affordance are incompatible', function (): void { $validator = new ActionSurfaceValidator( profileDefinition: new ActionSurfaceProfileDefinition, exemptions: new ActionSurfaceExemptions([]), ); $result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorIncompatibleInspectAffordanceStub::class)]); expect($result->hasIssues())->toBeTrue(); expect($result->formatForAssertion())->toContain('Inspect affordance "view_action" is incompatible with surface type "crud_list_first_resource"'); }); it('fails primary-link-column inspect affordances without an explicit reason', function (): void { $validator = new ActionSurfaceValidator( profileDefinition: new ActionSurfaceProfileDefinition, exemptions: new ActionSurfaceExemptions([]), ); $result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorPrimaryLinkColumnWithoutReasonStub::class)]); expect($result->hasIssues())->toBeTrue(); expect($result->formatForAssertion())->toContain('Primary link column inspect affordance requires a non-empty reason'); }); it('accepts missing declarations when explicit baseline exemption exists', function (): void { $validator = new ActionSurfaceValidator( profileDefinition: new ActionSurfaceProfileDefinition, exemptions: new ActionSurfaceExemptions([ ActionSurfaceValidatorNoDeclarationStub::class => 'Retrofit intentionally deferred in validator unit test.', ]), ); $result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorNoDeclarationStub::class)]); expect($result->hasIssues())->toBeFalse($result->formatForAssertion()); }); it('requires a bulk exemption reason when run-log export default is disabled', function (): void { $validator = new ActionSurfaceValidator( profileDefinition: new ActionSurfaceProfileDefinition, exemptions: new ActionSurfaceExemptions([]), ); $result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorRunLogNoExportStub::class)]); expect($result->hasIssues())->toBeTrue(); expect($result->formatForAssertion())->toContain('ReadOnly/RunLog profile disables Export default'); }); it('fails when slot exemption reason is missing', function (): void { $validator = new ActionSurfaceValidator( profileDefinition: new ActionSurfaceProfileDefinition, exemptions: new ActionSurfaceExemptions([]), ); $result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorExemptSlotWithoutReasonStub::class)]); expect($result->hasIssues())->toBeTrue(); expect($result->formatForAssertion())->toContain('Slot is marked exempt but exemption reason is missing or empty'); }); it('accepts the repository spec 192 inventory even when only inventory validation runs', function (): void { $validator = ActionSurfaceValidator::withBaselineExemptions(); $result = $validator->validateComponents([]); expect($result->hasIssues())->toBeFalse($result->formatForAssertion()); }); it('accepts the repository spec 193 monitoring inventory even when only inventory validation runs', function (): void { $validator = ActionSurfaceValidator::withBaselineExemptions(); $result = $validator->validateComponents([]); expect($result->hasIssues())->toBeFalse($result->formatForAssertion()); }); it('accepts the repository spec 195 residual inventory even when only inventory validation runs', function (): void { $validator = ActionSurfaceValidator::withBaselineExemptions(); $result = $validator->validateComponents([]); expect($result->hasIssues())->toBeFalse($result->formatForAssertion()); }); it('fails when a residual system candidate is missing a spec 195 closure entry', function (): void { $inventory = ActionSurfaceExemptions::spec195ResidualSurfaceInventory(); unset($inventory[\App\Filament\System\Pages\Dashboard::class]); $issues = ActionSurfaceValidator::validateSpec195ResidualInventoryFixture( inventory: $inventory, discoveredClasses: repositoryDiscoveredActionSurfaceClasses(), baselineExemptions: ActionSurfaceExemptions::baseline()->all(), residualCandidateClasses: [\App\Filament\System\Pages\Dashboard::class], ); expect(formatActionSurfaceIssues($issues)) ->toContain(\App\Filament\System\Pages\Dashboard::class) ->toContain('Residual action surface is missing a Spec 195 closure entry'); }); it('fails when a non-enrolled spec 195 residual surface has no reason category', function (): void { $inventory = ActionSurfaceExemptions::spec195ResidualSurfaceInventory(); $inventory[\App\Filament\Pages\ChooseWorkspace::class]['reasonCategory'] = null; $issues = ActionSurfaceValidator::validateSpec195ResidualInventoryFixture( inventory: $inventory, discoveredClasses: repositoryDiscoveredActionSurfaceClasses(), baselineExemptions: ActionSurfaceExemptions::baseline()->all(), ); expect(formatActionSurfaceIssues($issues)) ->toContain(\App\Filament\Pages\ChooseWorkspace::class) ->toContain('reason category is invalid or missing'); }); it('fails when a spec 195 residual surface is missing structured evidence', function (): void { $inventory = ActionSurfaceExemptions::spec195ResidualSurfaceInventory(); $inventory[\App\Filament\Pages\Workspaces\ManagedTenantsLanding::class]['evidence'] = []; $issues = ActionSurfaceValidator::validateSpec195ResidualInventoryFixture( inventory: $inventory, discoveredClasses: repositoryDiscoveredActionSurfaceClasses(), baselineExemptions: ActionSurfaceExemptions::baseline()->all(), ); expect(formatActionSurfaceIssues($issues)) ->toContain(\App\Filament\Pages\Workspaces\ManagedTenantsLanding::class) ->toContain('require at least one structured evidence descriptor'); }); it('fails when a retired spec 195 residual surface still remains baseline-exempt', function (): void { $inventory = ActionSurfaceExemptions::spec195ResidualSurfaceInventory(); $baselineExemptions = ActionSurfaceExemptions::baseline()->all(); $baselineExemptions[\App\Filament\Pages\BreakGlassRecovery::class] = 'Stale retired exemption.'; $issues = ActionSurfaceValidator::validateSpec195ResidualInventoryFixture( inventory: $inventory, discoveredClasses: repositoryDiscoveredActionSurfaceClasses(), baselineExemptions: $baselineExemptions, ); expect(formatActionSurfaceIssues($issues)) ->toContain(\App\Filament\Pages\BreakGlassRecovery::class) ->toContain('must not remain baseline-exempt'); });