ASELSANMicrokernel
S558 · SOURCE-BOUND GATE EVIDENCE

S558 · R1 modem: sesli arama durum makinesi modeli

tam S558 implementation modülü → Operations --test hedefi ile bağlı tam focused test → ayrı Operations kaydı Bu sayfa yalnız S558 kapısına aittir; komşu kapıların kaynakları bu kabulün içine katılmaz.

S558Focused kod testiOperations id exactsource SHA exacttest target exact

operation: g8l-s558-r1-voice-call-state-machine-model

uygulama/model · focused test · Operations · 3 exact excerpt

sequence-bound=true · implementation-bound=true
01 · Yürütme / doğrulama kodu

Kapının gerçek repository sözleşmesi

tam dosyaL1–L999
kernel/src/g8l_target_dispatch_scheduler_owner_scheduler_mutation_production_migration_lifecycle_s558_r1_voice_call_state_machine_model.rs::S558 r1 voice call state machine model implementation
#![allow(unexpected_cfgs)]

//! S558 models the voice call control state machine of the R1 modem path as a
//! pure source/host model.
//!
//! The model covers `ATD<number>;` dialing with E.164-style number validation
//! (optional leading `+`, 3..=20 digits), `ATA` answer, `ATH`/`+CHUP` hangup,
//! `+CHLD`-style hold/resume, `+CLCC` list-current-calls parsing (`idx`, `dir`,
//! `stat` 0..5 active/held/dialing/alerting/incoming/waiting, `mode`, `mpty`,
//! quoted `number` plus type-of-address), `RING`/`+CLIP` unsolicited result
//! codes, `+VTS` DTMF tone validation, a bounded call table of at most two
//! calls (one active plus one held), bounded setup timeouts and checked call
//! duration ticks.  The per-call state machine is
//! `Idle -> Dialing -> Alerting -> Active -> Disconnecting -> Idle` for mobile
//! originated calls and `Idle -> Incoming/Waiting -> Active` for mobile
//! terminated calls.  Every accepted transition publishes one receipt into a
//! bounded ledger; every illegal transition, malformed line, invalid number,
//! invalid tone, out-of-order step or overflow fails closed without mutating
//! the state.
//!
//! S558 does not claim any modem hardware, AT transport, UART, audio path,
//! panel, board, power transition or runtime observation.  The module has no
//! production callsite and is driven only by its focused host test.  It
//! performs no device operation and does not rerun S540 or S543.
//! Predecessor: S557 (packet data PDP context / PPP frame model).  Next gate:
//! S559 (audio route / PCM capability model).

use alloc::format;
use alloc::string::String;
use alloc::vec::Vec;

pub const S558_SEQUENCE: usize = 558;
pub const S558_EXPECTED_PREDECESSOR: usize = 557;
pub const S558_R1_STAGE: u8 = 3;
pub const S558_R1_RANGE_FIRST: usize = 536;
pub const S558_R1_RANGE_LAST: usize = 568;
pub const S558_NUMBER_MIN_DIGITS: usize = 3;
pub const S558_NUMBER_MAX_DIGITS: usize = 20;
pub const S558_MAX_CALLS: usize = 2;
pub const S558_MAX_ACTIVE_CALLS: u8 = 1;
pub const S558_MAX_HELD_CALLS: u8 = 1;
pub const S558_CLCC_STAT_MAX: u8 = 5;
pub const S558_CLCC_MODE_VOICE: u8 = 0;
pub const S558_TOA_INTERNATIONAL: u8 = 145;
pub const S558_TOA_UNKNOWN: u8 = 129;
pub const S558_TOA_NATIONAL: u8 = 161;
pub const S558_DTMF_DURATION_MIN: u8 = 1;
pub const S558_DTMF_DURATION_MAX: u8 = 100;
pub const S558_DIAL_TIMEOUT_TICKS: u32 = 90;
pub const S558_RING_TIMEOUT_TICKS: u32 = 45;
pub const S558_MAX_TICK_STEP: u32 = 3_600;
pub const S558_MAX_CALL_DURATION_TICKS: u32 = 86_400;
pub const S558_MAX_TRANSITIONS: usize = 96;
pub const S558_SUPPORTED_PROFILE_RUNTIME_OBSERVATIONS: usize = 0;
pub const S558_PHYSICAL_OBSERVATIONS: usize = 0;
pub const S558_PHYSICAL_OR_DEVICE_OPERATIONS: usize = 0;
pub const S558_SD_WRITES: usize = 0;
pub const S558_UART_OPENS: usize = 0;
pub const S558_POWER_TRANSITIONS: usize = 0;
pub const S558_NEW_IMMUTABLE_RAW_CAPTURES: usize = 0;
pub const S558_S540_PHYSICAL_VERDICT_RETAINED_RED: bool = true;
pub const S558_S543_PHYSICAL_VERDICT_RETAINED_RED: bool = true;
pub const S558_AUTOMATIC_PROMOTION: bool = false;
pub const S558_BOOT_TO_UI_PHYSICALLY_OBSERVED: bool = false;
pub const S558_HARDWARE_PRESENT: bool = false;
pub const S558_R1_ACCEPTANCE_COMPLETE: bool = false;
pub const RUNBOOK_EXECUTED_IN_S558: bool = false;

/// Bounded E.164-style dial number: optional leading `+` and 3..=20 digits.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct G8lS558CallNumber {
    digits: [u8; S558_NUMBER_MAX_DIGITS],
    len: u8,
    international: bool,
}

impl G8lS558CallNumber {
    pub fn from_text(text: &str) -> Result<Self, G8lS558VoiceCallError> {
        let raw = text.as_bytes();
        if raw.is_empty() {
            return Err(G8lS558VoiceCallError::NumberEmpty);
        }
        let (international, body) = match raw[0] {
            b'+' => (true, &raw[1..]),
            _ => (false, raw),
        };
        if body.len() < S558_NUMBER_MIN_DIGITS {
            return Err(G8lS558VoiceCallError::NumberTooShort);
        }
        if body.len() > S558_NUMBER_MAX_DIGITS {
            return Err(G8lS558VoiceCallError::NumberTooLong);
        }
        let mut digits = [0u8; S558_NUMBER_MAX_DIGITS];
        for (slot, byte) in digits.iter_mut().zip(body) {
            if *byte == b'+' {
                return Err(G8lS558VoiceCallError::NumberMisplacedPlus);
            }
            if !byte.is_ascii_digit() {
                return Err(G8lS558VoiceCallError::NumberInvalidCharacter);
            }
            *slot = *byte;
        }
        Ok(Self {
            digits,
            len: body.len() as u8,
            international,
        })
    }

    pub const fn len(&self) -> usize {
        self.len as usize
    }

    pub const fn is_empty(&self) -> bool {
        self.len == 0
    }

    pub const fn is_international(&self) -> bool {
        self.international
    }

    pub fn digits(&self) -> &str {
        core::str::from_utf8(&self.digits[..self.len as usize]).unwrap_or("")
    }

    pub const fn type_of_address(&self) -> u8 {
        if self.international {
            S558_TOA_INTERNATIONAL
        } else {
            S558_TOA_UNKNOWN
        }
    }

    pub fn dial_string(&self) -> String {
        if self.international {
            format!("+{}", self.digits())
        } else {
            String::from(self.digits())
        }
    }
}

/// `+CLCC` `<dir>`: 0 mobile originated, 1 mobile terminated.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum G8lS558CallDirection {
    MobileOriginated,
    MobileTerminated,
}

impl G8lS558CallDirection {
    pub const fn from_code(code: u8) -> Option<Self> {
        match code {
            0 => Some(Self::MobileOriginated),
            1 => Some(Self::MobileTerminated),
            _ => None,
        }
    }

    pub const fn code(self) -> u8 {
        match self {
            Self::MobileOriginated => 0,
            Self::MobileTerminated => 1,
        }
    }
}

/// `+CLCC` `<stat>` 0..5 mapping.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum G8lS558ClccStat {
    Active,
    Held,
    Dialing,
    Alerting,
    Incoming,
    Waiting,
}

impl G8lS558ClccStat {
    pub const fn from_code(code: u8) -> Option<Self> {
        match code {
            0 => Some(Self::Active),
            1 => Some(Self::Held),
            2 => Some(Self::Dialing),
            3 => Some(Self::Alerting),
            4 => Some(Self::Incoming),
            5 => Some(Self::Waiting),
            _ => None,
        }
    }

    pub const fn code(self) -> u8 {
        match self {
            Self::Active => 0,
            Self::Held => 1,
            Self::Dialing => 2,
            Self::Alerting => 3,
            Self::Incoming => 4,
            Self::Waiting => 5,
        }
    }

    pub const fn text(self) -> &'static str {
        match self {
            Self::Active => "active",
            Self::Held => "held",
            Self::Dialing => "dialing",
            Self::Alerting => "alerting",
            Self::Incoming => "incoming",
            Self::Waiting => "waiting",
        }
    }
}

/// One parsed `+CLCC: <idx>,<dir>,<stat>,<mode>,<mpty>[,"<number>",<type>]` line.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct G8lS558ClccEntry {
    pub idx: u8,
    pub direction: G8lS558CallDirection,
    pub stat: G8lS558ClccStat,
    pub mode: u8,
    pub multiparty: bool,
    pub number: Option<G8lS558CallNumber>,
    pub type_of_address: u8,
}

/// Per-call phase of the voice call state machine.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum G8lS558CallPhase {
    Idle,
    Dialing,
    Alerting,
    Active,
    Held,
    Incoming,
    Waiting,
    Disconnecting,
}

impl G8lS558CallPhase {
    pub const fn clcc_stat(self) -> Option<G8lS558ClccStat> {
        match self {
            Self::Idle | Self::Disconnecting => None,
            Self::Dialing => Some(G8lS558ClccStat::Dialing),
            Self::Alerting => Some(G8lS558ClccStat::Alerting),
            Self::Active => Some(G8lS558ClccStat::Active),
            Self::Held => Some(G8lS558ClccStat::Held),
            Self::Incoming => Some(G8lS558ClccStat::Incoming),
            Self::Waiting => Some(G8lS558ClccStat::Waiting),
        }
    }

    const fn is_setup(self) -> bool {
        matches!(
            self,
            Self::Dialing | Self::Alerting | Self::Incoming | Self::Waiting | Self::Disconnecting
        )
    }
}

