770 lines
39 KiB
PHP
770 lines
39 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'")
|
|
->and($automation)->toContain("private const PLANNER_MODEL = 'gpt-5.6-sol'")
|
|
->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('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' => 'gpt-5.6-sol',
|
|
'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']);
|
|
});
|
|
|
|
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);
|
|
});
|