toBe('EC21233'); }); it('builds stable XL Vask automation item signatures', function (): void { $items = [ ['product_id' => 20, 'quantity' => 1, 'price' => 275], ['product_id' => 10, 'quantity' => 2, 'price' => 649], ['product_id' => 20, 'quantity' => 1, 'price' => 0], ]; expect(xlvask_automation_service::itemSignaturePartsForAutomation($items)) ->toBe([ '10:2:649', '20:1:0', '20:1:275', ]); }); it('identifies strict price agreement matches by product, quantity, and total', function (): void { $usageItems = [ ['product_id' => 10, 'quantity' => 1, 'price' => 519], ['product_id' => 24, 'quantity' => 1, 'price' => 39], ['product_id' => 21, 'quantity' => 1, 'price' => 79], ]; $orderItems = [ ['product_id' => 21, 'quantity' => 1, 'price' => 79], ['product_id' => 10, 'quantity' => 1, 'price' => 519], ['product_id' => 24, 'quantity' => 1, 'price' => 39], ]; expect(xlvask_automation_service::isExactItemMatchForAutomation($usageItems, $orderItems)) ->toBeTrue(); }); it('rejects price agreement automation when product lines differ despite equal total', function (): void { $usageItems = [ ['product_id' => 10, 'quantity' => 1, 'price' => 519], ['product_id' => 24, 'quantity' => 1, 'price' => 39], ]; $orderItems = [ ['product_id' => 10, 'quantity' => 1, 'price' => 519], ['product_id' => 50, 'quantity' => 1, 'price' => 39], ]; expect(xlvask_automation_service::itemsTotalForAutomation($usageItems)) ->toBe(xlvask_automation_service::itemsTotalForAutomation($orderItems)) ->and(xlvask_automation_service::isExactItemMatchForAutomation($usageItems, $orderItems)) ->toBeFalse(); }); it('normalizes persisted XL Vask usage-log rows before helper hydration', function (): void { $row = xlvask_automation_service::normalizeUsageLogRowForAutomation([ 'id' => 47086, 'WashId' => 'cc1eabc1-b4e1-425b-ad7c-dc68f8c97ceb', 'WashItems' => '[{"OriginalProductName":"Bus","Count":1}]', ]); expect($row) ->not->toHaveKey('id') ->and($row['WashItems'])->toBe([ [ 'OriginalProductName' => 'Bus', 'Count' => 1, ], ]); }); it('builds stable OpenAI cache keys for identical automation input', function (): void { $prompt = 'Prompt'; $schemaName = 'xlvask_automation'; $schema = [ 'required' => ['action'], 'properties' => [ 'confidence' => ['type' => 'number'], 'action' => ['type' => 'string'], ], ]; $schemaWithDifferentKeyOrder = [ 'properties' => [ 'action' => ['type' => 'string'], 'confidence' => ['type' => 'number'], ], 'required' => ['action'], ]; $payloadA = [ 'usage_log' => [ 'registration' => 'AB12345', 'creation_allowed' => true, ], 'candidate_orders' => [ ['id' => 10, 'items' => [['product_id' => 1, 'quantity' => 1, 'price' => 100]]], ], ]; $payloadB = [ 'candidate_orders' => [ ['items' => [['price' => 100, 'quantity' => 1, 'product_id' => 1]], 'id' => 10], ], 'usage_log' => [ 'creation_allowed' => true, 'registration' => 'AB12345', ], ]; expect(xlvask_automation_service::openAiCacheKeyForAutomation($schemaName, $prompt, $payloadA, $schema, 0.1)) ->toBe(xlvask_automation_service::openAiCacheKeyForAutomation($schemaName, $prompt, $payloadB, $schemaWithDifferentKeyOrder, 0.1)); }); it('changes OpenAI cache keys when automation eligibility input changes', function (): void { $schema = ['type' => 'object']; $newerWashPayload = ['usage_log' => ['creation_allowed' => false, 'age_bucket' => 'newer_than_6_hours']]; $olderWashPayload = ['usage_log' => ['creation_allowed' => true, 'age_bucket' => 'older_than_6_hours']]; expect(xlvask_automation_service::openAiCacheKeyForAutomation('xlvask_automation', 'Prompt', $newerWashPayload, $schema, 0.1)) ->not->toBe(xlvask_automation_service::openAiCacheKeyForAutomation('xlvask_automation', 'Prompt', $olderWashPayload, $schema, 0.1)); }); it('declares a persistent OpenAI cache table for XL Vask automation', function (): void { $bootstrapContent = file_get_contents(WD . '/classes/xlvask_usage_logs_schema_bootstrap.php'); expect($bootstrapContent) ->toContain('xlvask_automation_openai_cache') ->toContain('UNIQUE KEY `uniq_xlvask_openai_cache_key` (`cache_key`)'); }); it('declares auditable and idempotent XL Vask autopilot run tables', function (): void { $bootstrapContent = file_get_contents(WD . '/classes/xlvask_usage_logs_schema_bootstrap.php'); expect($bootstrapContent) ->toContain('xlvask_autopilot_runs') ->toContain('xlvask_autopilot_run_items') ->toContain('xlvask_automation_audit') ->toContain('UNIQUE KEY `uniq_xlvask_autopilot_run_idempotency` (`idempotency_key`)') ->toContain('updated_at'); }); it('declares cached amount summary columns for XL Vask usage logs', function (): void { $bootstrapContent = file_get_contents(WD . '/classes/xlvask_usage_logs_schema_bootstrap.php'); expect($bootstrapContent) ->toContain('cached_total_net_amount') ->toContain('cached_primary_product_name') ->toContain('cached_amount_at'); }); it('keeps automatic XL Vask execution scoped to exact attachments', function (): void { $serviceContent = file_get_contents(WD . '/classes/xlvask_automation_service.php'); expect($serviceContent) ->toContain('openAiAttachHardGuardsPass($suggestion, $context)') ->toContain('self::isExactItemMatchForAutomation((array)($context[\'items\'] ?? []), (array)($candidate[\'order_items\'] ?? []))') ->toContain("\$candidateOrderJson = \$suggestion['candidate_order_json'] ?? null;") ->toContain("return 'Automatisk accepteret: Prisoverensstemmelse.';"); }); it('keeps automatic order creation behind calibration and uniqueness readiness gates', function (): void { $serviceContent = file_get_contents(WD . '/classes/xlvask_automation_service.php'); expect($serviceContent) ->toContain('if ($action === self::ACTION_CREATE) {') ->toContain('automatic_order_creation_enabled->isTrue()') ->toContain("(string)(\$suggestion['certainty'] ?? '') !== 'certain'") ->toContain('washIdUniquenessReady()'); }); it('never classifies missing or undersized calibration evidence as certain', function (): void { expect(xlvask_automation_service::classifyCertaintyForAutomation([]))->toBe('uncertain') ->and(xlvask_automation_service::classifyCertaintyForAutomation([ 'active' => true, 'precision_value' => 0.999, 'wilson_lower_bound' => 0.99, 'holdout_examples' => 40, 'overall_examples' => 199, 'segment_examples' => 30, 'contradictions' => 0, ]))->toBe('uncertain'); }); it('classifies only a qualifying contradiction-free calibration artifact as certain', function (): void { $artifact = [ 'active' => true, 'precision_value' => 0.995, 'wilson_lower_bound' => 0.98, 'holdout_examples' => 200, 'overall_examples' => 200, 'segment_examples' => 30, 'contradictions' => 0, ]; expect(xlvask_automation_service::classifyCertaintyForAutomation($artifact))->toBe('certain') ->and(xlvask_automation_service::classifyCertaintyForAutomation($artifact, true, ['conflict']))->toBe('uncertain'); }); it('requires two source observations and a six-hour stable window for automatic actions', function (): void { $now = strtotime('2026-08-03 12:00:00'); $stable = [ 'source_observation_count' => 2, 'source_observed_at' => '2026-08-03 11:55:00', 'source_stable_since' => '2026-08-03 06:00:00', ]; expect(xlvask_automation_service::sourceIsStableForAutomatic($stable, $now))->toBeTrue() ->and(xlvask_automation_service::sourceIsStableForAutomatic([ ...$stable, 'source_observation_count' => 1, ], $now))->toBeFalse() ->and(xlvask_automation_service::sourceIsStableForAutomatic([ ...$stable, 'source_stable_since' => '2026-08-03 06:00:01', ], $now))->toBeFalse(); }); it('builds order-independent revision hashes for XL Vask source payloads', function (): void { $first = ['WashId' => 'wash-1', 'Customer' => 'A', 'WashItems' => [['Count' => 1, 'Name' => 'Vask']]]; $second = ['WashItems' => [['Name' => 'Vask', 'Count' => 1]], 'Customer' => 'A', 'WashId' => 'wash-1']; expect(xlvask_usage_logs_o::sourceHashForAutomation($first)) ->toBe(xlvask_usage_logs_o::sourceHashForAutomation($second)); }); it('treats reordered XL Vask wash items as the same source revision', function (): void { $first = ['WashId' => 'wash-1', 'WashItems' => [ ['ProductId' => 2, 'Count' => 1], ['ProductId' => 1, 'Count' => 2], ]]; $second = ['WashId' => 'wash-1', 'WashItems' => [ ['Count' => 2, 'ProductId' => 1], ['Count' => 1, 'ProductId' => 2], ]]; expect(xlvask_usage_logs_o::sourceHashForAutomation($first)) ->toBe(xlvask_usage_logs_o::sourceHashForAutomation($second)); }); it('uses the existing OpenAI module with strict no-retention planner settings', function (): void { $openAi = file_get_contents(WD . '/classes/openai.php'); $automation = file_get_contents(WD . '/classes/xlvask_automation_service.php'); expect($openAi)->toContain("'store' => false") ->toContain("'role' => 'developer'") ->toContain("'role' => 'user'") ->toContain("if (\$status !== 'completed')") ->toContain("=== 'refusal'") ->toContain("'_openai_usage' => [") ->and($automation)->toContain("private const PLANNER_MODEL = 'MiniMax-M3'") ->toContain('candidate_order_id') ->not->toContain('opaque_context_id') ->not->toContain("'product_name' =>"); }); it('verifies XL Vask TLS and never logs authorization headers or response bodies', function (): void { $client = file_get_contents(WD . '/modules/xlvask/classes/xlvask_request.php'); expect($client)->toContain('CURLOPT_SSL_VERIFYPEER, true') ->toContain('CURLOPT_SSL_VERIFYHOST, 2') ->not->toContain('Headers: " . implode') ->not->toContain('Response: $response'); }); it('uses a dedicated queued XL Vask autopilot service with scoped durable runs', function (): void { $serviceContent = file_get_contents(WD . '/classes/xlvask_autopilot_service.php'); expect($serviceContent) ->toContain('public function createRun(') ->toContain('public function processQueuedRuns(') ->toContain('public function getRun(') ->toContain('ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)') ->toContain('scope_hall_ids_json') ->toContain('ai_timeline') ->toContain('ai_batch_size') ->toContain('ai_max_cost_usd') ->toContain('lease_expires_at') ->toContain('attempt_count') ->toContain('next_attempt_at') ->toContain('$renewLease') ->toContain("phase = 'retry_wait'"); }); it('rejects execute run creation unless current server readiness allows execute mode', function (): void { $serviceContent = file_get_contents(WD . '/classes/xlvask_autopilot_service.php'); expect($serviceContent) ->toContain("if (\$mode === 'execute')") ->toContain('capabilitiesReadOnly(') ->toContain("!in_array('execute', (array)(\$capabilities['allowed_modes'] ?? []), true)"); }); it('enforces distinct runtime capabilities for execute dry-run and replay modes', function (): void { expect(xlvask_autopilot_service::modeCapabilities('execute'))->toBe([ 'import' => true, 'persist_plans' => true, 'execute_actions' => true, 'run_artifacts' => true, ])->and(xlvask_autopilot_service::modeCapabilities('dry_run'))->toBe([ 'import' => true, 'persist_plans' => true, 'execute_actions' => false, 'run_artifacts' => true, ])->and(xlvask_autopilot_service::modeCapabilities('replay'))->toBe([ 'import' => false, 'persist_plans' => false, 'execute_actions' => false, 'run_artifacts' => true, ]); }); it('preserves GUID hall scopes and rejects empty scope values at runtime', function (): void { expect(xlvask_autopilot_service::normalizeHallScope([ ' 845d29a1-a7d2-4e3b-bbc3-2b13242d744a ', '', '845d29a1-a7d2-4e3b-bbc3-2b13242d744a', ]))->toBe(['845d29a1-a7d2-4e3b-bbc3-2b13242d744a']); }); it('keeps explicit retry keys idempotent and actor-bound', function (): void { expect(xlvask_autopilot_service::idempotencyKey('retry-1', 10, 'nonce-a')) ->toBe(xlvask_autopilot_service::idempotencyKey('retry-1', 10, 'nonce-b')) ->not->toBe(xlvask_autopilot_service::idempotencyKey('retry-1', 11, 'nonce-a')) ->and(xlvask_autopilot_service::idempotencyKey('', 10, 'nonce-a')) ->not->toBe(xlvask_autopilot_service::idempotencyKey('', 10, 'nonce-b')); expect(xlvask_autopilot_service::requestFingerprint(['mode' => 'execute', 'ids' => [1]])) ->not->toBe(xlvask_autopilot_service::requestFingerprint(['mode' => 'dry_run', 'ids' => [1]])); }); it('fails preview snapshots closed when source hash or optimistic version changes', function (): void { $current = ['expected_version' => 4, 'source_hash' => str_repeat('a', 64)]; expect(xlvask_autopilot_service::previewSnapshotMatches(4, str_repeat('a', 64), $current))->toBeTrue() ->and(xlvask_autopilot_service::previewSnapshotMatches(5, str_repeat('a', 64), $current))->toBeFalse() ->and(xlvask_autopilot_service::previewSnapshotMatches(4, str_repeat('b', 64), $current))->toBeFalse(); }); it('requires explicit run modes and serializes active execute runs in schema', function (): void { $autopilot = file_get_contents(WD . '/classes/xlvask_autopilot_service.php'); $schema = file_get_contents(WD . '/classes/xlvask_usage_logs_schema_bootstrap.php'); expect($autopilot) ->toContain("(\$input['mode'] ?? '')") ->toContain('An explicit XL Vask autopilot mode is required.') ->and($schema) ->toContain('active_execute_slot') ->toContain('uniq_xlvask_active_execute_run'); }); it('uses exact adjudicated suggestion labels and a chronological holdout for calibration', function (): void { $serviceContent = file_get_contents(WD . '/classes/xlvask_autopilot_service.php'); expect($serviceContent) ->toContain('INNER JOIN xlvask_automation_suggestions s ON s.id = l.suggestion_id') ->toContain('chronological_80_20_by_suggestion_created_at_and_id') ->toContain('xlvask_automation_calibration_label_events') ->toContain("'label_snapshot' => \$snapshot") ->not->toContain("SUM(f.decision = 'accepted')"); }); it('keeps XL Vask automation execution inside a transactional revalidation boundary', function (): void { $serviceContent = file_get_contents(WD . '/classes/xlvask_automation_service.php'); expect($serviceContent) ->toContain('$connection->begin_transaction()') ->toContain('$connection->commit()') ->toContain('$connection->rollback()') ->toContain('SELECT * FROM xlvask_usage_logs WHERE id = {$usageLogId} FOR UPDATE') ->toContain("findSameDayCandidateOrders(\$lockedLog, (array)\$context['proposed_order'], true)") ->toContain('SELECT id FROM order_items WHERE order_id IN (') ->toContain("if (\$action === self::ACTION_CREATE && \$currentCandidates !== [])") ->toContain("WHERE xlvask_normalized_wash_id = NULLIF(LOWER(TRIM('{\$washId}')), '') FOR UPDATE") ->toContain('XL Vask-kildedata blev ændret efter evalueringen.'); }); it('permits only identity-bound calibrated OpenAI automatic actions behind deterministic hard guards', function (): void { $serviceContent = file_get_contents(WD . '/classes/xlvask_automation_service.php'); expect($serviceContent) ->toContain("!== self::SOURCE_OPENAI") ->toContain('hardGuardsPassForCertainty($suggestion, $context)') ->toContain('openAiAttachHardGuardsPass($suggestion, $context)') ->toContain('policyAllowsActionReadOnly($action)') ->toContain('sourceIsStableForAutomatic($context)') ->toContain('washIdUniquenessReady()'); }); it('pins the complete planner identity and invalidates cache keys with it', function (): void { $identity = xlvask_automation_service::automationIdentityForAutomation(); expect($identity) ->toMatchArray([ 'policy_version' => 'xlvask-ai-auto-v2', 'model' => 'MiniMax-M3', 'prompt_version' => 'xlvask-planner-da-v2', 'schema_version' => 'xlvask-automation-schema-v2', 'cache_version' => 2, ]) ->and($identity['identity_hash'])->toHaveLength(64) ->and($identity['prompt_hash'])->toHaveLength(64) ->and($identity['schema_hash'])->toHaveLength(64); }); it('accepts only completed structured OpenAI responses and records the resolved model', function (): void { $parsed = openai::parseJsonTaskResponse([ 'status' => 'completed', 'model' => 'gpt-5.6-sol', 'output' => [[ 'content' => [[ 'type' => 'output_text', 'text' => '{"action":"none"}', ]], ]], ]); expect($parsed)->toBe([ 'action' => 'none', '_openai_response_model' => 'gpt-5.6-sol', '_openai_usage' => [ 'input_tokens' => 0, 'output_tokens' => 0, 'total_tokens' => 0, 'service_tier' => '', ], ]); }); it('fails incomplete and refusal OpenAI responses closed', function (): void { expect(fn() => openai::parseJsonTaskResponse([ 'status' => 'incomplete', 'incomplete_details' => ['reason' => 'max_output_tokens'], ]))->toThrow(\classes\openai_request_exception::class) ->and(fn() => openai::parseJsonTaskResponse([ 'status' => 'completed', 'model' => 'gpt-5.6-sol', 'output' => [['content' => [['type' => 'refusal', 'refusal' => 'no']]]], ]))->toThrow(\classes\openai_request_exception::class); try { openai::parseJsonTaskResponse([ 'status' => 'incomplete', 'incomplete_details' => ['reason' => 'max_output_tokens'], ]); $incompleteRetryable = null; } catch (\classes\openai_request_exception $exception) { $incompleteRetryable = $exception->retryable; } try { openai::parseJsonTaskResponse([ 'status' => 'completed', 'model' => 'gpt-5.6-sol', 'output' => [['content' => [['type' => 'refusal', 'refusal' => 'no']]]], ]); $refusalRetryable = null; } catch (\classes\openai_request_exception $exception) { $refusalRetryable = $exception->retryable; } expect($incompleteRetryable)->toBeTrue() ->and($refusalRetryable)->toBeFalse(); }); it('declares explicit migration-only schema activation and server policy controls', function (): void { $schema = file_get_contents(WD . '/classes/xlvask_usage_logs_schema_bootstrap.php'); $migration = file_get_contents(WD . '/modules/xlvask/migrations/20260804_xlvask_ai_auto_policy_v2.php'); $policy = file_get_contents(WD . '/classes/xlvask_automation_policy_service.php'); expect($schema) ->toContain('applyExplicitMigration') ->toContain('migrationStatus') ->toContain('xlvask_automation_policy_state') ->toContain('xlvask_automation_action_events') ->and($migration)->toContain('operator-invoked') ->toContain('applyExplicitMigration') ->and($policy)->toContain('ATTACH_DAILY_CAP = 100') ->toContain('ATTACH_PER_HALL_DAILY_CAP = 10') ->toContain('CREATE_DAILY_CAP = 20') ->toContain('CREATE_PER_HALL_DAILY_CAP = 3') ->toContain("review_outcome = 'correct'"); }); it('fails migration readiness closed for partial runtime schema and missing active-run uniqueness', function (): void { $schema = file_get_contents(WD . '/classes/xlvask_usage_logs_schema_bootstrap.php'); expect($schema) ->toContain("'required_indexes' => \$requiredIndexes") ->toContain("'missing_indexes' => \$missingIndexes") ->toContain("'preflight_conflicts' => \$conflicts") ->toContain('multiple_active_execute_runs:') ->toContain('uniq_xlvask_active_execute_run') ->toContain("'xlvask_automation_policy_state' => [") ->toContain("'xlvask_automation_action_events' => [") ->toContain("'xlvask_automation_calibrations' => [") ->toContain("'xlvask_autopilot_runs' => ["); }); it('treats policy and budget stops as resumable run pauses instead of suggestion failures', function (): void { $policy = file_get_contents(WD . '/classes/xlvask_automation_policy_service.php'); $automation = file_get_contents(WD . '/classes/xlvask_automation_service.php'); expect($policy) ->toContain('final class xlvask_automation_control_stop') ->toContain("'budget_exhausted'") ->toContain("'calibration_revoked'") ->and($automation) ->toContain('if ($e instanceof xlvask_automation_control_stop)') ->toContain("'control_stop' => true") ->toContain("\$circuitBreaker = (string)(\$result['control_stop_reason'] ?? 'policy_control_stop')"); }); it('makes exact adjudication retries idempotent and rejects a changed outcome', function (): void { expect(\classes\xlvask_automation_policy_service::adjudicationRetryMatches('correct', 'correct'))->toBeTrue() ->and(\classes\xlvask_automation_policy_service::adjudicationRetryMatches('correct', 'duplicate'))->toBeFalse() ->and(\classes\xlvask_automation_policy_service::adjudicationRetryMatches(null, 'correct'))->toBeFalse(); }); it('requires current usage revision and review state for row action eligibility', function (): void { $suggestion = [ 'status' => 'suggested', 'action' => 'attach_order', 'expected_version' => 7, 'input_hash' => str_repeat('a', 64), ]; $usage = [ 'resolution_state' => 'needs_review', 'import_state' => 'unchanged', 'ignored_at' => null, 'FinishStatus' => 1, 'expected_version' => 7, 'source_hash' => str_repeat('a', 64), ]; expect(xlvask_automation_service::suggestionMatchesCurrentUsageForReview($suggestion, $usage))->toBeTrue() ->and(xlvask_automation_service::suggestionMatchesCurrentUsageForReview($suggestion, [...$usage, 'ignored_at' => '2026-08-04 12:00:00']))->toBeFalse() ->and(xlvask_automation_service::suggestionMatchesCurrentUsageForReview($suggestion, [...$usage, 'expected_version' => 8]))->toBeFalse() ->and(xlvask_automation_service::suggestionMatchesCurrentUsageForReview($suggestion, [...$usage, 'import_state' => 'invalid']))->toBeFalse(); }); it('emits explicit fail-closed per-row action flags and supersedes ignored suggestions', function (): void { $automation = file_get_contents(WD . '/classes/xlvask_automation_service.php'); $autopilot = file_get_contents(WD . '/classes/xlvask_autopilot_service.php'); $projectionStart = strpos((string)$automation, 'private function readProjectionActionFlags'); $projectionEnd = strpos((string)$automation, 'private function decodeJsonField', (int)$projectionStart); $projection = substr((string)$automation, (int)$projectionStart, (int)$projectionEnd - (int)$projectionStart); expect($automation) ->toContain("'can_ignore' =>") ->toContain("'can_attach_order' =>") ->toContain("'can_create_order' =>") ->toContain('suggestionMatchesCurrentUsageForReview($suggestion, $usageRow)') ->toContain('Current candidate') ->and($autopilot) ->toContain("SET status = 'superseded', updated_at = NOW()") ->toContain("WHERE usage_log_id = {\$usageId} AND status = 'suggested'"); expect($projection)->not->toContain('$this->buildContext('); }); it('keeps scheduled automation deploy-order safe without interrupting ordinary sync', function (): void { $tasks = (string)file_get_contents(WD . '/modules/xlvask/helpers/xlvask_tasks.php'); $importStart = strpos($tasks, 'public function runImportTasks(): void'); $importEnd = strpos($tasks, '/** Enqueue automatic work', (int)$importStart); $importBody = substr($tasks, (int)$importStart, (int)$importEnd - (int)$importStart); expect($tasks) ->toContain('$this->runCleanupTasks();') ->toContain('$this->runScheduledAutomationIfReady();') ->toContain('scheduledExecutionAllowed($migrationStatus, $capabilities)') ->and($importBody)->not->toContain("createRun(['mode' => 'execute']"); expect(strpos($tasks, '$this->runCleanupTasks();')) ->toBeLessThan(strpos($tasks, '$this->runScheduledAutomationIfReady();')); expect(xlvask_autopilot_service::scheduledExecutionAllowed(['ready' => false], ['allowed_modes' => ['execute']]))->toBeFalse() ->and(xlvask_autopilot_service::scheduledExecutionAllowed(['ready' => true], ['allowed_modes' => ['dry_run', 'replay']]))->toBeFalse() ->and(xlvask_autopilot_service::scheduledExecutionAllowed(['ready' => true], ['allowed_modes' => ['execute']]))->toBeTrue(); }); it('propagates only retryable OpenAI failures into a durable run retry', function (): void { $retryable = new \classes\openai_request_exception('temporary', true, 503); $refusal = new \classes\openai_request_exception('refused', false, null); expect(xlvask_automation_service::openAiFailureRequiresDurableRetry($retryable, 42))->toBeTrue() ->and(xlvask_automation_service::openAiFailureRequiresDurableRetry($retryable, null))->toBeFalse() ->and(xlvask_automation_service::openAiFailureRequiresDurableRetry($refusal, 42))->toBeFalse(); $automation = (string)file_get_contents(WD . '/classes/xlvask_automation_service.php'); expect($automation) ->toContain('catch (openai_request_exception $exception)') ->toContain('throw $exception;') ->toContain('OpenAI kunne ikke levere et anvendeligt forslag.'); }); it('binds financial execution to the locked suggestion revision and indexed wash id', function (): void { $automation = (string)file_get_contents(WD . '/classes/xlvask_automation_service.php'); $usage = ['id' => 7, 'expected_version' => 3, 'source_hash' => str_repeat('a', 64)]; $suggestion = ['usage_log_id' => 7, 'expected_version' => 3, 'input_hash' => str_repeat('a', 64)]; expect(xlvask_automation_service::suggestionMatchesLockedUsageForExecution($suggestion, $usage))->toBeTrue() ->and(xlvask_automation_service::suggestionMatchesLockedUsageForExecution([...$suggestion, 'expected_version' => 4], $usage))->toBeFalse() ->and(xlvask_automation_service::suggestionMatchesLockedUsageForExecution([...$suggestion, 'input_hash' => str_repeat('b', 64)], $usage))->toBeFalse() ->and(xlvask_automation_service::suggestionMatchesLockedUsageForExecution([...$suggestion, 'usage_log_id' => 8], $usage))->toBeFalse(); expect($automation) ->toContain('suggestionMatchesLockedUsageForExecution($suggestion, $lockedUsage)') ->toContain('WHERE xlvask_normalized_wash_id = NULLIF(LOWER(TRIM') ->not->toContain('WHERE LOWER(TRIM(wash_id)) = LOWER(TRIM'); }); it('binds calibration evidence and snapshots to current planner identity and resolved model', function (): void { $autopilot = (string)file_get_contents(WD . '/classes/xlvask_autopilot_service.php'); $policy = (string)file_get_contents(WD . '/classes/xlvask_automation_policy_service.php'); $identity = ['policy_version' => 'v2', 'identity_hash' => str_repeat('a', 64), 'model' => 'gpt-current']; $evidence = ['policy_version' => 'v2', 'planner_identity_hash' => str_repeat('a', 64), 'model' => 'gpt-current']; expect(xlvask_autopilot_service::calibrationEvidenceMatchesIdentity($evidence, $identity))->toBeTrue() ->and(xlvask_autopilot_service::calibrationEvidenceMatchesIdentity([...$evidence, 'planner_identity_hash' => str_repeat('b', 64)], $identity))->toBeFalse() ->and(xlvask_autopilot_service::calibrationEvidenceMatchesIdentity([...$evidence, 'model' => 'gpt-stale'], $identity))->toBeFalse() ->and(xlvask_autopilot_service::calibrationEvidenceMatchesIdentity([...$evidence, 'policy_version' => 'v1'], $identity))->toBeFalse(); expect($autopilot) ->toContain("BINARY s.planner_identity_hash = BINARY '{\$identitySql}'") ->toContain("BINARY s.model = BINARY '{\$modelSql}'") ->toContain("'planner_identity_hash' => (string)\$label['planner_identity_hash']") ->toContain("'resolved_model' => (string)\$label['model']") ->toContain("(string)(\$backtest['resolved_model'] ?? '')") ->and($policy)->toContain("(string)(\$artifact['resolved_model'] ?? '')"); }); it('authorizes calibration adjudication from an action event or an exact current reviewable suggestion', function (): void { $autopilot = (string)file_get_contents(WD . '/classes/xlvask_autopilot_service.php'); expect($autopilot) ->toContain('LEFT JOIN xlvask_automation_action_events ae ON ae.suggestion_id = s.id') ->toContain('ae.id IS NOT NULL') ->toContain("s.status = 'suggested' AND s.source = 'openai'") ->toContain('s.expected_version = u.expected_version AND s.input_hash = u.source_hash') ->toContain("u.resolution_state = 'needs_review'") ->toContain('newer.usage_log_id = s.usage_log_id AND newer.id > s.id') ->toContain("BINARY s.planner_identity_hash = BINARY '{\$identitySql}'") ->toContain("BINARY s.model = BINARY '{\$modelSql}'"); }); it('advertises OpenAI as the only effective automatic action source', function (): void { $policy = (string)file_get_contents(WD . '/classes/xlvask_automation_policy_service.php'); expect($policy) ->toContain("'effective_action_sources' => \$executeEnabled") ->toContain("? ['openai']") ->toContain(': []'); }); it('documents exact safe deploy partial migration rollback and bounded list projection', function (): void { $runbook = (string)file_get_contents(WD . '/modules/xlvask/AUTOMATION_RUNBOOK.md'); expect($runbook) ->toContain('Old code cannot interpret the new policy stages') ->toContain('set both legacy automatic-order switches to false') ->toContain('Never route old code as a partial-migration workaround') ->toContain('Use this exact rollback sequence before any old-code traffic') ->toContain('do not reconstruct same-day candidates per row') ->toContain('authoritatively rebuilt during preview/apply'); }); it('invalidates stale calibration and restarts soak at each canary activation epoch', function (): void { $policy = file_get_contents(WD . '/classes/xlvask_automation_policy_service.php'); $autopilot = file_get_contents(WD . '/classes/xlvask_autopilot_service.php'); $schema = file_get_contents(WD . '/classes/xlvask_usage_logs_schema_bootstrap.php'); expect($policy) ->toContain("UPDATE xlvask_automation_calibrations SET active = 0, invalidated_at = NOW()") ->toContain("'invalidated_calibration_segment' => \$segment") ->toContain("'ai_attach_canary' => [") ->toContain("'ai_attach_verified' => [") ->toContain("'ai_create_canary' => [") ->toContain("'verified_capped' => [") ->toContain("AND created_at >= '{\$sinceSql}'") ->toContain("\$state['attach_activated_at'] ?? null") ->toContain("\$state['create_activated_at'] ?? null") ->and($autopilot) ->toContain("'safety_epoch' => \$this->calibrationSafetyEpoch(\$segmentKey)") ->toContain('XL Vask calibration artifact was invalidated by an action safety latch.') ->toContain('XL Vask calibration labels changed after this artifact was generated.') ->and($schema)->toContain("'invalidated_at'"); }); it('scopes eligible suggestions and visible hall budgets to the caller revision and hall scope', function (): void { $policy = file_get_contents(WD . '/classes/xlvask_automation_policy_service.php'); expect($policy) ->toContain('s.expected_version = u.expected_version') ->toContain('s.input_hash = u.source_hash') ->toContain('budgetSnapshotReadOnly($hallIds, $state)') ->toContain('$visibleHallWhere') ->toContain("AND hall_id IN ("); }); it('exposes distinct pre-action review and post-action adjudication state', function (): void { $automation = file_get_contents(WD . '/classes/xlvask_automation_service.php'); $autopilot = file_get_contents(WD . '/classes/xlvask_autopilot_service.php'); expect($automation) ->toContain("'review_eligible' =>") ->toContain("'adjudication_eligible' =>") ->toContain("'allowed_adjudication_outcomes' =>") ->toContain("'adjudication_outcome' =>") ->and($autopilot) ->toContain('reviewAutomaticActionBySuggestion(') ->toContain('true') ->toContain('$connection->begin_transaction()') ->toContain("'action_halted' =>") ->toContain("'affected_action' =>"); }); it('fails malformed or reversed invoice-period readiness scopes closed', function (): void { expect(\classes\xlvask_automation_policy_service::scopeDatesAreValid('2026-08-01', '2026-08-31'))->toBeTrue() ->and(\classes\xlvask_automation_policy_service::scopeDatesAreValid('2026-02-30', '2026-03-01'))->toBeFalse() ->and(\classes\xlvask_automation_policy_service::scopeDatesAreValid('2026-08-31', '2026-08-01'))->toBeFalse(); }); it('rotates pending rows fairly and invalidates suggestions atomically on source changes', function (): void { $automation = file_get_contents(WD . '/classes/xlvask_automation_service.php'); $usageLogs = file_get_contents(WD . '/objects/xlvask_usage_logs_o.php'); expect($automation) ->toContain("ORDER BY COALESCE(last_evaluated_at, '1970-01-01 00:00:00') ASC, id ASC") ->toContain('eligible_total') ->and($usageLogs) ->toContain('supersedeSuggestionsForSourceRevision') ->toContain("SET status = 'superseded'") ->toContain('$connection->begin_transaction()'); }); it('captures the eligible population before processing and fails invalid revisions closed', function (): void { $automation = file_get_contents(WD . '/classes/xlvask_automation_service.php'); $autopilot = file_get_contents(WD . '/classes/xlvask_autopilot_service.php'); $usageLogs = file_get_contents(WD . '/objects/xlvask_usage_logs_o.php'); expect(strpos($automation, '$eligibleTotal =')) ->toBeLessThan(strpos($automation, 'foreach ($rows as $row)')) ->and($automation)->toContain("=== 'invalid'") ->toContain("=== 'updated'") ->toContain("=== 'recheck'") ->toContain('$linkedOrder->asArray(true, false)') ->toContain("'certainty' => 'certain'") ->toContain('existing_link_semantically_revalidated') ->toContain('linked_order_revision_mismatch') ->and($autopilot)->toContain('has invalid source data') ->and($usageLogs)->toContain('source_stable_since = NULL') ->toContain('supersedeSuggestionsForSourceRevision($id)'); }); it('keeps read-only replay cache-only without new OpenAI network calls', function (): void { $automation = file_get_contents(WD . '/classes/xlvask_automation_service.php'); expect($automation)->toContain("if (\$this->readOnlyEvaluation) {\n return null;"); }); it('revalidates linked orders against customer department registration lane date and normalized items', function (): void { $proposedOrder = [ 'customer_id' => 10, 'department_id' => 2, 'reg_1' => 'AB 12 345', 'lane' => 3, 'created_at' => '2026-08-03 10:00:00', ]; $linkedOrder = [ 'customer_id' => 10, 'department_id' => 2, 'reg_1' => 'AB12345', 'lane' => 3, 'created_at' => '2026-08-03 10:05:00', ]; $items = [['product_id' => 5, 'quantity' => 1, 'price' => 500]]; expect(xlvask_automation_service::linkedOrderMatchesForAutomation($proposedOrder, $items, $linkedOrder, $items)) ->toBeTrue() ->and(xlvask_automation_service::linkedOrderMatchesForAutomation( $proposedOrder, $items, [...$linkedOrder, 'customer_id' => 11], $items ))->toBeFalse(); }); it('runs autopilot retention from the hourly XL Vask cleanup path', function (): void { $autopilot = file_get_contents(WD . '/classes/xlvask_autopilot_service.php'); $tasks = file_get_contents(WD . '/modules/xlvask/helpers/xlvask_tasks.php'); expect($autopilot)->toContain('public function pruneExpiredData(): array') ->and($tasks)->toContain('(new xlvask_autopilot_service())->pruneExpiredData();'); }); it('scores same-day orders with matching XL Vask products and extra add-ons as attach suggestions', function (): void { $usageItems = [ ['product_id' => 10, 'quantity' => 1, 'price' => 519, 'product' => ['name' => 'Forvogn']], ['product_id' => 24, 'quantity' => 1, 'price' => 39, 'product' => ['name' => 'Spot Free- Lastbil']], ['product_id' => 21, 'quantity' => 1, 'price' => 79, 'product' => ['name' => 'Undervognskyl pr. Enhed']], ['product_id' => 99, 'quantity' => 8, 'price' => 0, 'product' => ['name' => 'Halleje']], ]; $orderItems = [ ['product_id' => 10, 'quantity' => 1, 'price' => 519, 'product' => ['name' => 'Forvogn']], ['product_id' => 50, 'quantity' => 1, 'price' => 319, 'product' => ['name' => 'Indvendig vask Forvogn']], ['product_id' => 21, 'quantity' => 1, 'price' => 79, 'product' => ['name' => 'Undervognskyl pr. Enhed']], ['product_id' => 24, 'quantity' => 1, 'price' => 39, 'product' => ['name' => 'Spot Free- Lastbil']], ]; $score = xlvask_automation_service::scoreItemMatchForAutomation($usageItems, $orderItems); expect($score['source']) ->toBe('fuzzy') ->and($score['confidence'])->toBeGreaterThanOrEqual(0.70) ->and($score['confidence'])->toBeLessThan(0.92) ->and($score['reason'])->toContain('ekstra ydelser'); }); it('does not score an order with only the primary product as a matching add-on attachment', function (): void { $usageItems = [ ['product_id' => 10, 'quantity' => 1, 'price' => 519, 'product' => ['name' => 'Forvogn']], ['product_id' => 24, 'quantity' => 1, 'price' => 39, 'product' => ['name' => 'Spot Free- Lastbil']], ['product_id' => 21, 'quantity' => 1, 'price' => 79, 'product' => ['name' => 'Undervognskyl pr. Enhed']], ]; $orderItems = [ ['product_id' => 10, 'quantity' => 1, 'price' => 519, 'product' => ['name' => 'Forvogn']], ['product_id' => 50, 'quantity' => 1, 'price' => 319, 'product' => ['name' => 'Indvendig vask Forvogn']], ]; expect(xlvask_automation_service::scoreItemMatchForAutomation($usageItems, $orderItems)['confidence']) ->toBe(0.0); }); it('creates a manual operator suggestion with a deterministic proposal', function (): void { $service = new xlvask_automation_service(); $reflection = new ReflectionClass($service); // The constant is private but must be 'manual'. $source = $reflection->getConstant('SOURCE_MANUAL'); expect($source)->toBe('manual'); // The method must exist and be invokable on partial inputs (no AI). expect($reflection->hasMethod('createManualSuggestion'))->toBeTrue(); // Calling it with an invalid action should throw, not silently accept. expect(fn () => $service->createManualSuggestion(0, 'not_a_real_action', null, [])) ->toThrow(Exception::class); }); it('accepts force_manual in the decision preview contract', function (): void { // Read the route to confirm the preview endpoint whitelists force_manual. $routeFile = file_get_contents(__DIR__ . '/../../../routes/xlvaskUsageLogsRoute.php'); expect($routeFile)->toContain("'force_manual'"); expect($routeFile)->toContain("'usage_log_ids', 'action', 'suggestion_id', 'order_id', 'reason', 'force_manual'"); });