/// Events driving the state machine.  Every event is copyable so that an exact
/// replay can be compared against the published receipt.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum G8lS558Event {
    Dial(G8lS558CallNumber),
    Ring { number: Option<G8lS558CallNumber> },
    Answer,
    Hangup { idx: u8 },
    Hold { idx: u8 },
    Resume { idx: u8 },
    Dtmf { tone: u8, duration: u8 },
    Tick(u32),
    ClccReport(G8lS558ClccEntry),
    Released { idx: u8 },
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct G8lS558Call {
    pub idx: u8,
    pub direction: G8lS558CallDirection,
    pub phase: G8lS558CallPhase,
    pub number: Option<G8lS558CallNumber>,
    pub setup_ticks: u32,
    pub duration_ticks: u32,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct G8lS558CallTransitionReceipt {
    pub sequence: usize,
    pub predecessor_sequence: usize,
    pub step: usize,
    pub event: G8lS558Event,
    pub call_idx: u8,
    pub from: G8lS558CallPhase,
    pub to: G8lS558CallPhase,
    pub active_calls: u8,
    pub held_calls: u8,
    pub occupied_slots: u8,
    pub setup_ticks: u32,
    pub duration_ticks: u32,
    pub missed_calls: u32,
    pub completed_calls: u32,
    pub dtmf_tones_sent: u32,
    pub hardware_present: bool,
    pub physical_observations: usize,
    pub runbook_executed: bool,
}

#[derive(Debug)]
pub struct G8lS558VoiceCallState {
    calls: [Option<G8lS558Call>; S558_MAX_CALLS],
    missed_calls: u32,
    completed_calls: u32,
    dtmf_tones_sent: u32,
    ledger: Vec<G8lS558CallTransitionReceipt>,
}

impl G8lS558VoiceCallState {
    pub const fn new() -> Self {
        Self {
            calls: [None; S558_MAX_CALLS],
            missed_calls: 0,
            completed_calls: 0,
            dtmf_tones_sent: 0,
            ledger: Vec::new(),
        }
    }

    pub fn call(&self, idx: u8) -> Option<G8lS558Call> {
        slot_index(idx).ok().and_then(|slot| self.calls[slot])
    }

    pub fn phase(&self, idx: u8) -> G8lS558CallPhase {
        self.call(idx)
            .map_or(G8lS558CallPhase::Idle, |call| call.phase)
    }

    pub fn active_calls(&self) -> u8 {
        count_phase(&self.calls, G8lS558CallPhase::Active)
    }

    pub fn held_calls(&self) -> u8 {
        count_phase(&self.calls, G8lS558CallPhase::Held)
    }

    pub fn occupied_slots(&self) -> u8 {
        self.calls.iter().filter(|call| call.is_some()).count() as u8
    }

    pub const fn missed_calls(&self) -> u32 {
        self.missed_calls
    }

    pub const fn completed_calls(&self) -> u32 {
        self.completed_calls
    }

    pub const fn dtmf_tones_sent(&self) -> u32 {
        self.dtmf_tones_sent
    }

    pub fn receipts(&self) -> &[G8lS558CallTransitionReceipt] {
        &self.ledger
    }

    pub fn last_receipt(&self) -> Option<G8lS558CallTransitionReceipt> {
        self.ledger.last().copied()
    }
}

impl Default for G8lS558VoiceCallState {
    fn default() -> Self {
        Self::new()
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum G8lS558VoiceCallOutcome {
    TransitionPublished(G8lS558CallTransitionReceipt),
    TransitionRetained(G8lS558CallTransitionReceipt),
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum G8lS558VoiceCallError {
    StepOutOfOrder,
    PublishedStateDrift,
    LedgerFull,
    NumberEmpty,
    NumberTooShort,
    NumberTooLong,
    NumberInvalidCharacter,
    NumberMisplacedPlus,
    InvalidClccLine,
    InvalidClccIndex,
    InvalidClccDirection,
    InvalidClccStat,
    InvalidClccMode,
    InvalidClccMultiparty,
    InvalidClccTypeOfAddress,
    InvalidClipLine,
    InvalidDtmfTone,
    InvalidDtmfDuration,
    NoCallSlot,
    CallSetupAlreadyInProgress,
    NoSuchCall,
    NoIncomingCall,
    IllegalTransition,
    ActiveCallMustBeHeld,
    HeldCallAlreadyPresent,
    ActiveCallAlreadyPresent,
    NoActiveCallForDtmf,
    MultipartyUnsupported,
    ClccDirectionMismatch,
    ClccNumberMismatch,
    TickZero,
    TickTooLarge,
    NoCallForTick,
    DurationOverflow,
    CounterOverflow,
}

impl G8lS558VoiceCallError {
    pub const fn diagnostic_code(self) -> u64 {
        match self {
            Self::StepOutOfOrder => 1,
            Self::PublishedStateDrift => 2,
            Self::LedgerFull => 3,
            Self::NumberEmpty => 4,
            Self::NumberTooShort => 5,
            Self::NumberTooLong => 6,
            Self::NumberInvalidCharacter => 7,
            Self::NumberMisplacedPlus => 8,
            Self::InvalidClccLine => 9,
            Self::InvalidClccIndex => 10,
            Self::InvalidClccDirection => 11,
            Self::InvalidClccStat => 12,
            Self::InvalidClccMode => 13,
            Self::InvalidClccMultiparty => 14,
            Self::InvalidClccTypeOfAddress => 15,
            Self::InvalidClipLine => 16,
            Self::InvalidDtmfTone => 17,
            Self::InvalidDtmfDuration => 18,
            Self::NoCallSlot => 19,
            Self::CallSetupAlreadyInProgress => 20,
            Self::NoSuchCall => 21,
            Self::NoIncomingCall => 22,
            Self::IllegalTransition => 23,
            Self::ActiveCallMustBeHeld => 24,
            Self::HeldCallAlreadyPresent => 25,
            Self::ActiveCallAlreadyPresent => 26,
            Self::NoActiveCallForDtmf => 27,
            Self::MultipartyUnsupported => 28,
            Self::ClccDirectionMismatch => 29,
            Self::ClccNumberMismatch => 30,
            Self::TickZero => 31,
            Self::TickTooLarge => 32,
            Self::NoCallForTick => 33,
            Self::DurationOverflow => 34,
            Self::CounterOverflow => 35,
        }
    }
}

fn slot_index(idx: u8) -> Result<usize, G8lS558VoiceCallError> {
    if idx == 0 || idx as usize > S558_MAX_CALLS {
        return Err(G8lS558VoiceCallError::InvalidClccIndex);
    }
    Ok(idx as usize - 1)
}

fn count_phase(calls: &[Option<G8lS558Call>; S558_MAX_CALLS], phase: G8lS558CallPhase) -> u8 {
    calls
        .iter()
        .flatten()
        .filter(|call| call.phase == phase)
        .count() as u8
}

fn parse_u8_field(field: &str, error: G8lS558VoiceCallError) -> Result<u8, G8lS558VoiceCallError> {
    if field.is_empty() || field.len() > 3 {
        return Err(error);
    }
    let mut value: u16 = 0;
    for byte in field.bytes() {
        if !byte.is_ascii_digit() {
            return Err(error);
        }
        value = value * 10 + u16::from(byte - b'0');
    }
    u8::try_from(value).map_err(|_| error)
}

fn strip_quotes(field: &str, error: G8lS558VoiceCallError) -> Result<&str, G8lS558VoiceCallError> {
    field
        .strip_prefix('"')
        .and_then(|rest| rest.strip_suffix('"'))
        .ok_or(error)
}

/// Validates a DTMF tone byte (`0`..`9`, `*`, `#`, `A`..`D`).
pub const fn dtmf_tone_is_valid(tone: u8) -> bool {
    matches!(tone, b'0'..=b'9' | b'*' | b'#' | b'A'..=b'D')
}

/// Validates the tone and the duration (in 1/10 s units) of one `+VTS` tone.
pub fn validate_dtmf(tone: u8, duration: u8) -> Result<(), G8lS558VoiceCallError> {
    if !dtmf_tone_is_valid(tone) {
        return Err(G8lS558VoiceCallError::InvalidDtmfTone);
    }
    if !(S558_DTMF_DURATION_MIN..=S558_DTMF_DURATION_MAX).contains(&duration) {
        return Err(G8lS558VoiceCallError::InvalidDtmfDuration);
    }
    Ok(())
}

/// Parses one `+CLCC:` response line.
pub fn parse_clcc_line(line: &str) -> Result<G8lS558ClccEntry, G8lS558VoiceCallError> {
    let body = line
        .strip_prefix("+CLCC: ")
        .ok_or(G8lS558VoiceCallError::InvalidClccLine)?;
    let mut fields = [""; 7];
    let mut count = 0usize;
    for field in body.split(',') {
        if count == fields.len() {
            return Err(G8lS558VoiceCallError::InvalidClccLine);
        }
        fields[count] = field;
        count += 1;
    }
    if count != 5 && count != 7 {
        return Err(G8lS558VoiceCallError::InvalidClccLine);
    }
    let idx = parse_u8_field(fields[0], G8lS558VoiceCallError::InvalidClccIndex)?;
    slot_index(idx)?;
    let direction = parse_u8_field(fields[1], G8lS558VoiceCallError::InvalidClccDirection)
        .and_then(|code| {
            G8lS558CallDirection::from_code(code).ok_or(G8lS558VoiceCallError::InvalidClccDirection)
        })?;
    let stat =
        parse_u8_field(fields[2], G8lS558VoiceCallError::InvalidClccStat).and_then(|code| {
            G8lS558ClccStat::from_code(code).ok_or(G8lS558VoiceCallError::InvalidClccStat)
        })?;
    let mode = parse_u8_field(fields[3], G8lS558VoiceCallError::InvalidClccMode)?;
    if mode != S558_CLCC_MODE_VOICE {
        return Err(G8lS558VoiceCallError::InvalidClccMode);
    }
    let multiparty = match parse_u8_field(fields[4], G8lS558VoiceCallError::InvalidClccMultiparty)?
    {
        0 => false,
        1 => true,
        _ => return Err(G8lS558VoiceCallError::InvalidClccMultiparty),
    };
    let (number, type_of_address) = if count == 7 {
        let text = strip_quotes(fields[5], G8lS558VoiceCallError::InvalidClccLine)?;
        let number = G8lS558CallNumber::from_text(text)?;
        let toa = parse_u8_field(fields[6], G8lS558VoiceCallError::InvalidClccTypeOfAddress)?;
        let expected_international = toa == S558_TOA_INTERNATIONAL;
        let known = matches!(
            toa,
            S558_TOA_INTERNATIONAL | S558_TOA_UNKNOWN | S558_TOA_NATIONAL
        );
        if !known || expected_international != number.is_international() {
            return Err(G8lS558VoiceCallError::InvalidClccTypeOfAddress);
        }
        (Some(number), toa)
    } else {
        (None, 0)
    };
    Ok(G8lS558ClccEntry {
        idx,
        direction,
        stat,
        mode,
        multiparty,
        number,
        type_of_address,
    })
}

/// Parses one `+CLIP: "<number>",<type>[,...]` unsolicited result code.
pub fn parse_clip_line(line: &str) -> Result<G8lS558CallNumber, G8lS558VoiceCallError> {
    let body = line
        .strip_prefix("+CLIP: ")
        .ok_or(G8lS558VoiceCallError::InvalidClipLine)?;
    let mut fields = body.split(',');
    let quoted = fields
        .next()
        .ok_or(G8lS558VoiceCallError::InvalidClipLine)?;
    let toa_field = fields
        .next()
        .ok_or(G8lS558VoiceCallError::InvalidClipLine)?;
    let text = strip_quotes(quoted, G8lS558VoiceCallError::InvalidClipLine)?;
    let number = G8lS558CallNumber::from_text(text)?;
    let toa = parse_u8_field(toa_field, G8lS558VoiceCallError::InvalidClipLine)?;
    if (toa == S558_TOA_INTERNATIONAL) != number.is_international() {
        return Err(G8lS558VoiceCallError::InvalidClipLine);
    }
    Ok(number)
}

/// `RING` unsolicited result code recogniser.
pub fn is_ring_urc(line: &str) -> bool {
    line == "RING"
}

pub fn encode_atd(number: G8lS558CallNumber) -> String {
    format!("ATD{};", number.dial_string())
}

pub const fn encode_ata() -> &'static str {
    "ATA"
}

pub const fn encode_ath() -> &'static str {
    "ATH"
}

pub const fn encode_chup() -> &'static str {
    "AT+CHUP"
}

pub const fn encode_clcc_query() -> &'static str {
    "AT+CLCC"
}

