, held: list, holds: bool}|null, * include: list, * exclude: list * } */ public static function explain(Targeting $targeting, RequestContext $context): array { $include = self::rows($targeting->include, $context); $exclude = self::rows($targeting->exclude, $context); $loggedIn = $targeting->loggedIn === null ? null : ['wanted' => $targeting->loggedIn, 'holds' => $targeting->loggedIn === $context->isLoggedIn]; // **What the merchant is signed in AS is reported, not simulated.** // The one question this screen cannot answer is what somebody else // sees, and the roles they hold are the sharpest version of it: the // panel opens because they are an administrator, so "what does a // subscriber see" is unanswerable here. Both halves travel, and the // sentence says which is which. $roles = $targeting->roles === null ? null : [ 'wanted' => $targeting->roles, 'held' => $context->roles, 'holds' => array_intersect($targeting->roles, $context->roles) !== [], ]; // THE verdict, not A verdict. $admits = TargetingEvaluator::matches($targeting, $context); return [ 'admits' => $admits, // Null exactly when it was admitted, so a caller never has to hold // a reason beside a positive verdict — the pair that cannot occur. 'reason' => $admits ? null : self::reason($loggedIn, $roles, $include, $exclude), 'logged_in' => $loggedIn, 'roles' => $roles, 'include' => $include, // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude -- a Targeting key, not a get_posts() argument. 'exclude' => $exclude, ]; } /** * Which gate closed. * * **In the evaluator's own order**, which is the only order that can agree * with its verdict: the visitor predicate first, then exclude as an * unconditional veto, then include. Reading them in any other order would * name the wrong cause on a page that fails two of them at once — and a * merchant told to fix the include list when the checkout is in their * exclude list is worse off than one told nothing. * * @param array{wanted: bool, holds: bool}|null $loggedIn * @param array{wanted: list, held: list, holds: bool}|null $roles * @param list $include * @param list $exclude */ private static function reason(?array $loggedIn, ?array $roles, array $include, array $exclude): ?string { if ($loggedIn !== null && !$loggedIn['holds']) { return self::WRONG_VISITOR; } // Both visitor predicates before either list, in the evaluator's own // order — and `logged_in` before `roles`, because a signed-out visitor // holds no role and being told about the roles would send a merchant // to a list when what they needed was the sign-in question above it. if ($roles !== null && !$roles['holds']) { return self::WRONG_ROLE; } foreach ($exclude as $row) { if ($row['matches']) { return self::EXCLUDED; } } // An empty include list is "everywhere", never "nowhere" — the only // reading under which an exclude-only Optin means anything. return $include === [] ? null : self::NOT_INCLUDED; } /** * @param list $rules * @return list */ private static function rows(array $rules, RequestContext $context): array { return array_map( static fn (TargetingRule $rule): array => [ 'type' => $rule->type->value, 'value' => $rule->value, 'matches' => TargetingEvaluator::ruleMatches($rule, $context), ], $rules ); } }