Files
api/services/nginx/app/tests/Unit/XLVask/XLVaskAutomationServiceTest.php
T
Jeppe BandCleanup Agent 3e89085296 feat(api): support manual XL Vask operator decisions via force_manual (#356)
Adds a deterministic manual-suggestion path so operators can drive
accept/reject/ignore decisions on the self-wash view before the AI
autopilot has produced a suggestion. Whitelists force_manual in the
preview route. Adds unit tests for the new constant, method, and route
contract.

---------

Co-authored-by: Cleanup Agent <agent@truckwash.io>
2026-08-09 21:48:03 +02:00

806 lines
40 KiB
PHP

<?php
use classes\xlvask_automation_service;
use classes\xlvask_autopilot_service;
use classes\openai;
use objects\xlvask_usage_logs_o;
require_once WD . '/classes/xlvask_automation_service.php';
require_once WD . '/classes/xlvask_autopilot_service.php';
require_once WD . '/objects/xlvask_usage_logs_o.php';
it('normalizes registrations for XL Vask automation signatures', function (): void {
expect(xlvask_automation_service::normalizeRegistrationForAutomation(' ec 21-233 '))
->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'");
});