pub fn encode_vts(tone: u8, duration: u8) -> Result<String, G8lS558VoiceCallError> {
    validate_dtmf(tone, duration)?;
    Ok(format!("AT+VTS={},{}", tone as char, duration))
}

struct Next {
    calls: [Option<G8lS558Call>; S558_MAX_CALLS],
    call_idx: u8,
    from: G8lS558CallPhase,
    to: G8lS558CallPhase,
    missed_calls: u32,
    completed_calls: u32,
    dtmf_tones_sent: u32,
}

fn free_slot(
    calls: &[Option<G8lS558Call>; S558_MAX_CALLS],
) -> Result<usize, G8lS558VoiceCallError> {
    calls
        .iter()
        .position(Option::is_none)
        .ok_or(G8lS558VoiceCallError::NoCallSlot)
}

fn occupied_call(
    calls: &[Option<G8lS558Call>; S558_MAX_CALLS],
    idx: u8,
) -> Result<(usize, G8lS558Call), G8lS558VoiceCallError> {
    let slot = slot_index(idx)?;
    calls[slot]
        .map(|call| (slot, call))
        .ok_or(G8lS558VoiceCallError::NoSuchCall)
}

fn any_setup_in_progress(calls: &[Option<G8lS558Call>; S558_MAX_CALLS]) -> bool {
    calls.iter().flatten().any(|call| call.phase.is_setup())
}

fn next_state(
    state: &G8lS558VoiceCallState,
    event: G8lS558Event,
) -> Result<Next, G8lS558VoiceCallError> {
    let mut next = Next {
        calls: state.calls,
        call_idx: 0,
        from: G8lS558CallPhase::Idle,
        to: G8lS558CallPhase::Idle,
        missed_calls: state.missed_calls,
        completed_calls: state.completed_calls,
        dtmf_tones_sent: state.dtmf_tones_sent,
    };
    match event {
        G8lS558Event::Dial(number) => {
            if any_setup_in_progress(&next.calls) {
                return Err(G8lS558VoiceCallError::CallSetupAlreadyInProgress);
            }
            if count_phase(&next.calls, G8lS558CallPhase::Active) >= S558_MAX_ACTIVE_CALLS {
                return Err(G8lS558VoiceCallError::ActiveCallMustBeHeld);
            }
            let slot = free_slot(&next.calls)?;
            next.call_idx = slot as u8 + 1;
            next.to = G8lS558CallPhase::Dialing;
            next.calls[slot] = Some(G8lS558Call {
                idx: next.call_idx,
                direction: G8lS558CallDirection::MobileOriginated,
                phase: G8lS558CallPhase::Dialing,
                number: Some(number),
                setup_ticks: 0,
                duration_ticks: 0,
            });
        }
        G8lS558Event::Ring { number } => {
            if any_setup_in_progress(&next.calls) {
                return Err(G8lS558VoiceCallError::CallSetupAlreadyInProgress);
            }
            let slot = free_slot(&next.calls)?;
            let busy = count_phase(&next.calls, G8lS558CallPhase::Active)
                + count_phase(&next.calls, G8lS558CallPhase::Held);
            next.call_idx = slot as u8 + 1;
            next.to = if busy > 0 {
                G8lS558CallPhase::Waiting
            } else {
                G8lS558CallPhase::Incoming
            };
            next.calls[slot] = Some(G8lS558Call {
                idx: next.call_idx,
                direction: G8lS558CallDirection::MobileTerminated,
                phase: next.to,
                number,
                setup_ticks: 0,
                duration_ticks: 0,
            });
        }
        G8lS558Event::Answer => {
            let (slot, call) = next
                .calls
                .iter()
                .enumerate()
                .find_map(|(slot, call)| {
                    call.filter(|call| {
                        matches!(
                            call.phase,
                            G8lS558CallPhase::Incoming | G8lS558CallPhase::Waiting
                        )
                    })
                    .map(|call| (slot, call))
                })
                .ok_or(G8lS558VoiceCallError::NoIncomingCall)?;
            if count_phase(&next.calls, G8lS558CallPhase::Active) >= S558_MAX_ACTIVE_CALLS {
                return Err(G8lS558VoiceCallError::ActiveCallMustBeHeld);
            }
            next.call_idx = call.idx;
            next.from = call.phase;
            next.to = G8lS558CallPhase::Active;
            next.calls[slot] = Some(G8lS558Call {
                phase: G8lS558CallPhase::Active,
                ..call
            });
        }
        G8lS558Event::Hangup { idx } => {
            let (slot, call) = occupied_call(&next.calls, idx)?;
            if call.phase == G8lS558CallPhase::Disconnecting {
                return Err(G8lS558VoiceCallError::IllegalTransition);
            }
            next.call_idx = idx;
            next.from = call.phase;
            next.to = G8lS558CallPhase::Disconnecting;
            next.calls[slot] = Some(G8lS558Call {
                phase: G8lS558CallPhase::Disconnecting,
                ..call
            });
        }
        G8lS558Event::Released { idx } => {
            let (slot, call) = occupied_call(&next.calls, idx)?;
            if call.phase != G8lS558CallPhase::Disconnecting {
                return Err(G8lS558VoiceCallError::IllegalTransition);
            }
            next.call_idx = idx;
            next.from = call.phase;
            next.to = G8lS558CallPhase::Idle;
            next.completed_calls = next
                .completed_calls
                .checked_add(1)
                .ok_or(G8lS558VoiceCallError::CounterOverflow)?;
            next.calls[slot] = None;
        }
        G8lS558Event::Hold { idx } => {
            let (slot, call) = occupied_call(&next.calls, idx)?;
            if call.phase != G8lS558CallPhase::Active {
                return Err(G8lS558VoiceCallError::IllegalTransition);
            }
            if count_phase(&next.calls, G8lS558CallPhase::Held) >= S558_MAX_HELD_CALLS {
                return Err(G8lS558VoiceCallError::HeldCallAlreadyPresent);
            }
            next.call_idx = idx;
            next.from = call.phase;
            next.to = G8lS558CallPhase::Held;
            next.calls[slot] = Some(G8lS558Call {
                phase: G8lS558CallPhase::Held,
                ..call
            });
        }
        G8lS558Event::Resume { idx } => {
            let (slot, call) = occupied_call(&next.calls, idx)?;
            if call.phase != G8lS558CallPhase::Held {
                return Err(G8lS558VoiceCallError::IllegalTransition);
            }
            if count_phase(&next.calls, G8lS558CallPhase::Active) >= S558_MAX_ACTIVE_CALLS {
                return Err(G8lS558VoiceCallError::ActiveCallAlreadyPresent);
            }
            next.call_idx = idx;
            next.from = call.phase;
            next.to = G8lS558CallPhase::Active;
            next.calls[slot] = Some(G8lS558Call {
                phase: G8lS558CallPhase::Active,
                ..call
            });
        }
        G8lS558Event::Dtmf { tone, duration } => {
            validate_dtmf(tone, duration)?;
            let call = next
                .calls
                .iter()
                .flatten()
                .find(|call| call.phase == G8lS558CallPhase::Active)
                .copied()
                .ok_or(G8lS558VoiceCallError::NoActiveCallForDtmf)?;
            next.call_idx = call.idx;
            next.from = G8lS558CallPhase::Active;
            next.to = G8lS558CallPhase::Active;
            next.dtmf_tones_sent = next
                .dtmf_tones_sent
                .checked_add(1)
                .ok_or(G8lS558VoiceCallError::CounterOverflow)?;
        }
        G8lS558Event::Tick(ticks) => {
            if ticks == 0 {
                return Err(G8lS558VoiceCallError::TickZero);
            }
            if ticks > S558_MAX_TICK_STEP {
                return Err(G8lS558VoiceCallError::TickTooLarge);
            }
            let mut primary: Option<(u8, G8lS558CallPhase, G8lS558CallPhase)> = None;
            for slot in 0..S558_MAX_CALLS {
                let Some(call) = next.calls[slot] else {
                    continue;
                };
                let updated = tick_call(call, ticks)?;
                if primary.is_none() {
                    primary = Some((
                        call.idx,
                        call.phase,
                        updated.map_or(G8lS558CallPhase::Idle, |call| call.phase),
                    ));
                }
                if updated.is_none() {
                    next.missed_calls = next
                        .missed_calls
                        .checked_add(1)
                        .ok_or(G8lS558VoiceCallError::CounterOverflow)?;
                }
                next.calls[slot] = updated;
            }
            let (idx, from, to) = primary.ok_or(G8lS558VoiceCallError::NoCallForTick)?;
            next.call_idx = idx;
            next.from = from;
            next.to = to;
        }
        G8lS558Event::ClccReport(entry) => {
            if entry.mode != S558_CLCC_MODE_VOICE {
                return Err(G8lS558VoiceCallError::InvalidClccMode);
            }
            if entry.multiparty {
                return Err(G8lS558VoiceCallError::MultipartyUnsupported);
            }
            let (slot, call) = occupied_call(&next.calls, entry.idx)?;
            if entry.direction != call.direction {
                return Err(G8lS558VoiceCallError::ClccDirectionMismatch);
            }
            let number = match (call.number, entry.number) {
                (Some(known), Some(reported)) if known != reported => {
                    return Err(G8lS558VoiceCallError::ClccNumberMismatch)
                }
                (Some(known), _) => Some(known),
                (None, reported) => reported,
            };
            let to = match (call.phase, entry.stat) {
                (G8lS558CallPhase::Dialing, G8lS558ClccStat::Dialing) => G8lS558CallPhase::Dialing,
                (G8lS558CallPhase::Dialing, G8lS558ClccStat::Alerting)
                | (G8lS558CallPhase::Alerting, G8lS558ClccStat::Alerting) => {
                    G8lS558CallPhase::Alerting
                }
                (G8lS558CallPhase::Alerting, G8lS558ClccStat::Active)
                | (G8lS558CallPhase::Active, G8lS558ClccStat::Active) => G8lS558CallPhase::Active,
                (G8lS558CallPhase::Held, G8lS558ClccStat::Held) => G8lS558CallPhase::Held,
                (G8lS558CallPhase::Incoming, G8lS558ClccStat::Incoming) => {
                    G8lS558CallPhase::Incoming
                }
                (G8lS558CallPhase::Waiting, G8lS558ClccStat::Waiting) => G8lS558CallPhase::Waiting,
                _ => return Err(G8lS558VoiceCallError::IllegalTransition),
            };
            next.call_idx = entry.idx;
            next.from = call.phase;
            next.to = to;
            next.calls[slot] = Some(G8lS558Call {
                phase: to,
                number,
                ..call
            });
        }
    }
    Ok(next)
}

