Skip to main content

cloud_sdk_testkit/
script.rs

1//! Validated finite pagination and action-polling scenario scripts.
2
3use crate::{
4    ActionState, DynamicRequest, FixtureKind, MAX_DYNAMIC_RECORDS, ProviderFixtureBuilder,
5    ResponseFixture,
6};
7
8/// Invalid or exhausted deterministic scenario script.
9#[derive(Clone, Copy, Debug, Eq, PartialEq)]
10pub enum ScenarioScriptError {
11    /// A script requires at least one response fixture.
12    Empty,
13    /// The script exceeds the bounded dynamic-record limit.
14    TooManySteps,
15    /// A pagination script contains a non-pagination fixture.
16    ExpectedPagination,
17    /// Pagination pages must begin at one and increase by one.
18    InvalidPageSequence,
19    /// Page size, entry total, and last page must remain stable.
20    PaginationMetadataChanged,
21    /// The final pagination fixture must represent the last page.
22    PaginationDidNotFinish,
23    /// An action script contains a non-action fixture.
24    ExpectedAction,
25    /// Action progress must not decrease.
26    ActionProgressDecreased,
27    /// Only the final action fixture may be terminal.
28    ActionFinishedEarly,
29    /// The final action fixture must be terminal.
30    ActionDidNotFinish,
31    /// No scripted response remains.
32    Exhausted,
33}
34
35impl_static_error!(ScenarioScriptError,
36    Self::Empty => "scenario script is empty",
37    Self::TooManySteps => "scenario script exceeds the step limit",
38    Self::ExpectedPagination => "pagination script contains a different fixture kind",
39    Self::InvalidPageSequence => "pagination script page sequence is invalid",
40    Self::PaginationMetadataChanged => "pagination script metadata changes between pages",
41    Self::PaginationDidNotFinish => "pagination script does not finish on the last page",
42    Self::ExpectedAction => "action script contains a different fixture kind",
43    Self::ActionProgressDecreased => "action script progress decreases",
44    Self::ActionFinishedEarly => "action script finishes before its final step",
45    Self::ActionDidNotFinish => "action script does not finish",
46    Self::Exhausted => "scenario script has no response remaining",
47);
48
49/// Coherent finite pagination response sequence.
50pub struct PaginationScript<'fixture> {
51    fixtures: &'fixture [ResponseFixture<'fixture>],
52}
53
54impl<'fixture> PaginationScript<'fixture> {
55    /// Validates a complete page-one-through-last-page sequence.
56    pub fn new(
57        fixtures: &'fixture [ResponseFixture<'fixture>],
58    ) -> Result<Self, ScenarioScriptError> {
59        validate_length(fixtures)?;
60        let first = fixtures
61            .first()
62            .and_then(ResponseFixture::pagination)
63            .ok_or(ScenarioScriptError::ExpectedPagination)?;
64        let mut expected_page = 1_u64;
65        for fixture in fixtures {
66            if fixture.kind() != FixtureKind::Pagination {
67                return Err(ScenarioScriptError::ExpectedPagination);
68            }
69            let page = fixture
70                .pagination()
71                .ok_or(ScenarioScriptError::ExpectedPagination)?;
72            if page.page() != expected_page {
73                return Err(ScenarioScriptError::InvalidPageSequence);
74            }
75            if page.per_page() != first.per_page()
76                || page.total_entries() != first.total_entries()
77                || page.last_page() != first.last_page()
78            {
79                return Err(ScenarioScriptError::PaginationMetadataChanged);
80            }
81            expected_page = expected_page
82                .checked_add(1)
83                .ok_or(ScenarioScriptError::InvalidPageSequence)?;
84        }
85        let last = fixtures
86            .last()
87            .and_then(ResponseFixture::pagination)
88            .ok_or(ScenarioScriptError::ExpectedPagination)?;
89        if last.page() != last.last_page() {
90            return Err(ScenarioScriptError::PaginationDidNotFinish);
91        }
92        Ok(Self { fixtures })
93    }
94
95    /// Returns the validated number of page responses.
96    #[must_use]
97    pub const fn len(&self) -> usize {
98        self.fixtures.len()
99    }
100
101    /// Reports whether this script has no steps. Valid scripts are never empty.
102    #[must_use]
103    pub const fn is_empty(&self) -> bool {
104        self.fixtures.is_empty()
105    }
106}
107
108impl<'fixture> ProviderFixtureBuilder<'fixture> for PaginationScript<'fixture> {
109    type Error = ScenarioScriptError;
110
111    fn build<'request>(
112        &self,
113        request: DynamicRequest<'request>,
114    ) -> Result<&'fixture ResponseFixture<'fixture>, Self::Error> {
115        self.fixtures
116            .get(request.sequence())
117            .ok_or(ScenarioScriptError::Exhausted)
118    }
119}
120
121/// Coherent finite running-to-terminal action response sequence.
122pub struct ActionScript<'fixture> {
123    fixtures: &'fixture [ResponseFixture<'fixture>],
124}
125
126impl<'fixture> ActionScript<'fixture> {
127    /// Validates nondecreasing progress and a single final terminal state.
128    pub fn new(
129        fixtures: &'fixture [ResponseFixture<'fixture>],
130    ) -> Result<Self, ScenarioScriptError> {
131        validate_length(fixtures)?;
132        let mut previous_progress = 0_u8;
133        let final_index = fixtures
134            .len()
135            .checked_sub(1)
136            .ok_or(ScenarioScriptError::Empty)?;
137        for (index, fixture) in fixtures.iter().enumerate() {
138            if fixture.kind() != FixtureKind::Action {
139                return Err(ScenarioScriptError::ExpectedAction);
140            }
141            let action = fixture
142                .action_metadata()
143                .ok_or(ScenarioScriptError::ExpectedAction)?;
144            if action.progress() < previous_progress {
145                return Err(ScenarioScriptError::ActionProgressDecreased);
146            }
147            let terminal = !matches!(action.state(), ActionState::Running);
148            if terminal && index != final_index {
149                return Err(ScenarioScriptError::ActionFinishedEarly);
150            }
151            if !terminal && index == final_index {
152                return Err(ScenarioScriptError::ActionDidNotFinish);
153            }
154            previous_progress = action.progress();
155        }
156        Ok(Self { fixtures })
157    }
158
159    /// Returns the validated number of polling responses.
160    #[must_use]
161    pub const fn len(&self) -> usize {
162        self.fixtures.len()
163    }
164
165    /// Reports whether this script has no steps. Valid scripts are never empty.
166    #[must_use]
167    pub const fn is_empty(&self) -> bool {
168        self.fixtures.is_empty()
169    }
170}
171
172impl<'fixture> ProviderFixtureBuilder<'fixture> for ActionScript<'fixture> {
173    type Error = ScenarioScriptError;
174
175    fn build<'request>(
176        &self,
177        request: DynamicRequest<'request>,
178    ) -> Result<&'fixture ResponseFixture<'fixture>, Self::Error> {
179        self.fixtures
180            .get(request.sequence())
181            .ok_or(ScenarioScriptError::Exhausted)
182    }
183}
184
185fn validate_length(fixtures: &[ResponseFixture<'_>]) -> Result<(), ScenarioScriptError> {
186    if fixtures.is_empty() {
187        return Err(ScenarioScriptError::Empty);
188    }
189    if fixtures.len() > MAX_DYNAMIC_RECORDS {
190        return Err(ScenarioScriptError::TooManySteps);
191    }
192    Ok(())
193}