buildDepartmentOptionMap($ids); } public function departmentPrompt(array $map): string { return $this->buildDepartmentPromptText($map); } public function gatePrompt(string $name): string { return $this->buildGateTypePromptText($name); } public function gatePromptForOptions(string $name, array $options): string { return $this->buildGateTypePromptText($name, $options); } public function selectDepartment(array $map, string $digit): ?int { return $this->resolveDepartmentIdByDigit($map, $digit); } public function selectGateType(string $digit, array $options = []): ?string { return $this->resolveGateTypeByDigit($digit, $options); } public function mapGateOptions(array $types): array { return $this->buildGateOptionMap($types); } public function normalizeMenuDigit(?string $input): ?string { return $this->normalizeMenuDigitInput($input); } public function storeState(string $callId, array $state): void { $this->saveIvrState($callId, $state); } public function loadState(string $callId): ?array { return $this->readIvrState($callId); } public function removeState(string $callId): void { $this->clearIvrState($callId); } public function stateKey(string $callId): string { return $this->ivrStateKey($callId); } protected function getDepartmentNameById(int $departmentId): string { return $this->departmentNames[$departmentId] ?? ('Department ' . $departmentId); } protected function writeIvrStateRaw(string $key, string $value, int $ttlSeconds): void { $this->rawState[$key] = $value; $this->ttls[$key] = $ttlSeconds; } protected function readIvrStateRaw(string $key): ?string { return $this->rawState[$key] ?? null; } protected function deleteIvrStateRaw(string $key): void { unset($this->rawState[$key], $this->ttls[$key]); } } it('builds deterministic department digit map without capping options', function (): void { $route = new BirdVoiceWebhookRouteTestDouble(); $route->departmentNames = [ 11 => 'Nord', 22 => 'Syd', 33 => 'Vest', 44 => 'Ost', 55 => 'Midt', 66 => 'Aalborg', 77 => 'Odense', 88 => 'Esbjerg', 99 => 'Kolding', 111 => 'Roskilde', ]; $map = $route->mapDepartments([ ['department_id' => 11, 'department_name' => 'Nord', 'has_entrance_gate' => true, 'has_exit_gate' => false], ['department_id' => 22, 'department_name' => 'Syd', 'has_entrance_gate' => false, 'has_exit_gate' => true], 0, -5, ['department_id' => 33, 'department_name' => 'Vest', 'has_entrance_gate' => true, 'has_exit_gate' => true], 44, 55, 66, 77, 88, 99, 111, ]); expect($map)->toHaveCount(10); expect(array_keys($map))->toBe([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); expect($map['1']['department_id'])->toBe(11); expect($map['2']['department_id'])->toBe(22); expect($map['3']['department_id'])->toBe(33); expect($map['10']['department_id'])->toBe(111); expect($map['1']['department_name'])->toBe('Nord'); expect($map['2']['department_name'])->toBe('Syd'); expect($map['3']['department_name'])->toBe('Vest'); expect($map['10']['department_name'])->toBe('Roskilde'); expect($map['1']['has_entrance_gate'])->toBeTrue(); expect($map['1']['has_exit_gate'])->toBeFalse(); expect($map['2']['has_entrance_gate'])->toBeFalse(); expect($map['2']['has_exit_gate'])->toBeTrue(); }); it('resolves department ids by selected digit', function (): void { $route = new BirdVoiceWebhookRouteTestDouble(); $map = [ '1' => ['department_id' => 101, 'department_name' => 'A'], '2' => ['department_id' => 202, 'department_name' => 'B'], ]; expect($route->selectDepartment($map, '1'))->toBe(101); expect($route->selectDepartment($map, '2'))->toBe(202); expect($route->selectDepartment($map + ['10' => ['department_id' => 1001, 'department_name' => 'J']], '10'))->toBe(1001); expect($route->selectDepartment($map, '9'))->toBeNull(); expect($route->selectDepartment($map, ''))->toBeNull(); }); it('builds compact gate option maps and resolves selected gate type by digit', function (): void { $route = new BirdVoiceWebhookRouteTestDouble(); $both = $route->mapGateOptions(['entrance', 'exit']); $exitOnly = $route->mapGateOptions(['exit']); $entranceOnly = $route->mapGateOptions(['entrance']); expect($both)->toBe(['1' => 'entrance', '2' => 'exit']); expect($exitOnly)->toBe(['1' => 'exit']); expect($entranceOnly)->toBe(['1' => 'entrance']); expect($route->selectGateType('1', $both))->toBe('entrance'); expect($route->selectGateType('2', $both))->toBe('exit'); expect($route->selectGateType('1', $exitOnly))->toBe('exit'); expect($route->selectGateType('2', $exitOnly))->toBeNull(); expect($route->selectGateType('0', $both))->toBeNull(); expect($route->selectGateType('x', $both))->toBeNull(); }); it('stores, loads and clears ivr state with ttl', function (): void { $route = new BirdVoiceWebhookRouteTestDouble(); $callId = 'abc-123'; $state = [ 'stage' => 'department_select', 'department_options' => ['1' => ['department_id' => 5, 'department_name' => 'North']], 'gate_options' => ['1' => 'entrance'], 'gather_template' => ['timeout' => 30], ]; $route->storeState($callId, $state); $key = $route->stateKey($callId); expect($route->loadState($callId))->toBe($state); expect($route->ttls[$key] ?? null)->toBe(1200); $route->removeState($callId); expect($route->loadState($callId))->toBeNull(); }); it('builds department prompts with multi-digit guidance and compact gate prompts', function (): void { $route = new BirdVoiceWebhookRouteTestDouble(); $prompt = $route->departmentPrompt([ '1' => ['department_id' => 1, 'department_name' => 'Nord'], '2' => ['department_id' => 2, 'department_name' => 'Syd'], '3' => ['department_id' => 3, 'department_name' => 'Vest'], '4' => ['department_id' => 4, 'department_name' => 'Ost'], '5' => ['department_id' => 5, 'department_name' => 'Midt'], '6' => ['department_id' => 6, 'department_name' => 'Aalborg'], '7' => ['department_id' => 7, 'department_name' => 'Odense'], '8' => ['department_id' => 8, 'department_name' => 'Esbjerg'], '9' => ['department_id' => 9, 'department_name' => 'Kolding'], '10' => ['department_id' => 10, 'department_name' => 'Roskilde'], ]); expect($prompt)->toContain('Choose department.'); expect($prompt)->toContain('Enter the option number followed by pound.'); expect($prompt)->toContain('Press 1 for Nord'); expect($prompt)->toContain('Press 10 for Roskilde'); $gatePrompt = $route->gatePromptForOptions('Nord', ['1' => 'exit']); expect($gatePrompt)->toContain('You selected Nord.'); expect($gatePrompt)->toContain('Press 1 for exit.'); expect($gatePrompt)->not->toContain('Press 2 for exit.'); }); it('normalizes menu digit input from Bird dtmf payload values', function (): void { $route = new BirdVoiceWebhookRouteTestDouble(); expect($route->normalizeMenuDigit('1'))->toBe('1'); expect($route->normalizeMenuDigit('1#'))->toBe('1'); expect($route->normalizeMenuDigit(' 2## '))->toBe('2'); expect($route->normalizeMenuDigit('10#'))->toBe('10'); expect($route->normalizeMenuDigit('12#'))->toBe('12'); expect($route->normalizeMenuDigit('09'))->toBeNull(); expect($route->normalizeMenuDigit('*'))->toBeNull(); expect($route->normalizeMenuDigit(null))->toBeNull(); });