/// Advances one call by `ticks`.  Returns `None` when an unanswered incoming
/// call times out and its slot is released as a missed call.
fn tick_call(call: G8lS558Call, ticks: u32) -> Result<Option<G8lS558Call>, G8lS558VoiceCallError> {
    match call.phase {
        G8lS558CallPhase::Active | G8lS558CallPhase::Held => {
            let duration_ticks = call
                .duration_ticks
                .checked_add(ticks)
                .filter(|total| *total <= S558_MAX_CALL_DURATION_TICKS)
                .ok_or(G8lS558VoiceCallError::DurationOverflow)?;
            Ok(Some(G8lS558Call {
                duration_ticks,
                ..call
            }))
        }
        G8lS558CallPhase::Dialing | G8lS558CallPhase::Alerting => {
            let setup_ticks = call
                .setup_ticks
                .checked_add(ticks)
                .ok_or(G8lS558VoiceCallError::DurationOverflow)?;
            let phase = if setup_ticks > S558_DIAL_TIMEOUT_TICKS {
                G8lS558CallPhase::Disconnecting
            } else {
                call.phase
            };
            Ok(Some(G8lS558Call {
                phase,
                setup_ticks,
                ..call
            }))
        }
        G8lS558CallPhase::Incoming | G8lS558CallPhase::Waiting => {
            let setup_ticks = call
                .setup_ticks
                .checked_add(ticks)
                .ok_or(G8lS558VoiceCallError::DurationOverflow)?;
            if setup_ticks > S558_RING_TIMEOUT_TICKS {
                return Ok(None);
            }
            Ok(Some(G8lS558Call {
                setup_ticks,
                ..call
            }))
        }
        G8lS558CallPhase::Idle | G8lS558CallPhase::Disconnecting => Ok(Some(call)),
    }
}

/// Applies one event at ledger position `step`.  `step` must equal the number
/// of published receipts (a new transition) or address an already published
/// receipt with the exact same event (retained replay).  Any other input
/// fails closed without mutating the state.
pub fn service_s558_model_voice_call_transition(
    state: &mut G8lS558VoiceCallState,
    step: usize,
    event: G8lS558Event,
) -> Result<G8lS558VoiceCallOutcome, G8lS558VoiceCallError> {
    if let Some(published) = state.ledger.get(step).copied() {
        if published.event != event || published.step != step {
            return Err(G8lS558VoiceCallError::PublishedStateDrift);
        }
        return Ok(G8lS558VoiceCallOutcome::TransitionRetained(published));
    }
    if step != state.ledger.len() {
        return Err(G8lS558VoiceCallError::StepOutOfOrder);
    }
    if state.ledger.len() >= S558_MAX_TRANSITIONS {
        return Err(G8lS558VoiceCallError::LedgerFull);
    }
    let next = next_state(state, event)?;
    let touched = slot_index(next.call_idx)
        .ok()
        .and_then(|slot| next.calls[slot]);
    let receipt = G8lS558CallTransitionReceipt {
        sequence: S558_SEQUENCE,
        predecessor_sequence: S558_EXPECTED_PREDECESSOR,
        step,
        event,
        call_idx: next.call_idx,
        from: next.from,
        to: next.to,
        active_calls: count_phase(&next.calls, G8lS558CallPhase::Active),
        held_calls: count_phase(&next.calls, G8lS558CallPhase::Held),
        occupied_slots: next.calls.iter().filter(|call| call.is_some()).count() as u8,
        setup_ticks: touched.map_or(0, |call| call.setup_ticks),
        duration_ticks: touched.map_or(0, |call| call.duration_ticks),
        missed_calls: next.missed_calls,
        completed_calls: next.completed_calls,
        dtmf_tones_sent: next.dtmf_tones_sent,
        hardware_present: S558_HARDWARE_PRESENT,
        physical_observations: S558_PHYSICAL_OBSERVATIONS,
        runbook_executed: RUNBOOK_EXECUTED_IN_S558,
    };
    state.calls = next.calls;
    state.missed_calls = next.missed_calls;
    state.completed_calls = next.completed_calls;
    state.dtmf_tones_sent = next.dtmf_tones_sent;
    state.ledger.push(receipt);
    Ok(G8lS558VoiceCallOutcome::TransitionPublished(receipt))
}
snippet sha256: bae7dba15c24file sha256: bae7dba15c24
02 · Doğrulayan test kodu

Operations komutuna bağlı focused test

tam dosyaL1–L662
simulation/tests/g8l_target_dispatch_scheduler_owner_scheduler_mutation_production_migration_lifecycle_s558_r1_voice_call_state_machine_model.rs::S558 r1 voice call state machine model focused tests
use aselsan_microkernel_simulation::g8l_target_dispatch_scheduler_owner_scheduler_mutation_production_migration_lifecycle_s558_r1_voice_call_state_machine_model::*;
use std::collections::BTreeSet;

const SOURCE: &str = include_str!(
    "../../kernel/src/g8l_target_dispatch_scheduler_owner_scheduler_mutation_production_migration_lifecycle_s558_r1_voice_call_state_machine_model.rs"
);
const MAIN: &str = include_str!("../../kernel/src/main.rs");
const SIMULATION_LIB: &str = include_str!("../src/lib.rs");

type Outcome = Result<G8lS558VoiceCallOutcome, G8lS558VoiceCallError>;

fn number(text: &str) -> G8lS558CallNumber {
    G8lS558CallNumber::from_text(text).unwrap()
}

fn apply(state: &mut G8lS558VoiceCallState, step: usize, event: G8lS558Event) -> Outcome {
    service_s558_model_voice_call_transition(state, step, event)
}

fn published(outcome: Outcome) -> G8lS558CallTransitionReceipt {
    match outcome.unwrap() {
        G8lS558VoiceCallOutcome::TransitionPublished(receipt) => receipt,
        other => panic!("expected publication, got {other:?}"),
    }
}

fn clcc(idx: u8, direction: u8, stat: u8, number: Option<G8lS558CallNumber>) -> G8lS558ClccEntry {
    G8lS558ClccEntry {
        idx,
        direction: G8lS558CallDirection::from_code(direction).unwrap(),
        stat: G8lS558ClccStat::from_code(stat).unwrap(),
        mode: S558_CLCC_MODE_VOICE,
        multiparty: false,
        number,
        type_of_address: number.map_or(0, |number| number.type_of_address()),
    }
}

/// Drives a mobile-originated call to `Active` on idx 1 and returns the next step.
fn establish_outgoing(state: &mut G8lS558VoiceCallState, text: &str) -> usize {
    let dialed = number(text);
    published(apply(state, 0, G8lS558Event::Dial(dialed)));
    published(apply(state, 1, G8lS558Event::ClccReport(clcc(1, 0, 3, Some(dialed)))));
    published(apply(state, 2, G8lS558Event::ClccReport(clcc(1, 0, 0, Some(dialed)))));
    assert_eq!(state.phase(1), G8lS558CallPhase::Active);
    3
}

#[test]
fn sequence_scope_and_nonpromotion_are_exact() {
    assert_eq!(S558_SEQUENCE, 558);
    assert_eq!(S558_EXPECTED_PREDECESSOR, 557);
    assert_eq!(S558_R1_STAGE, 3);
    assert_eq!(S558_R1_RANGE_FIRST, 536);
    assert_eq!(S558_R1_RANGE_LAST, 568);
    assert_eq!(S558_NUMBER_MIN_DIGITS, 3);
    assert_eq!(S558_NUMBER_MAX_DIGITS, 20);
    assert_eq!(S558_MAX_CALLS, 2);
    assert_eq!(S558_MAX_ACTIVE_CALLS, 1);
    assert_eq!(S558_MAX_HELD_CALLS, 1);
    assert_eq!(S558_CLCC_STAT_MAX, 5);
    assert_eq!(S558_DTMF_DURATION_MIN, 1);
    assert_eq!(S558_DTMF_DURATION_MAX, 100);
    assert_eq!(S558_DIAL_TIMEOUT_TICKS, 90);
    assert_eq!(S558_RING_TIMEOUT_TICKS, 45);
    assert_eq!(S558_MAX_CALL_DURATION_TICKS, 86_400);
    assert_eq!(S558_MAX_TRANSITIONS, 96);
    assert_eq!(S558_SUPPORTED_PROFILE_RUNTIME_OBSERVATIONS, 0);
    assert_eq!(S558_PHYSICAL_OBSERVATIONS, 0);
    assert_eq!(S558_PHYSICAL_OR_DEVICE_OPERATIONS, 0);
    assert_eq!(S558_SD_WRITES, 0);
    assert_eq!(S558_UART_OPENS, 0);
    assert_eq!(S558_POWER_TRANSITIONS, 0);
    assert_eq!(S558_NEW_IMMUTABLE_RAW_CAPTURES, 0);
    assert!(S558_S540_PHYSICAL_VERDICT_RETAINED_RED);
    assert!(S558_S543_PHYSICAL_VERDICT_RETAINED_RED);
    assert!(!S558_AUTOMATIC_PROMOTION);
    assert!(!S558_BOOT_TO_UI_PHYSICALLY_OBSERVED);
    assert!(!S558_HARDWARE_PRESENT);
    assert!(!S558_R1_ACCEPTANCE_COMPLETE);
    assert!(!RUNBOOK_EXECUTED_IN_S558);
}

#[test]
fn module_is_registered_in_kernel_and_simulation() {
    let module = "g8l_target_dispatch_scheduler_owner_scheduler_mutation_production_migration_lifecycle_s558_r1_voice_call_state_machine_model";
    assert!(MAIN.contains(&format!("mod {module};")));
    assert!(SIMULATION_LIB.contains(&format!("pub mod {module};")));
}

#[test]
fn source_has_no_device_execution_or_uart_emission_surface() {
    for forbidden in [
        "unsafe",
        "asm!",
        "write_volatile",
        "crate::uart",
        "crate::arch",
        "#[no_mangle]",
        "spin::",
        "std::",
        "std::process::Command",
        "/dev/cu.",
        "TIOCEXCL",
        "crate::kprintln!",
    ] {
        assert!(!SOURCE.contains(forbidden), "forbidden token: {forbidden}");
    }
    assert!(SOURCE.contains("performs no device operation"));
    assert!(SOURCE.contains("does not rerun S540 or S543"));
    assert!(SOURCE.contains("S558_HARDWARE_PRESENT: bool = false"));
    assert!(SOURCE.contains("S558_R1_ACCEPTANCE_COMPLETE: bool = false"));
    assert!(SOURCE.contains("RUNBOOK_EXECUTED_IN_S558: bool = false"));
}

