TenantAtlas/tests/Feature/Guards/ActionSurfaceValidatorTest.php
Ahmed Darrazi 72faa38472 feat: require inspect affordance for lists
- Replace view-only row buttons with clickable rows (recordUrl)\n- Update action-surface contract slot to InspectAffordance + validator support\n- Add golden guard tests + contract doc\n- Update SpecKit constitution/templates to include inspection affordance rule
2026-02-08 21:29:20 +01:00

154 lines
6.5 KiB
PHP

<?php
declare(strict_types=1);
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\ActionSurfaceDiscoveredComponent;
use App\Support\Ui\ActionSurface\ActionSurfaceExemptions;
use App\Support\Ui\ActionSurface\ActionSurfaceProfileDefinition;
use App\Support\Ui\ActionSurface\ActionSurfaceSlotRequirement;
use App\Support\Ui\ActionSurface\ActionSurfaceValidator;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceComponentType;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
final class ActionSurfaceValidatorCompleteStub
{
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
->satisfy(ActionSurfaceSlot::ListHeader)
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->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)
->satisfy(ActionSurfaceSlot::ListHeader)
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value);
}
}
final class ActionSurfaceValidatorRunLogNoExportStub
{
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::RunLog)
->withDefaults(new \App\Support\Ui\ActionSurface\ActionSurfaceDefaults(
moreGroupLabel: 'More',
exportIsDefaultBulkActionForReadOnly: false,
))
->satisfy(ActionSurfaceSlot::ListHeader)
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup)
->satisfy(ActionSurfaceSlot::ListEmptyState)
->satisfy(ActionSurfaceSlot::DetailHeader);
}
}
final class ActionSurfaceValidatorExemptSlotWithoutReasonStub
{
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
->setSlot(ActionSurfaceSlot::ListHeader, ActionSurfaceSlotRequirement::exempt())
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
->satisfy(ActionSurfaceSlot::ListRowMoreMenu)
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup)
->satisfy(ActionSurfaceSlot::ListEmptyState)
->satisfy(ActionSurfaceSlot::DetailHeader);
}
}
final class ActionSurfaceValidatorNoDeclarationStub {}
function actionSurfaceComponent(string $className): ActionSurfaceDiscoveredComponent
{
return new ActionSurfaceDiscoveredComponent(
className: $className,
componentType: ActionSurfaceComponentType::Resource,
panelScopes: [ActionSurfacePanelScope::Tenant],
);
}
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('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');
});