#[test]
fn diagnostic_codes_are_nonzero_and_unique() {
    let errors = [
        G8lS558VoiceCallError::StepOutOfOrder,
        G8lS558VoiceCallError::PublishedStateDrift,
        G8lS558VoiceCallError::LedgerFull,
        G8lS558VoiceCallError::NumberEmpty,
        G8lS558VoiceCallError::NumberTooShort,
        G8lS558VoiceCallError::NumberTooLong,
        G8lS558VoiceCallError::NumberInvalidCharacter,
        G8lS558VoiceCallError::NumberMisplacedPlus,
        G8lS558VoiceCallError::InvalidClccLine,
        G8lS558VoiceCallError::InvalidClccIndex,
        G8lS558VoiceCallError::InvalidClccDirection,
        G8lS558VoiceCallError::InvalidClccStat,
        G8lS558VoiceCallError::InvalidClccMode,
        G8lS558VoiceCallError::InvalidClccMultiparty,
        G8lS558VoiceCallError::InvalidClccTypeOfAddress,
        G8lS558VoiceCallError::InvalidClipLine,
        G8lS558VoiceCallError::InvalidDtmfTone,
        G8lS558VoiceCallError::InvalidDtmfDuration,
        G8lS558VoiceCallError::NoCallSlot,
        G8lS558VoiceCallError::CallSetupAlreadyInProgress,
        G8lS558VoiceCallError::NoSuchCall,
        G8lS558VoiceCallError::NoIncomingCall,
        G8lS558VoiceCallError::IllegalTransition,
        G8lS558VoiceCallError::ActiveCallMustBeHeld,
        G8lS558VoiceCallError::HeldCallAlreadyPresent,
        G8lS558VoiceCallError::ActiveCallAlreadyPresent,
        G8lS558VoiceCallError::NoActiveCallForDtmf,
        G8lS558VoiceCallError::MultipartyUnsupported,
        G8lS558VoiceCallError::ClccDirectionMismatch,
        G8lS558VoiceCallError::ClccNumberMismatch,
        G8lS558VoiceCallError::TickZero,
        G8lS558VoiceCallError::TickTooLarge,
        G8lS558VoiceCallError::NoCallForTick,
        G8lS558VoiceCallError::DurationOverflow,
        G8lS558VoiceCallError::CounterOverflow,
    ];
    let codes: BTreeSet<_> = errors
        .into_iter()
        .map(G8lS558VoiceCallError::diagnostic_code)
        .collect();
    assert_eq!(codes.len(), errors.len());
    assert_eq!(codes.len(), 35);
    assert!(!codes.contains(&0));
}

#[test]
fn exact_replay_retains_the_same_receipt() {
    let mut state = G8lS558VoiceCallState::new();
    let dialed = number("+905551234567");
    let receipt = published(apply(&mut state, 0, G8lS558Event::Dial(dialed)));
    assert_eq!(
        apply(&mut state, 0, G8lS558Event::Dial(dialed)),
        Ok(G8lS558VoiceCallOutcome::TransitionRetained(receipt))
    );
    assert_eq!(state.receipts().len(), 1);
    assert_eq!(state.last_receipt(), Some(receipt));
    assert_eq!(receipt.sequence, S558_SEQUENCE);
    assert_eq!(receipt.predecessor_sequence, S558_EXPECTED_PREDECESSOR);
    assert!(!receipt.hardware_present);
    assert_eq!(receipt.physical_observations, 0);
    assert!(!receipt.runbook_executed);
}

#[test]
fn divergent_input_after_publication_fails_closed() {
    let mut state = G8lS558VoiceCallState::new();
    published(apply(&mut state, 0, G8lS558Event::Dial(number("+905551234567"))));
    assert_eq!(
        apply(&mut state, 0, G8lS558Event::Dial(number("+905557654321"))),
        Err(G8lS558VoiceCallError::PublishedStateDrift)
    );
    assert_eq!(
        apply(&mut state, 0, G8lS558Event::Answer),
        Err(G8lS558VoiceCallError::PublishedStateDrift)
    );
    assert_eq!(state.receipts().len(), 1);
    assert_eq!(state.phase(1), G8lS558CallPhase::Dialing);
}

#[test]
fn number_validation_accepts_e164_and_rejects_malformed_input() {
    let international = number("+905551234567");
    assert!(international.is_international());
    assert_eq!(international.digits(), "905551234567");
    assert_eq!(international.len(), 12);
    assert_eq!(international.type_of_address(), S558_TOA_INTERNATIONAL);
    assert_eq!(international.dial_string(), "+905551234567");
    let national = number("05551234567");
    assert!(!national.is_international());
    assert_eq!(national.type_of_address(), S558_TOA_UNKNOWN);
    assert_eq!(national.dial_string(), "05551234567");
    assert_eq!(number("112").len(), 3);
    let twenty = "1".repeat(20);
    assert_eq!(number(&twenty).len(), 20);
    assert_eq!(number(&format!("+{twenty}")).len(), 20);
    let twenty_one = "1".repeat(21);
    assert_eq!(
        G8lS558CallNumber::from_text(&twenty_one),
        Err(G8lS558VoiceCallError::NumberTooLong)
    );
    assert_eq!(
        G8lS558CallNumber::from_text(&format!("+{twenty_one}")),
        Err(G8lS558VoiceCallError::NumberTooLong)
    );
    assert_eq!(
        G8lS558CallNumber::from_text(""),
        Err(G8lS558VoiceCallError::NumberEmpty)
    );
    assert_eq!(
        G8lS558CallNumber::from_text("+"),
        Err(G8lS558VoiceCallError::NumberTooShort)
    );
    assert_eq!(
        G8lS558CallNumber::from_text("12"),
        Err(G8lS558VoiceCallError::NumberTooShort)
    );
    assert_eq!(
        G8lS558CallNumber::from_text("+90+555"),
        Err(G8lS558VoiceCallError::NumberMisplacedPlus)
    );
    for malformed in ["555-1234", "555 1234", "555;1234", "ABC123", "+90555123456a"] {
        assert_eq!(
            G8lS558CallNumber::from_text(malformed),
            Err(G8lS558VoiceCallError::NumberInvalidCharacter),
            "{malformed}"
        );
    }
}

#[test]
fn mobile_originated_call_walks_dialing_alerting_active_disconnecting_idle() {
    let mut state = G8lS558VoiceCallState::new();
    let dialed = number("+905551234567");
    let dial = published(apply(&mut state, 0, G8lS558Event::Dial(dialed)));
    assert_eq!((dial.call_idx, dial.from, dial.to), (1, G8lS558CallPhase::Idle, G8lS558CallPhase::Dialing));
    assert_eq!(dial.occupied_slots, 1);
    let call = state.call(1).unwrap();
    assert_eq!(call.direction, G8lS558CallDirection::MobileOriginated);
    assert_eq!(call.number, Some(dialed));
    let alerting = published(apply(&mut state, 1, G8lS558Event::ClccReport(clcc(1, 0, 3, Some(dialed)))));
    assert_eq!((alerting.from, alerting.to), (G8lS558CallPhase::Dialing, G8lS558CallPhase::Alerting));
    let active = published(apply(&mut state, 2, G8lS558Event::ClccReport(clcc(1, 0, 0, None))));
    assert_eq!((active.from, active.to), (G8lS558CallPhase::Alerting, G8lS558CallPhase::Active));
    assert_eq!(active.active_calls, 1);
    assert_eq!(state.call(1).unwrap().number, Some(dialed));
    let tick = published(apply(&mut state, 3, G8lS558Event::Tick(42)));
    assert_eq!(tick.duration_ticks, 42);
    let hangup = published(apply(&mut state, 4, G8lS558Event::Hangup { idx: 1 }));
    assert_eq!((hangup.from, hangup.to), (G8lS558CallPhase::Active, G8lS558CallPhase::Disconnecting));
    assert_eq!(hangup.active_calls, 0);
    assert_eq!(hangup.duration_ticks, 42);
    let released = published(apply(&mut state, 5, G8lS558Event::Released { idx: 1 }));
    assert_eq!((released.from, released.to), (G8lS558CallPhase::Disconnecting, G8lS558CallPhase::Idle));
    assert_eq!(released.occupied_slots, 0);
    assert_eq!(released.completed_calls, 1);
    assert_eq!(state.phase(1), G8lS558CallPhase::Idle);
    assert_eq!(state.call(1), None);
    assert_eq!(state.receipts().len(), 6);
    for (position, receipt) in state.receipts().iter().enumerate() {
        assert_eq!(receipt.step, position);
    }
}

#[test]
fn incoming_ring_with_clip_answers_into_active() {
    assert!(is_ring_urc("RING"));
    assert!(!is_ring_urc("RINGING"));
    let caller = parse_clip_line("+CLIP: \"+905559876543\",145").unwrap();
    assert_eq!(caller, number("+905559876543"));
    assert_eq!(parse_clip_line("+CLIP: \"05559876543\",129,,,,0").unwrap(), number("05559876543"));
    assert_eq!(parse_clip_line("+CLIP: +905559876543,145"), Err(G8lS558VoiceCallError::InvalidClipLine));
    assert_eq!(parse_clip_line("+CLIP: \"+905559876543\",129"), Err(G8lS558VoiceCallError::InvalidClipLine));
    assert_eq!(parse_clip_line("+CLIP: \"+905559876543\""), Err(G8lS558VoiceCallError::InvalidClipLine));
    assert_eq!(parse_clip_line("RING"), Err(G8lS558VoiceCallError::InvalidClipLine));

    let mut state = G8lS558VoiceCallState::new();
    let ring = published(apply(&mut state, 0, G8lS558Event::Ring { number: None }));
    assert_eq!((ring.from, ring.to), (G8lS558CallPhase::Idle, G8lS558CallPhase::Incoming));
    assert_eq!(state.call(1).unwrap().direction, G8lS558CallDirection::MobileTerminated);
    let clip = published(apply(&mut state, 1, G8lS558Event::ClccReport(clcc(1, 1, 4, Some(caller)))));
    assert_eq!((clip.from, clip.to), (G8lS558CallPhase::Incoming, G8lS558CallPhase::Incoming));
    assert_eq!(state.call(1).unwrap().number, Some(caller));
    published(apply(&mut state, 2, G8lS558Event::Tick(10)));
    let answer = published(apply(&mut state, 3, G8lS558Event::Answer));
    assert_eq!((answer.call_idx, answer.from, answer.to), (1, G8lS558CallPhase::Incoming, G8lS558CallPhase::Active));
    assert_eq!(answer.setup_ticks, 10);
    assert_eq!(answer.duration_ticks, 0);
    assert_eq!(answer.active_calls, 1);
    assert_eq!(
        apply(&mut state, 4, G8lS558Event::Answer),
        Err(G8lS558VoiceCallError::NoIncomingCall)
    );
}

#[test]
fn clcc_line_parsing_maps_all_stat_codes_and_rejects_malformed_lines() {
    for (code, stat, text) in [
        (0, G8lS558ClccStat::Active, "active"),
        (1, G8lS558ClccStat::Held, "held"),
        (2, G8lS558ClccStat::Dialing, "dialing"),
        (3, G8lS558ClccStat::Alerting, "alerting"),
        (4, G8lS558ClccStat::Incoming, "incoming"),
        (5, G8lS558ClccStat::Waiting, "waiting"),
    ] {
        assert_eq!(G8lS558ClccStat::from_code(code), Some(stat));
        assert_eq!(stat.code(), code);
        assert_eq!(stat.text(), text);
        let line = format!("+CLCC: 1,0,{code},0,0,\"+905551234567\",145");
        let entry = parse_clcc_line(&line).unwrap();
        assert_eq!(entry.stat, stat);
        assert_eq!(entry.idx, 1);
        assert_eq!(entry.direction, G8lS558CallDirection::MobileOriginated);
        assert_eq!(entry.number, Some(number("+905551234567")));
        assert_eq!(entry.type_of_address, 145);
    }
    assert_eq!(G8lS558ClccStat::from_code(6), None);
    assert_eq!(G8lS558CallDirection::from_code(2), None);
    let short = parse_clcc_line("+CLCC: 2,1,4,0,0").unwrap();
    assert_eq!(short.idx, 2);
    assert_eq!(short.direction, G8lS558CallDirection::MobileTerminated);
    assert_eq!(short.number, None);
    assert_eq!(short.type_of_address, 0);
    assert!(parse_clcc_line("+CLCC: 1,0,0,0,1").unwrap().multiparty);
    assert_eq!(parse_clcc_line("+CLCC: 1,0,0,0,0,\"05551234567\",161").unwrap().type_of_address, 161);
    for (line, error) in [
        ("+CLCC:1,0,0,0,0", G8lS558VoiceCallError::InvalidClccLine),
        ("+CLCC: 1,0,0,0", G8lS558VoiceCallError::InvalidClccLine),
        ("+CLCC: 1,0,0,0,0,\"+905551234567\"", G8lS558VoiceCallError::InvalidClccLine),
        ("+CLCC: 1,0,0,0,0,\"+905551234567\",145,0,0", G8lS558VoiceCallError::InvalidClccLine),
        ("+CLCC: 1,0,0,0,0,+905551234567,145", G8lS558VoiceCallError::InvalidClccLine),
        ("+CLCC: 0,0,0,0,0", G8lS558VoiceCallError::InvalidClccIndex),
        ("+CLCC: 3,0,0,0,0", G8lS558VoiceCallError::InvalidClccIndex),
        ("+CLCC: 999,0,0,0,0", G8lS558VoiceCallError::InvalidClccIndex),
        ("+CLCC: x,0,0,0,0", G8lS558VoiceCallError::InvalidClccIndex),
        ("+CLCC: 1,2,0,0,0", G8lS558VoiceCallError::InvalidClccDirection),
        ("+CLCC: 1,0,6,0,0", G8lS558VoiceCallError::InvalidClccStat),
        ("+CLCC: 1,0,0,1,0", G8lS558VoiceCallError::InvalidClccMode),
        ("+CLCC: 1,0,0,0,2", G8lS558VoiceCallError::InvalidClccMultiparty),
        ("+CLCC: 1,0,0,0,0,\"+905551234567\",129", G8lS558VoiceCallError::InvalidClccTypeOfAddress),
        ("+CLCC: 1,0,0,0,0,\"05551234567\",145", G8lS558VoiceCallError::InvalidClccTypeOfAddress),
        ("+CLCC: 1,0,0,0,0,\"05551234567\",200", G8lS558VoiceCallError::InvalidClccTypeOfAddress),
        ("+CLCC: 1,0,0,0,0,\"555-1234\",129", G8lS558VoiceCallError::NumberInvalidCharacter),
        ("+CREG: 0,1", G8lS558VoiceCallError::InvalidClccLine),
    ] {
        assert_eq!(parse_clcc_line(line), Err(error), "{line}");
    }
}

#[test]
fn illegal_transitions_fail_closed_without_mutation() {
    let mut state = G8lS558VoiceCallState::new();
    let dialed = number("+905551234567");
    assert_eq!(apply(&mut state, 0, G8lS558Event::Answer), Err(G8lS558VoiceCallError::NoIncomingCall));
    assert_eq!(apply(&mut state, 0, G8lS558Event::Hangup { idx: 1 }), Err(G8lS558VoiceCallError::NoSuchCall));
    assert_eq!(apply(&mut state, 0, G8lS558Event::Hangup { idx: 0 }), Err(G8lS558VoiceCallError::InvalidClccIndex));
    assert_eq!(apply(&mut state, 0, G8lS558Event::Hangup { idx: 3 }), Err(G8lS558VoiceCallError::InvalidClccIndex));
    assert_eq!(apply(&mut state, 0, G8lS558Event::Released { idx: 1 }), Err(G8lS558VoiceCallError::NoSuchCall));
    assert_eq!(apply(&mut state, 0, G8lS558Event::Hold { idx: 1 }), Err(G8lS558VoiceCallError::NoSuchCall));
    assert_eq!(apply(&mut state, 0, G8lS558Event::Tick(1)), Err(G8lS558VoiceCallError::NoCallForTick));
    assert!(state.receipts().is_empty());

    published(apply(&mut state, 0, G8lS558Event::Dial(dialed)));
    // Dialing -> Active directly (skipping Alerting) is illegal.
    assert_eq!(
        apply(&mut state, 1, G8lS558Event::ClccReport(clcc(1, 0, 0, Some(dialed)))),
        Err(G8lS558VoiceCallError::IllegalTransition)
    );
    // Dialing -> Held / Incoming / Waiting are illegal.
    for stat in [1, 4, 5] {
        assert_eq!(
            apply(&mut state, 1, G8lS558Event::ClccReport(clcc(1, 0, stat, Some(dialed)))),
            Err(G8lS558VoiceCallError::IllegalTransition)
        );
    }
    assert_eq!(apply(&mut state, 1, G8lS558Event::Released { idx: 1 }), Err(G8lS558VoiceCallError::IllegalTransition));
    assert_eq!(apply(&mut state, 1, G8lS558Event::Hold { idx: 1 }), Err(G8lS558VoiceCallError::IllegalTransition));
    assert_eq!(apply(&mut state, 1, G8lS558Event::Resume { idx: 1 }), Err(G8lS558VoiceCallError::IllegalTransition));
    assert_eq!(apply(&mut state, 1, G8lS558Event::Dial(dialed)), Err(G8lS558VoiceCallError::CallSetupAlreadyInProgress));
    assert_eq!(apply(&mut state, 1, G8lS558Event::Ring { number: None }), Err(G8lS558VoiceCallError::CallSetupAlreadyInProgress));
    assert_eq!(
        apply(&mut state, 1, G8lS558Event::ClccReport(clcc(1, 1, 2, Some(dialed)))),
        Err(G8lS558VoiceCallError::ClccDirectionMismatch)
    );
    assert_eq!(
        apply(&mut state, 1, G8lS558Event::ClccReport(clcc(1, 0, 2, Some(number("+905550000000"))))),
        Err(G8lS558VoiceCallError::ClccNumberMismatch)
    );
    assert_eq!(
        apply(&mut state, 1, G8lS558Event::ClccReport(clcc(2, 0, 2, Some(dialed)))),
        Err(G8lS558VoiceCallError::NoSuchCall)
    );
    let mut multiparty = clcc(1, 0, 2, Some(dialed));
    multiparty.multiparty = true;
    assert_eq!(
        apply(&mut state, 1, G8lS558Event::ClccReport(multiparty)),
        Err(G8lS558VoiceCallError::MultipartyUnsupported)
    );
    let mut data_mode = clcc(1, 0, 2, Some(dialed));
    data_mode.mode = 1;
    assert_eq!(
        apply(&mut state, 1, G8lS558Event::ClccReport(data_mode)),
        Err(G8lS558VoiceCallError::InvalidClccMode)
    );
    assert_eq!(state.receipts().len(), 1);
    assert_eq!(state.phase(1), G8lS558CallPhase::Dialing);
    assert_eq!(state.call(2), None);

    // Disconnecting accepts only Released.
    published(apply(&mut state, 1, G8lS558Event::Hangup { idx: 1 }));
    assert_eq!(apply(&mut state, 2, G8lS558Event::Hangup { idx: 1 }), Err(G8lS558VoiceCallError::IllegalTransition));
    assert_eq!(
        apply(&mut state, 2, G8lS558Event::ClccReport(clcc(1, 0, 2, Some(dialed)))),
        Err(G8lS558VoiceCallError::IllegalTransition)
    );
    assert_eq!(state.phase(1), G8lS558CallPhase::Disconnecting);
}

#[test]
fn bounded_concurrency_allows_one_active_plus_one_held_only() {
    let mut state = G8lS558VoiceCallState::new();
    let step = establish_outgoing(&mut state, "+905551234567");
    let second = number("+905557654321");
    assert_eq!(apply(&mut state, step, G8lS558Event::Dial(second)), Err(G8lS558VoiceCallError::ActiveCallMustBeHeld));
    let hold = published(apply(&mut state, step, G8lS558Event::Hold { idx: 1 }));
    assert_eq!((hold.from, hold.to, hold.active_calls, hold.held_calls), (G8lS558CallPhase::Active, G8lS558CallPhase::Held, 0, 1));
    let dial = published(apply(&mut state, step + 1, G8lS558Event::Dial(second)));
    assert_eq!((dial.call_idx, dial.to, dial.occupied_slots), (2, G8lS558CallPhase::Dialing, 2));
    published(apply(&mut state, step + 2, G8lS558Event::ClccReport(clcc(2, 0, 3, Some(second)))));
    let active = published(apply(&mut state, step + 3, G8lS558Event::ClccReport(clcc(2, 0, 0, Some(second)))));
    assert_eq!((active.active_calls, active.held_calls, active.occupied_slots), (1, 1, 2));
    assert_eq!(apply(&mut state, step + 4, G8lS558Event::Hold { idx: 2 }), Err(G8lS558VoiceCallError::HeldCallAlreadyPresent));
    assert_eq!(apply(&mut state, step + 4, G8lS558Event::Resume { idx: 1 }), Err(G8lS558VoiceCallError::ActiveCallAlreadyPresent));
    assert_eq!(apply(&mut state, step + 4, G8lS558Event::Ring { number: None }), Err(G8lS558VoiceCallError::NoCallSlot));
    assert_eq!(apply(&mut state, step + 4, G8lS558Event::Dial(number("112"))), Err(G8lS558VoiceCallError::ActiveCallMustBeHeld));
    let tick = published(apply(&mut state, step + 4, G8lS558Event::Tick(5)));
    assert_eq!(tick.call_idx, 1);
    assert_eq!(state.call(1).unwrap().duration_ticks, 5);
    assert_eq!(state.call(2).unwrap().duration_ticks, 5);
    published(apply(&mut state, step + 5, G8lS558Event::Hangup { idx: 2 }));
    published(apply(&mut state, step + 6, G8lS558Event::Released { idx: 2 }));
    let resume = published(apply(&mut state, step + 7, G8lS558Event::Resume { idx: 1 }));
    assert_eq!((resume.from, resume.to, resume.active_calls, resume.held_calls), (G8lS558CallPhase::Held, G8lS558CallPhase::Active, 1, 0));
    assert_eq!(state.completed_calls(), 1);
    assert_eq!(state.occupied_slots(), 1);
}

#[test]
fn call_waiting_requires_hold_before_answer() {
    let mut state = G8lS558VoiceCallState::new();
    let step = establish_outgoing(&mut state, "+905551234567");
    let waiting_number = number("+905559876543");
    let ring = published(apply(&mut state, step, G8lS558Event::Ring { number: Some(waiting_number) }));
    assert_eq!((ring.call_idx, ring.to), (2, G8lS558CallPhase::Waiting));
    published(apply(&mut state, step + 1, G8lS558Event::ClccReport(clcc(2, 1, 5, Some(waiting_number)))));
    assert_eq!(
        apply(&mut state, step + 2, G8lS558Event::ClccReport(clcc(2, 1, 4, Some(waiting_number)))),
        Err(G8lS558VoiceCallError::IllegalTransition)
    );
    assert_eq!(apply(&mut state, step + 2, G8lS558Event::Answer), Err(G8lS558VoiceCallError::ActiveCallMustBeHeld));
    published(apply(&mut state, step + 2, G8lS558Event::Hold { idx: 1 }));
    let answer = published(apply(&mut state, step + 3, G8lS558Event::Answer));
    assert_eq!((answer.call_idx, answer.from, answer.to), (2, G8lS558CallPhase::Waiting, G8lS558CallPhase::Active));
    assert_eq!((answer.active_calls, answer.held_calls), (1, 1));
    // Rejecting a waiting call goes through Disconnecting like any other call.
    let mut reject = G8lS558VoiceCallState::new();
    let step = establish_outgoing(&mut reject, "+905551234567");
    published(apply(&mut reject, step, G8lS558Event::Ring { number: None }));
    let hangup = published(apply(&mut reject, step + 1, G8lS558Event::Hangup { idx: 2 }));
    assert_eq!((hangup.from, hangup.to), (G8lS558CallPhase::Waiting, G8lS558CallPhase::Disconnecting));
    published(apply(&mut reject, step + 2, G8lS558Event::Released { idx: 2 }));
    assert_eq!(reject.phase(1), G8lS558CallPhase::Active);
    assert_eq!(reject.phase(2), G8lS558CallPhase::Idle);
}

#[test]
fn dtmf_validation_requires_active_call_and_valid_tone() {
    for tone in b"0123456789*#ABCD" {
        assert!(dtmf_tone_is_valid(*tone), "{}", *tone as char);
        assert_eq!(validate_dtmf(*tone, 1), Ok(()));
        assert_eq!(validate_dtmf(*tone, 100), Ok(()));
    }
    for tone in b"abcdEF+, -" {
        assert_eq!(validate_dtmf(*tone, 10), Err(G8lS558VoiceCallError::InvalidDtmfTone), "{}", *tone as char);
    }
    assert_eq!(validate_dtmf(b'5', 0), Err(G8lS558VoiceCallError::InvalidDtmfDuration));
    assert_eq!(validate_dtmf(b'5', 101), Err(G8lS558VoiceCallError::InvalidDtmfDuration));
    assert_eq!(validate_dtmf(b'5', 255), Err(G8lS558VoiceCallError::InvalidDtmfDuration));
    assert_eq!(encode_vts(b'#', 10).unwrap(), "AT+VTS=#,10");
    assert_eq!(encode_vts(b'x', 10), Err(G8lS558VoiceCallError::InvalidDtmfTone));

    let mut state = G8lS558VoiceCallState::new();
    assert_eq!(
        apply(&mut state, 0, G8lS558Event::Dtmf { tone: b'1', duration: 10 }),
        Err(G8lS558VoiceCallError::NoActiveCallForDtmf)
    );
    published(apply(&mut state, 0, G8lS558Event::Dial(number("+905551234567"))));
    assert_eq!(
        apply(&mut state, 1, G8lS558Event::Dtmf { tone: b'1', duration: 10 }),
        Err(G8lS558VoiceCallError::NoActiveCallForDtmf)
    );
    let mut state = G8lS558VoiceCallState::new();
    let step = establish_outgoing(&mut state, "+905551234567");
    assert_eq!(
        apply(&mut state, step, G8lS558Event::Dtmf { tone: b'E', duration: 10 }),
        Err(G8lS558VoiceCallError::InvalidDtmfTone)
    );
    assert_eq!(
        apply(&mut state, step, G8lS558Event::Dtmf { tone: b'1', duration: 0 }),
        Err(G8lS558VoiceCallError::InvalidDtmfDuration)
    );
    let first = published(apply(&mut state, step, G8lS558Event::Dtmf { tone: b'1', duration: 10 }));
    assert_eq!((first.call_idx, first.from, first.to, first.dtmf_tones_sent), (1, G8lS558CallPhase::Active, G8lS558CallPhase::Active, 1));
    let second = published(apply(&mut state, step + 1, G8lS558Event::Dtmf { tone: b'#', duration: 100 }));
    assert_eq!(second.dtmf_tones_sent, 2);
    assert_eq!(state.dtmf_tones_sent(), 2);
    published(apply(&mut state, step + 2, G8lS558Event::Hold { idx: 1 }));
    assert_eq!(
        apply(&mut state, step + 3, G8lS558Event::Dtmf { tone: b'1', duration: 10 }),
        Err(G8lS558VoiceCallError::NoActiveCallForDtmf)
    );
}

#[test]
fn call_duration_ticks_accumulate_and_overflow_fails_closed() {
    let mut state = G8lS558VoiceCallState::new();
    let step = establish_outgoing(&mut state, "+905551234567");
    assert_eq!(apply(&mut state, step, G8lS558Event::Tick(0)), Err(G8lS558VoiceCallError::TickZero));
    assert_eq!(apply(&mut state, step, G8lS558Event::Tick(S558_MAX_TICK_STEP + 1)), Err(G8lS558VoiceCallError::TickTooLarge));
    let mut expected = 0u32;
    let mut current = step;
    while expected + S558_MAX_TICK_STEP <= S558_MAX_CALL_DURATION_TICKS {
        let receipt = published(apply(&mut state, current, G8lS558Event::Tick(S558_MAX_TICK_STEP)));
        expected += S558_MAX_TICK_STEP;
        assert_eq!(receipt.duration_ticks, expected);
        current += 1;
    }
    assert_eq!(expected, S558_MAX_CALL_DURATION_TICKS);
    assert_eq!(state.call(1).unwrap().duration_ticks, S558_MAX_CALL_DURATION_TICKS);
    assert_eq!(apply(&mut state, current, G8lS558Event::Tick(1)), Err(G8lS558VoiceCallError::DurationOverflow));
    assert_eq!(state.call(1).unwrap().duration_ticks, S558_MAX_CALL_DURATION_TICKS);
    assert_eq!(state.receipts().len(), current);
    // Held calls keep accumulating duration; a boundary tick exactly at the maximum is accepted.
    let mut held = G8lS558VoiceCallState::new();
    let step = establish_outgoing(&mut held, "+905551234567");
    published(apply(&mut held, step, G8lS558Event::Hold { idx: 1 }));
    let tick = published(apply(&mut held, step + 1, G8lS558Event::Tick(7)));
    assert_eq!((tick.from, tick.to, tick.duration_ticks), (G8lS558CallPhase::Held, G8lS558CallPhase::Held, 7));
}

#[test]
fn dial_and_ring_timeouts_bound_setup_phases() {
    let mut state = G8lS558VoiceCallState::new();
    let dialed = number("+905551234567");
    published(apply(&mut state, 0, G8lS558Event::Dial(dialed)));
    let boundary = published(apply(&mut state, 1, G8lS558Event::Tick(S558_DIAL_TIMEOUT_TICKS)));
    assert_eq!((boundary.from, boundary.to, boundary.setup_ticks), (G8lS558CallPhase::Dialing, G8lS558CallPhase::Dialing, S558_DIAL_TIMEOUT_TICKS));
    let timed_out = published(apply(&mut state, 2, G8lS558Event::Tick(1)));
    assert_eq!((timed_out.from, timed_out.to), (G8lS558CallPhase::Dialing, G8lS558CallPhase::Disconnecting));
    assert_eq!(timed_out.duration_ticks, 0);
    published(apply(&mut state, 3, G8lS558Event::Released { idx: 1 }));
    assert_eq!(state.completed_calls(), 1);
    assert_eq!(state.missed_calls(), 0);

    let mut alerting = G8lS558VoiceCallState::new();
    published(apply(&mut alerting, 0, G8lS558Event::Dial(dialed)));
    published(apply(&mut alerting, 1, G8lS558Event::Tick(30)));
    published(apply(&mut alerting, 2, G8lS558Event::ClccReport(clcc(1, 0, 3, None))));
    let timed_out = published(apply(&mut alerting, 3, G8lS558Event::Tick(61)));
    assert_eq!((timed_out.from, timed_out.to, timed_out.setup_ticks), (G8lS558CallPhase::Alerting, G8lS558CallPhase::Disconnecting, 91));

    let mut incoming = G8lS558VoiceCallState::new();
    published(apply(&mut incoming, 0, G8lS558Event::Ring { number: Some(dialed) }));
    let boundary = published(apply(&mut incoming, 1, G8lS558Event::Tick(S558_RING_TIMEOUT_TICKS)));
    assert_eq!((boundary.to, boundary.setup_ticks), (G8lS558CallPhase::Incoming, S558_RING_TIMEOUT_TICKS));
    let missed = published(apply(&mut incoming, 2, G8lS558Event::Tick(1)));
    assert_eq!((missed.from, missed.to, missed.occupied_slots, missed.missed_calls), (G8lS558CallPhase::Incoming, G8lS558CallPhase::Idle, 0, 1));
    assert_eq!(incoming.call(1), None);
    assert_eq!(incoming.missed_calls(), 1);
    assert_eq!(apply(&mut incoming, 3, G8lS558Event::Answer), Err(G8lS558VoiceCallError::NoIncomingCall));
    // The freed slot can be reused by a new call.
    let ring = published(apply(&mut incoming, 3, G8lS558Event::Ring { number: None }));
    assert_eq!((ring.call_idx, ring.setup_ticks), (1, 0));
}

#[test]
fn step_out_of_order_and_ledger_bound_fail_closed() {
    let mut state = G8lS558VoiceCallState::new();
    let dialed = number("+905551234567");
    assert_eq!(apply(&mut state, 1, G8lS558Event::Dial(dialed)), Err(G8lS558VoiceCallError::StepOutOfOrder));
    assert!(state.receipts().is_empty());
    let step = establish_outgoing(&mut state, "+905551234567");
    assert_eq!(apply(&mut state, step + 1, G8lS558Event::Tick(1)), Err(G8lS558VoiceCallError::StepOutOfOrder));
    assert_eq!(apply(&mut state, usize::MAX, G8lS558Event::Tick(1)), Err(G8lS558VoiceCallError::StepOutOfOrder));
    let mut current = step;
    while current < S558_MAX_TRANSITIONS {
        published(apply(&mut state, current, G8lS558Event::Tick(1)));
        current += 1;
    }
    assert_eq!(state.receipts().len(), S558_MAX_TRANSITIONS);
    assert_eq!(apply(&mut state, current, G8lS558Event::Tick(1)), Err(G8lS558VoiceCallError::LedgerFull));
    assert_eq!(apply(&mut state, current, G8lS558Event::Hangup { idx: 1 }), Err(G8lS558VoiceCallError::LedgerFull));
    assert_eq!(state.receipts().len(), S558_MAX_TRANSITIONS);
    assert_eq!(state.call(1).unwrap().duration_ticks, (S558_MAX_TRANSITIONS - step) as u32);
    // Retained replay of an already published step still works when the ledger is full.
    let first = state.receipts()[0];
    assert_eq!(
        apply(&mut state, 0, G8lS558Event::Dial(dialed)),
        Ok(G8lS558VoiceCallOutcome::TransitionRetained(first))
    );
}

#[test]
fn at_command_encoders_emit_exact_strings() {
    assert_eq!(encode_atd(number("+905551234567")), "ATD+905551234567;");
    assert_eq!(encode_atd(number("112")), "ATD112;");
    assert_eq!(encode_ata(), "ATA");
    assert_eq!(encode_ath(), "ATH");
    assert_eq!(encode_chup(), "AT+CHUP");
    assert_eq!(encode_clcc_query(), "AT+CLCC");
    assert_eq!(encode_vts(b'5', 1).unwrap(), "AT+VTS=5,1");
    assert_eq!(encode_vts(b'D', 100).unwrap(), "AT+VTS=D,100");
    assert_eq!(encode_vts(b'*', 101), Err(G8lS558VoiceCallError::InvalidDtmfDuration));
    for phase in [
        G8lS558CallPhase::Dialing,
        G8lS558CallPhase::Alerting,
        G8lS558CallPhase::Active,
        G8lS558CallPhase::Held,
        G8lS558CallPhase::Incoming,
        G8lS558CallPhase::Waiting,
    ] {
        let stat = phase.clcc_stat().unwrap();
        assert!(stat.code() <= S558_CLCC_STAT_MAX);
        assert_eq!(G8lS558ClccStat::from_code(stat.code()), Some(stat));
    }
    assert_eq!(G8lS558CallPhase::Idle.clcc_stat(), None);
    assert_eq!(G8lS558CallPhase::Disconnecting.clcc_stat(), None);
    let state = G8lS558VoiceCallState::default();
    assert_eq!(state.occupied_slots(), 0);
    assert_eq!(state.active_calls(), 0);
    assert_eq!(state.held_calls(), 0);
    assert_eq!(state.phase(1), G8lS558CallPhase::Idle);
    assert_eq!(state.phase(9), G8lS558CallPhase::Idle);
}
snippet sha256: ae6165f20622file sha256: ae6165f20622
03 · Kapı kimlik kaydı

Operations sıra, kimlik ve başlık bağı

tam Operations kaydıL2424–L2483
website/src/lib/operations.ts::g8l-s558-r1-voice-call-state-machine-model
  {
    id: "g8l-s558-r1-voice-call-state-machine-model",
    date: "2026-08-30",
    sequence: 558,
    status: "passed",
    umbrella_status: "partial",
    title: "S558 · R1 modem: sesli arama durum makinesi modeli",
    summary:
      "S558 kaynak kapısı PASS'tir: R1 modem yolunun sesli arama kontrolü — ATD<numara>; çevirme ve E.164 tarzı numara doğrulama (opsiyonel + ve 3–20 rakam), ATA cevaplama, ATH/+CHUP kapatma, +CHLD tarzı hold/resume, +CLCC list-current-calls ayrıştırma (idx, dir, stat 0..5, mode, mpty, numara), RING/+CLIP URC işleme, +VTS DTMF doğrulama, en fazla 1 aktif + 1 held sınırlı eşzamanlı çağrı tablosu, kurulum zaman aşımı ve checked süre tick'leri — Idle→Dialing→Alerting→Active→Disconnecting→Idle ve Incoming/Waiting→Active geçişleriyle saf host modeli olarak modellendi. Her geçersiz geçiş, bozuk satır, geçersiz numara/ton, sıra dışı adım ve taşma state değiştirmeden fail-closed reddedilir; exact replay aynı receipt ile retained döner. Focused 18/18 PASS'tir. S540 ve S543 fiziksel verdict'leri değişmez RED kalır; hiçbir modem donanımı yoktur, physical observation=0 ve RUNBOOK_EXECUTED_IN_S558=NO'dur. S559 sesli aramanın ses yolunu bağlayan audio route / PCM capability model kapısıdır.",
    evidence: [
      "S558, S557'den ayrı source module, 18-test focused binary, proof, status manifest bloğu, Operations kaydı ve complete Code kartına sahiptir.",
      "Dar S558 source-model status=PASS; R1 umbrella=PARTIAL ve S540/S543 physical gate status=RED olarak ayrı tutulur.",
      "Numara doğrulama opsiyonel önde + ve 3..=20 ASCII rakam kabul eder; boş, kısa, 21+ rakam, yanlış yerde + ve rakam dışı karakter ayrı hata kodlarıyla fail-closed reddedilir.",
      "ATD<numara>; yalnız doğrulanmış numaradan encode edilir; type-of-address uluslararası için 145, aksi halde 129'dur.",
      "+CLCC satırı exact 5 veya 7 alanla ayrıştırılır: idx 1..=2, dir 0..1, stat 0..5 (active/held/dialing/alerting/incoming/waiting), voice mode 0, mpty 0..1 ve numara/type tutarlılığı zorunludur; on sekiz bozuk satır sınıfı testte ayrı ayrı reddedilir.",
      "RING tanıyıcı ve +CLIP ayrıştırıcı gelen aramanın numarasını aynı tutarlılık kuralıyla üretir; Incoming/Waiting fazına CLCC raporu ile bağlanır.",
      "Geçiş tablosu Idle→Dialing→Alerting→Active→Disconnecting→Idle ve Incoming/Waiting→Active'dir; Dialing'den doğrudan Active'e CLCC raporu, Disconnecting'e yeni rapor ve faz dışı her stat IllegalTransition ile reddedilir.",
      "Eşzamanlılık sınırı 2 slot, en fazla 1 aktif + 1 held'dir: aktif çağrı varken Dial/Answer, held varken Hold, aktif varken Resume, üçüncü çağrı ve devam eden kurulum sırasında yeni kurulum fail-closed döner.",
      "+VTS DTMF tonu 0-9/*/#/A-D ve süre 1..=100 ile doğrulanır; ton yalnız aktif çağrı varken gönderilir ve AT+VTS=<ton>,<süre> exact encode edilir.",
      "Tick 1..=3600 sınırlıdır: aktif/held çağrılar checked toplama ile en fazla 86400 tick süre biriktirir, Dialing/Alerting 90 tick sonra Disconnecting'e, Incoming/Waiting 45 tick sonra missed-call olarak Idle'a düşer; çağrısız tick ve taşma fail-closed'dur.",
      "Ledger en fazla 96 receipt tutar; exact replay TransitionRetained, publication sonrası sapan girdi PublishedStateDrift, sıra dışı adım StepOutOfOrder döner ve state değişmez.",
      "Otuz beş hata varyantı 1..=35 nonzero benzersiz diagnostic kodu taşır; focused test benzersizliği BTreeSet ile doğrular.",
      "Focused target CARGO_INCREMENTAL=0 ile 1 grup / 18 passed / 0 failed / 0 ignored / 0 filtered verdi.",
      "Implementation 34362 B / bae7dba15c24acd69141a060eeadfbe1fe7dd68f3784e763de52bf7d1133552e; focused test 33580 B / ae6165f20622b01822762ad50e41b14ebf0e4e00d33b11f0468f718fdf0c2202 SHA-256'dır.",
      "Proof 6211 B'dir ve model tablolarını, fail-closed koşullarını ve non-claim listesini içerir.",
      "Modül hiçbir production callsite'a bağlanmadı; unsafe, asm!, write_volatile, crate::uart, crate::arch, spin:: ve #[no_mangle] yüzeyi yoktur ve focused test bunu source üzerinde doğrular.",
      "Her receipt hardware_present=false, physical_observations=0 ve runbook_executed=false taşır.",
      "RUNBOOK_EXECUTED_IN_S558=NO; supported-profile runtime observations=0, physical observations=0, SD/UART/power/new-raw=0/0/0/0, Boot-to-UI physically observed=false ve R1 acceptance=false'dur.",
      "S540 ve S543 immutable fiziksel RED verdict'leri korunur; automatic promotion=false'dur ve S546 sonucu varsayılmaz.",
      "S559 sesli aramanın earpiece/speaker/headset ses yolu ve PCM capability modelini host-only kuracaktır; aygıt veya fiziksel koşu yetkisi değildir.",
    ],
    commands: [
      "CARGO_INCREMENTAL=0 cargo test -p aselsan_microkernel_simulation --test g8l_target_dispatch_scheduler_owner_scheduler_mutation_production_migration_lifecycle_s558_r1_voice_call_state_machine_model -- --test-threads=1",
    ],
    terminalSessions: [
      {
        id: "s558-focused",
        title: "S558 sesli arama durum makinesi focused acceptance",
        commandLines: [
          "CARGO_INCREMENTAL=0 cargo test -p aselsan_microkernel_simulation --test g8l_target_dispatch_scheduler_owner_scheduler_mutation_production_migration_lifecycle_s558_r1_voice_call_state_machine_model -- --test-threads=1",
        ],
        outputLines: [
          "test result: ok. 18 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s",
          "S558 focused=1 group / 18 passed / 0 failed",
          "hardware=none physical=0 runbook=NO",
        ],
        exitCode: 0,
        outputMode: "complete",
      },
    ],
    terminalSessionsNote:
      "S558 saf kaynak/host model kapısıdır; modem donanımı, AT transportu, UART veya fiziksel gözlem içermez. S540/S543 RED verdict'leri değişmez.",
    limitations: [
      "S558 yalnız kaynak/host modelidir; hiçbir donanım/panel/modem/board gözlemi yoktur ve gerçek modem üzerinde ATD/ATA/ATH/+CLCC davranışı gözlenmemiştir.",
      "Modül production yoluna bağlanmadı; boot, IRQ, scheduler veya driver çağrısı yoktur.",
      "S540 ve S543 fiziksel RED immutable kalır; automatic promotion yoktur ve S546 üçüncü fiziksel koşunun sonucu varsayılmaz.",
      "Boot-to-UI physically observed=false ve R1 acceptance complete=false kalır.",
      "S559 audio route / PCM capability modelini host-only kuracaktır; aygıt, ses veya fiziksel koşu yetkisi vermez.",
    ],
  },
snippet sha256: fa9cd0ca2b6dfile sha256: 9726dbf00f84
Focused test komutu
CARGO_INCREMENTAL=0 cargo test -p aselsan_microkernel_simulation --test g8l_target_dispatch_scheduler_owner_scheduler_mutation_production_migration_lifecycle_s558_r1_voice_call_state_machine_model -- --test-threads=1
proof: docs/M8.1-RPi5-G8l-S558-R1-Voice-Call-State-Machine-Model-Proof.md
Registry schema v5 · generator website/scripts/generate-code-gates.mjs · Tam SHA-256: 91d38c7b6222f0b4c117be786454853543da55a160e543d9b951057cc20dcc06