Skip to main content

ic_testkit/pic/
baseline_pool.rs

1use candid::Principal;
2use std::{
3    collections::{BTreeMap, BTreeSet},
4    num::NonZeroUsize,
5    ops::Deref,
6    panic::{AssertUnwindSafe, catch_unwind},
7    time::{Duration, Instant},
8};
9
10use super::{
11    CachedPocketIcBaseline, ControllerSnapshots, SnapshotRestoreFunding,
12    bounded_pool::{BoundedSlotLease, BoundedSlotPool},
13};
14
15/// Caller-owned stable identity for one pooled fixture recipe.
16#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
17pub struct FixtureRecipeId(String);
18
19impl From<SnapshotRestoreFunding> for CycleResetPolicy {
20    fn from(funding: SnapshotRestoreFunding) -> Self {
21        match funding {
22            SnapshotRestoreFunding::Preserve => Self::PreserveCurrent,
23            SnapshotRestoreFunding::TopUpTo { minimum_cycles } => Self::TopUpTo(minimum_cycles),
24        }
25    }
26}
27
28/// Reset domain whose handling is declared by a pooled baseline recipe.
29#[non_exhaustive]
30#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
31pub enum ResetDomainKind {
32    /// PocketIC simulated time.
33    PocketIcTime,
34    /// Canisters outside the captured baseline set.
35    ExtraCanisters,
36    /// Pending ingress, timers, or cross-canister messages.
37    PendingMessages,
38    /// Subnet metrics, routing, allocation, or other subnet-global state.
39    SubnetState,
40    /// Files, processes, services, or other caller-owned resources.
41    ExternalResources,
42}
43
44/// Cycle handling required or achieved by one reset.
45#[non_exhaustive]
46#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub enum CycleResetPolicy {
48    /// Do not proactively add or remove cycles before snapshot restoration.
49    ///
50    /// PocketIC may still charge cycles while performing the restore.
51    PreserveCurrent,
52    /// Add cycles as needed to reach this minimum immediately before restore,
53    /// without removing excess.
54    TopUpTo(u128),
55    /// Restore the exact balance recorded by the recipe baseline.
56    RestoreExactBaseline,
57    /// Treat any relevant cycle mutation as requiring slot reconstruction.
58    RebuildOnMutation,
59}
60
61/// PocketIC time handling required or achieved by one reset.
62#[non_exhaustive]
63#[derive(Clone, Copy, Debug, Eq, PartialEq)]
64pub enum TimeResetPolicy {
65    /// Preserve the current simulator time rather than claiming it was reset.
66    PreserveCurrent,
67    /// Restore the exact time recorded by the recipe baseline.
68    RestoreBaseline,
69    /// Treat any relevant time mutation as requiring slot reconstruction.
70    RebuildOnMutation,
71}
72
73/// Extra-canister handling required or achieved by one reset.
74#[non_exhaustive]
75#[derive(Clone, Copy, Debug, Eq, PartialEq)]
76pub enum ExtraCanisterPolicy {
77    /// Validate that the baseline canister set is unchanged.
78    RequireBaselineSet,
79    /// Remove canisters explicitly tracked by the recipe.
80    RemoveTracked,
81    /// Treat any extra-canister change as requiring slot reconstruction.
82    RebuildOnChange,
83}
84
85/// Generic handling for reset domains without a more specific policy.
86#[non_exhaustive]
87#[derive(Clone, Copy, Debug, Eq, PartialEq)]
88pub enum StateResetPolicy {
89    /// Reset the domain through caller-owned recipe logic.
90    ResetByRecipe,
91    /// Validate that the domain remained unchanged.
92    ValidateUnchanged,
93    /// Explicitly declare the domain irrelevant to this recipe's guarantees.
94    IrrelevantByRecipeContract,
95    /// Treat any relevant change as requiring slot reconstruction.
96    RebuildOnChange,
97}
98
99/// Policy for one non-snapshot reset domain.
100///
101/// [`ResetRequirements`] declares policies that a recipe requires;
102/// [`ResetReceipt`] reports policies achieved by its reset operation. The pool
103/// checks every required domain against the reported policy before reuse.
104///
105/// ```
106/// use ic_testkit::pic::{
107///     CycleResetPolicy, ResetDomainKind, ResetDomainPolicy, ResetReceipt,
108///     ResetRequirements, TimeResetPolicy,
109/// };
110///
111/// let requirements = ResetRequirements::try_new(
112///     CycleResetPolicy::PreserveCurrent,
113///     [ResetDomainPolicy::PocketIcTime(TimeResetPolicy::PreserveCurrent)],
114/// )?;
115/// // The reset operation deliberately preserves simulated time.
116/// let receipt = ResetReceipt::try_new([
117///     ResetDomainPolicy::PocketIcTime(TimeResetPolicy::PreserveCurrent),
118/// ])?;
119/// assert_eq!(
120///     requirements.get(ResetDomainKind::PocketIcTime),
121///     receipt.get(ResetDomainKind::PocketIcTime),
122/// );
123/// # Ok::<(), ic_testkit::pic::BaselinePoolContractError>(())
124/// ```
125#[non_exhaustive]
126#[derive(Clone, Debug, Eq, PartialEq)]
127pub enum ResetDomainPolicy {
128    /// PocketIC time policy.
129    PocketIcTime(TimeResetPolicy),
130    /// Extra-canister policy.
131    ExtraCanisters(ExtraCanisterPolicy),
132    /// Pending-message policy.
133    PendingMessages(StateResetPolicy),
134    /// Subnet-state policy.
135    SubnetState(StateResetPolicy),
136    /// External-resource policy.
137    ExternalResources(StateResetPolicy),
138}
139
140/// Typed reset guarantees required by one fixture recipe.
141#[derive(Clone, Debug, Eq, PartialEq)]
142pub struct ResetRequirements {
143    cycle_policy: CycleResetPolicy,
144    domains: BTreeMap<ResetDomainKind, ResetDomainPolicy>,
145}
146
147/// Typed reset guarantees achieved by one preparation pass.
148#[derive(Clone, Debug, Default, Eq, PartialEq)]
149pub struct ResetReceipt(BTreeMap<ResetDomainKind, ResetDomainPolicy>);
150
151/// Receipt for restoring the recipe's captured canister set.
152#[derive(Clone, Debug, Eq, PartialEq)]
153pub struct CanisterRestoreReceipt {
154    canister_ids: Vec<Principal>,
155    cycle_policy: CycleResetPolicy,
156}
157
158/// Receipt identifying the readiness boundary reached after reset.
159#[derive(Clone, Debug, Eq, PartialEq)]
160pub struct ReadinessReceipt {
161    identity: String,
162}
163
164/// Receipt proving the recipe's final invariant validation ran successfully.
165#[derive(Clone, Debug, Eq, PartialEq)]
166pub struct ValidationReceipt {
167    recipe_id: FixtureRecipeId,
168    invariant_identity: String,
169}
170
171/// Contract failure while constructing or verifying recipe reset evidence.
172#[non_exhaustive]
173#[derive(Clone, Debug, Eq, PartialEq)]
174pub enum BaselinePoolContractError {
175    /// Recipe identity was empty or whitespace-only.
176    EmptyRecipeIdentity,
177    /// A receipt identity was empty or whitespace-only.
178    EmptyReceiptIdentity { receipt: &'static str },
179    /// A reset domain was declared more than once.
180    DuplicateResetDomain { domain: ResetDomainKind },
181    /// A restored canister appeared more than once.
182    DuplicateCanisterId { canister_id: Principal },
183    /// A restore receipt contained no canisters.
184    EmptyCanisterSet,
185    /// The restore receipt did not satisfy the required cycle policy.
186    CyclePolicyMismatch {
187        required: CycleResetPolicy,
188        achieved: CycleResetPolicy,
189    },
190    /// The restored canister receipt did not identify the complete snapshot set.
191    RestoreCanisterSetMismatch {
192        expected: Vec<Principal>,
193        actual: Vec<Principal>,
194    },
195    /// A required reset domain had no matching achievement.
196    MissingResetDomain { domain: ResetDomainKind },
197    /// A reset achievement did not satisfy the required policy.
198    ResetPolicyMismatch {
199        requirement: ResetDomainPolicy,
200        achievement: ResetDomainPolicy,
201    },
202    /// Final validation reported a recipe other than the pool-owned recipe.
203    RecipeIdentityMismatch {
204        expected: FixtureRecipeId,
205        actual: FixtureRecipeId,
206    },
207}
208
209/// Whether validation is observing a newly built or restored baseline.
210#[non_exhaustive]
211#[derive(Clone, Debug, Eq, PartialEq)]
212pub enum PreparedBaseline {
213    /// The recipe just built this baseline.
214    Built,
215    /// The recipe restored and reset an existing baseline.
216    Restored {
217        /// Captured canisters restored by the recipe.
218        canisters: CanisterRestoreReceipt,
219        /// Typed non-snapshot reset receipt.
220        reset: ResetReceipt,
221        /// Readiness boundary reached after reset.
222        readiness: ReadinessReceipt,
223    },
224}
225
226/// Recipe stage associated with a structured preparation failure.
227#[non_exhaustive]
228#[derive(Clone, Copy, Debug, Eq, PartialEq)]
229pub enum BaselinePreparationStage {
230    /// Constructing a new baseline.
231    Build,
232    /// Restoring captured canisters.
233    RestoreCanisters,
234    /// Resetting state outside the snapshots.
235    ResetNonSnapshotState,
236    /// Driving the restored topology to readiness.
237    DriveToReadiness,
238    /// Validating a newly built baseline.
239    ValidateBuilt,
240    /// Validating a restored baseline.
241    ValidateRestored,
242}
243
244/// Why an invalid or failed slot was reconstructed.
245#[non_exhaustive]
246#[derive(Clone, Debug, Eq, PartialEq)]
247pub enum RebuildReason {
248    /// PocketIC transport was no longer reachable.
249    DeadPocketIcTransport,
250    /// Captured snapshot restoration failed.
251    SnapshotRestoreFailure,
252    /// Non-snapshot reset failed.
253    ResetFailure,
254    /// Readiness or quiescence could not be established.
255    ReadinessFailure,
256    /// Required and achieved reset domains did not match.
257    ResetCoverageMismatch,
258    /// Final invariant validation failed.
259    InvariantValidationFailure,
260    /// A caller explicitly invalidated its lease.
261    ExplicitLeaseInvalidation,
262    /// A lease was dropped while its thread was unwinding.
263    UnwindWhileLeased,
264    /// Recipe-specific structured reason.
265    RecipeClassified { code: String },
266}
267
268/// Recipe decision for a failed restored-slot preparation stage.
269#[non_exhaustive]
270#[derive(Clone, Debug, Eq, PartialEq)]
271pub enum FailureDisposition {
272    /// Return the failure without rebuilding during this acquisition.
273    Fatal,
274    /// Invalidate and rebuild the slot once.
275    Rebuild(RebuildReason),
276}
277
278/// Timings for one baseline-pool acquisition.
279#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
280pub struct BaselinePoolTimings {
281    wait: Duration,
282    build: Option<Duration>,
283    restore: Option<Duration>,
284    reset: Option<Duration>,
285    readiness: Option<Duration>,
286    validation: Option<Duration>,
287    stale_teardown: Option<Duration>,
288    total: Duration,
289}
290
291/// One successful acquisition's slot, timings and preparation disposition.
292#[derive(Clone, Debug, Eq, PartialEq)]
293pub struct BaselinePoolOutcome {
294    /// Diagnostic slot index.
295    pub slot: usize,
296    /// Acquisition phase timings.
297    pub timings: BaselinePoolTimings,
298    /// Whether this acquisition built, restored or rebuilt the baseline.
299    pub disposition: BaselinePoolDisposition,
300}
301
302/// Whether a baseline-pool lease was built, restored, or rebuilt.
303#[non_exhaustive]
304#[derive(Clone, Debug, Eq, PartialEq)]
305pub enum BaselinePoolDisposition {
306    /// An empty slot was constructed and validated.
307    Built,
308    /// An existing slot was restored, reset, and validated.
309    Restored,
310    /// An invalid or failed slot was reconstructed and validated.
311    Rebuilt(RebuildReason),
312}
313
314/// One failed recipe or contract stage while preparing a baseline slot.
315#[non_exhaustive]
316#[derive(Debug)]
317pub enum BaselinePoolPreparationError<E> {
318    /// Caller-owned recipe logic returned an error.
319    Recipe {
320        /// Failed lifecycle stage.
321        stage: BaselinePreparationStage,
322        /// Caller-owned structured source error.
323        source: E,
324    },
325    /// Typed reset or recipe evidence violated the pool contract.
326    Contract(BaselinePoolContractError),
327}
328
329/// Failure to acquire a validated baseline-pool lease.
330#[non_exhaustive]
331#[derive(Debug)]
332pub enum BaselinePoolError<E> {
333    /// Initial construction or a non-rebuilt preparation failed.
334    Preparation {
335        /// Recipe or contract failure that stopped acquisition.
336        error: BaselinePoolPreparationError<E>,
337        /// Phase timings recorded before acquisition failed.
338        timings: Box<BaselinePoolTimings>,
339    },
340    /// Reused-slot preparation failed and its one rebuild attempt also failed.
341    RecoveryFailed {
342        /// Original restore/reset/readiness/validation failure.
343        original: Box<BaselinePoolPreparationError<E>>,
344        /// Failure while rebuilding or validating the replacement.
345        rebuild: Box<BaselinePoolPreparationError<E>>,
346        /// Combined timings for preparation, stale teardown, and rebuilding.
347        timings: Box<BaselinePoolTimings>,
348    },
349}
350
351/// Complete caller-owned lifecycle recipe for one pooled PocketIC baseline.
352pub trait PocketIcBaselineRecipe: Send + Sync + 'static {
353    /// Metadata retained beside every baseline owned by this recipe.
354    type Metadata: Send + 'static;
355    /// Structured caller error shared by recipe lifecycle stages.
356    type Error: std::error::Error + Send + Sync + 'static;
357
358    /// Stable caller-owned recipe identity.
359    fn id(&self) -> &FixtureRecipeId;
360
361    /// Reset guarantees required before an existing slot may be reused.
362    fn reset_requirements(&self) -> &ResetRequirements;
363
364    /// Construct and capture one complete baseline.
365    fn build(&self) -> Result<CachedPocketIcBaseline<Self::Metadata>, Self::Error>;
366
367    /// Restore every captured canister and report the cycle policy applied.
368    fn restore_canisters(
369        &self,
370        baseline: &CachedPocketIcBaseline<Self::Metadata>,
371    ) -> Result<CanisterRestoreReceipt, Self::Error>;
372
373    /// Reset state not covered by canister snapshots.
374    fn reset_non_snapshot_state(
375        &self,
376        baseline: &CachedPocketIcBaseline<Self::Metadata>,
377    ) -> Result<ResetReceipt, Self::Error>;
378
379    /// Drive the topology to the recipe's readiness boundary.
380    fn drive_to_readiness(
381        &self,
382        baseline: &CachedPocketIcBaseline<Self::Metadata>,
383    ) -> Result<ReadinessReceipt, Self::Error>;
384
385    /// Validate the same baseline invariants after build and restore.
386    fn validate(
387        &self,
388        baseline: &CachedPocketIcBaseline<Self::Metadata>,
389        preparation: &PreparedBaseline,
390    ) -> Result<ValidationReceipt, Self::Error>;
391
392    /// Classify a restored-slot recipe failure as fatal or rebuildable.
393    fn classify_failure(
394        &self,
395        stage: BaselinePreparationStage,
396        _error: &Self::Error,
397    ) -> FailureDisposition {
398        FailureDisposition::Rebuild(stage.default_rebuild_reason())
399    }
400}
401
402/// Caller-owned runtime-capacity pool of independently restorable PocketIC baselines.
403///
404/// One pool structurally owns one [`PocketIcBaselineRecipe`]. A warm
405/// acquisition restores the complete captured canister set, applies the
406/// recipe's non-snapshot reset, reaches its readiness boundary, checks typed
407/// reset coverage, and validates final invariants before exposing a lease.
408/// Each capacity slot owns an independent PocketIC instance.
409///
410/// Snapshot reuse is not a complete PocketIC rollback. The recipe must account
411/// for time, extra canisters, pending messages, subnet state, cycles, and
412/// external resources when those domains matter to its tests.
413pub struct CachedPocketIcBaselinePool<R>
414where
415    R: PocketIcBaselineRecipe,
416{
417    recipe: R,
418    slots: BoundedSlotPool<CachedPocketIcBaseline<R::Metadata>, RebuildReason>,
419}
420
421/// Exclusive lease of one validated pooled PocketIC baseline.
422pub struct CachedPocketIcBaselinePoolGuard<'a, R>
423where
424    R: PocketIcBaselineRecipe,
425{
426    slot: BoundedSlotLease<'a, CachedPocketIcBaseline<R::Metadata>, RebuildReason>,
427}
428
429impl FixtureRecipeId {
430    /// Construct a nonempty caller-owned stable recipe identity.
431    pub fn try_new(identity: impl Into<String>) -> Result<Self, BaselinePoolContractError> {
432        let identity = identity.into();
433        if identity.trim().is_empty() {
434            return Err(BaselinePoolContractError::EmptyRecipeIdentity);
435        }
436        Ok(Self(identity))
437    }
438
439    /// Borrow the recipe identity.
440    #[must_use]
441    pub fn as_str(&self) -> &str {
442        &self.0
443    }
444}
445
446impl ResetDomainPolicy {
447    /// Domain governed by this policy.
448    #[must_use]
449    pub const fn domain(&self) -> ResetDomainKind {
450        match self {
451            Self::PocketIcTime(_) => ResetDomainKind::PocketIcTime,
452            Self::ExtraCanisters(_) => ResetDomainKind::ExtraCanisters,
453            Self::PendingMessages(_) => ResetDomainKind::PendingMessages,
454            Self::SubnetState(_) => ResetDomainKind::SubnetState,
455            Self::ExternalResources(_) => ResetDomainKind::ExternalResources,
456        }
457    }
458}
459
460fn collect_reset_domains(
461    policies: impl IntoIterator<Item = ResetDomainPolicy>,
462) -> Result<BTreeMap<ResetDomainKind, ResetDomainPolicy>, BaselinePoolContractError> {
463    let mut domains = BTreeMap::new();
464    for policy in policies {
465        let domain = policy.domain();
466        if domains.insert(domain, policy).is_some() {
467            return Err(BaselinePoolContractError::DuplicateResetDomain { domain });
468        }
469    }
470    Ok(domains)
471}
472
473impl ResetRequirements {
474    /// Construct a duplicate-checked reset requirement set.
475    ///
476    /// Every recipe restores its complete snapshot set. The required cycle
477    /// policy is explicit; `requirements` describe only non-snapshot domains.
478    pub fn try_new<I>(
479        cycle_policy: CycleResetPolicy,
480        requirements: I,
481    ) -> Result<Self, BaselinePoolContractError>
482    where
483        I: IntoIterator<Item = ResetDomainPolicy>,
484    {
485        Ok(Self {
486            cycle_policy,
487            domains: collect_reset_domains(requirements)?,
488        })
489    }
490
491    /// Cycle policy required of the canister restore receipt.
492    #[must_use]
493    pub const fn cycle_policy(&self) -> CycleResetPolicy {
494        self.cycle_policy
495    }
496
497    /// Read the requirement for one domain.
498    #[must_use]
499    pub fn get(&self, domain: ResetDomainKind) -> Option<&ResetDomainPolicy> {
500        self.domains.get(&domain)
501    }
502
503    /// Iterate over requirements in deterministic domain order.
504    pub fn iter(&self) -> impl Iterator<Item = &ResetDomainPolicy> {
505        self.domains.values()
506    }
507
508    fn verify(
509        &self,
510        restore: &CanisterRestoreReceipt,
511        receipt: &ResetReceipt,
512    ) -> Result<(), BaselinePoolContractError> {
513        if restore.cycle_policy != self.cycle_policy {
514            return Err(BaselinePoolContractError::CyclePolicyMismatch {
515                required: self.cycle_policy,
516                achieved: restore.cycle_policy,
517            });
518        }
519        for (domain, requirement) in &self.domains {
520            let Some(achievement) = receipt.0.get(domain) else {
521                return Err(BaselinePoolContractError::MissingResetDomain { domain: *domain });
522            };
523            if achievement != requirement {
524                return Err(BaselinePoolContractError::ResetPolicyMismatch {
525                    requirement: requirement.clone(),
526                    achievement: achievement.clone(),
527                });
528            }
529        }
530        Ok(())
531    }
532}
533
534impl ResetReceipt {
535    /// Construct a duplicate-checked non-snapshot reset achievement set.
536    ///
537    /// Snapshot restoration and cycle policy are verified separately through
538    /// [`CanisterRestoreReceipt`].
539    pub fn try_new<I>(achievements: I) -> Result<Self, BaselinePoolContractError>
540    where
541        I: IntoIterator<Item = ResetDomainPolicy>,
542    {
543        Ok(Self(collect_reset_domains(achievements)?))
544    }
545
546    /// Create an empty receipt for recipes with no non-snapshot reset achievements.
547    #[must_use]
548    pub const fn empty() -> Self {
549        Self(BTreeMap::new())
550    }
551
552    /// Read the achievement for one domain.
553    #[must_use]
554    pub fn get(&self, domain: ResetDomainKind) -> Option<&ResetDomainPolicy> {
555        self.0.get(&domain)
556    }
557
558    /// Iterate over achievements in deterministic domain order.
559    pub fn iter(&self) -> impl Iterator<Item = &ResetDomainPolicy> {
560        self.0.values()
561    }
562}
563
564impl CanisterRestoreReceipt {
565    /// Construct a deterministic, duplicate-checked canister restore receipt.
566    pub fn try_new<I>(
567        canister_ids: I,
568        cycle_policy: CycleResetPolicy,
569    ) -> Result<Self, BaselinePoolContractError>
570    where
571        I: IntoIterator<Item = Principal>,
572    {
573        let mut unique = BTreeSet::new();
574        for canister_id in canister_ids {
575            if !unique.insert(canister_id) {
576                return Err(BaselinePoolContractError::DuplicateCanisterId { canister_id });
577            }
578        }
579        if unique.is_empty() {
580            return Err(BaselinePoolContractError::EmptyCanisterSet);
581        }
582        Ok(Self {
583            canister_ids: unique.into_iter().collect(),
584            cycle_policy,
585        })
586    }
587
588    // Only a completed baseline restore produces this receipt. Capture already
589    // admitted the nonempty set, duplicate validation and deterministic order.
590    pub(super) fn from_restore(
591        snapshots: &ControllerSnapshots,
592        funding: SnapshotRestoreFunding,
593    ) -> Self {
594        Self {
595            canister_ids: snapshots.canister_ids().collect(),
596            cycle_policy: funding.into(),
597        }
598    }
599
600    /// Restored canister ids in deterministic order.
601    #[must_use]
602    pub fn canister_ids(&self) -> &[Principal] {
603        &self.canister_ids
604    }
605
606    /// Cycle policy applied while restoring canisters.
607    #[must_use]
608    pub const fn cycle_policy(&self) -> CycleResetPolicy {
609        self.cycle_policy
610    }
611}
612
613impl ReadinessReceipt {
614    /// Construct a nonempty caller-owned readiness identity.
615    pub fn try_new(identity: impl Into<String>) -> Result<Self, BaselinePoolContractError> {
616        Ok(Self {
617            identity: nonempty_receipt_identity("readiness", identity.into())?,
618        })
619    }
620
621    /// Borrow the readiness identity.
622    #[must_use]
623    pub fn identity(&self) -> &str {
624        &self.identity
625    }
626}
627
628impl ValidationReceipt {
629    /// Construct final validation evidence for one recipe.
630    pub fn try_new(
631        recipe_id: FixtureRecipeId,
632        invariant_identity: impl Into<String>,
633    ) -> Result<Self, BaselinePoolContractError> {
634        Ok(Self {
635            recipe_id,
636            invariant_identity: nonempty_receipt_identity("validation", invariant_identity.into())?,
637        })
638    }
639
640    /// Recipe identity validated by this receipt.
641    #[must_use]
642    pub const fn recipe_id(&self) -> &FixtureRecipeId {
643        &self.recipe_id
644    }
645
646    /// Borrow the caller-owned invariant identity.
647    #[must_use]
648    pub fn invariant_identity(&self) -> &str {
649        &self.invariant_identity
650    }
651}
652
653impl BaselinePreparationStage {
654    /// Default rebuild reason used by [`PocketIcBaselineRecipe::classify_failure`].
655    ///
656    /// Recipes that override classification can use this for their fallback
657    /// after handling a more specific error such as dead PocketIC transport.
658    #[must_use]
659    pub fn default_rebuild_reason(self) -> RebuildReason {
660        match self {
661            Self::RestoreCanisters => RebuildReason::SnapshotRestoreFailure,
662            Self::ResetNonSnapshotState => RebuildReason::ResetFailure,
663            Self::DriveToReadiness => RebuildReason::ReadinessFailure,
664            Self::ValidateRestored | Self::ValidateBuilt => {
665                RebuildReason::InvariantValidationFailure
666            }
667            Self::Build => RebuildReason::RecipeClassified {
668                code: "build".to_owned(),
669            },
670        }
671    }
672}
673
674impl BaselinePoolTimings {
675    /// Time spent waiting for a capacity slot.
676    #[must_use]
677    pub const fn wait(self) -> Duration {
678        self.wait
679    }
680
681    /// Time spent constructing a new baseline.
682    #[must_use]
683    pub const fn build(self) -> Option<Duration> {
684        self.build
685    }
686
687    /// Time spent restoring captured canisters.
688    #[must_use]
689    pub const fn restore(self) -> Option<Duration> {
690        self.restore
691    }
692
693    /// Time spent resetting non-snapshot state.
694    #[must_use]
695    pub const fn reset(self) -> Option<Duration> {
696        self.reset
697    }
698
699    /// Time spent driving the topology to readiness.
700    #[must_use]
701    pub const fn readiness(self) -> Option<Duration> {
702        self.readiness
703    }
704
705    /// Time spent validating final baseline invariants.
706    #[must_use]
707    pub const fn validation(self) -> Option<Duration> {
708        self.validation
709    }
710
711    /// Time spent dropping an invalid baseline before rebuilding.
712    #[must_use]
713    pub const fn stale_teardown(self) -> Option<Duration> {
714        self.stale_teardown
715    }
716
717    /// Complete acquisition duration.
718    #[must_use]
719    pub const fn total(self) -> Duration {
720        self.total
721    }
722}
723
724impl std::fmt::Display for BaselinePoolTimings {
725    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
726        write!(
727            formatter,
728            "total={:?} wait={:?} build={:?} restore={:?} reset={:?} readiness={:?} validation={:?} stale_teardown={:?}",
729            self.total,
730            self.wait,
731            self.build,
732            self.restore,
733            self.reset,
734            self.readiness,
735            self.validation,
736            self.stale_teardown,
737        )
738    }
739}
740
741impl std::fmt::Display for BaselinePoolOutcome {
742    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
743        match &self.disposition {
744            BaselinePoolDisposition::Built => formatter.write_str("built")?,
745            BaselinePoolDisposition::Restored => formatter.write_str("restored")?,
746            BaselinePoolDisposition::Rebuilt(reason) => {
747                write!(
748                    formatter,
749                    "rebuilt slot={} reason={reason:?} {}",
750                    self.slot, self.timings
751                )?;
752                return Ok(());
753            }
754        }
755        write!(formatter, " slot={} {}", self.slot, self.timings)
756    }
757}
758
759impl<E> BaselinePoolError<E> {
760    /// Timings recorded before this acquisition failed.
761    #[must_use]
762    pub const fn timings(&self) -> BaselinePoolTimings {
763        match self {
764            Self::Preparation { timings, .. } | Self::RecoveryFailed { timings, .. } => **timings,
765        }
766    }
767}
768
769impl<R> CachedPocketIcBaselinePool<R>
770where
771    R: PocketIcBaselineRecipe,
772{
773    /// Create a runtime-capacity pool that structurally owns one recipe.
774    ///
775    /// Construction is const and lazy: slots are allocated on first acquisition,
776    /// and the recipe builds a baseline only when a slot needs one.
777    #[must_use]
778    pub const fn new(capacity: NonZeroUsize, recipe: R) -> Self {
779        Self {
780            recipe,
781            slots: BoundedSlotPool::new(capacity),
782        }
783    }
784
785    /// Borrow the caller-owned identity of this pool's only recipe.
786    #[must_use]
787    pub fn recipe_id(&self) -> &FixtureRecipeId {
788        self.recipe.id()
789    }
790
791    /// Maximum number of simultaneously leased PocketIC baselines.
792    #[must_use]
793    pub const fn capacity(&self) -> NonZeroUsize {
794        self.slots.capacity()
795    }
796
797    /// Acquire one fully built or restored and validated baseline lease.
798    ///
799    /// A rebuildable warm-preparation failure discards the stale slot and
800    /// performs at most one build attempt. Recipe and caller panics are never
801    /// converted into cache misses; the lease is invalidated while the panic
802    /// continues unwinding.
803    ///
804    /// # Errors
805    ///
806    /// Returns a stage-specific recipe or contract error. If warm preparation
807    /// and its one recovery build both fail, the error preserves both causes.
808    pub fn acquire(
809        &self,
810    ) -> Result<
811        (CachedPocketIcBaselinePoolGuard<'_, R>, BaselinePoolOutcome),
812        BaselinePoolError<R::Error>,
813    > {
814        let total_started = Instant::now();
815        let mut slot = self.slots.acquire();
816        let mut timings = BaselinePoolTimings {
817            wait: slot.wait(),
818            ..BaselinePoolTimings::default()
819        };
820        let slot_index = slot.slot_index();
821
822        let (rebuild_reason, original) = if slot.is_reusable() {
823            match self.prepare_reused(&slot, &mut timings) {
824                Ok(()) => {
825                    timings.total = total_started.elapsed();
826                    return Ok((
827                        CachedPocketIcBaselinePoolGuard { slot },
828                        BaselinePoolOutcome {
829                            slot: slot_index,
830                            timings,
831                            disposition: BaselinePoolDisposition::Restored,
832                        },
833                    ));
834                }
835                Err(original) => match self.failure_disposition(&original) {
836                    FailureDisposition::Fatal => {
837                        // A failed restore may have partially changed the instance.
838                        // Discard it without treating a fatal failure as a rebuild.
839                        Self::discard_stale_slot(&mut slot, &mut timings);
840                        timings.total = total_started.elapsed();
841                        return Err(BaselinePoolError::Preparation {
842                            error: original,
843                            timings: Box::new(timings),
844                        });
845                    }
846                    FailureDisposition::Rebuild(reason) => (Some(reason), Some(original)),
847                },
848            }
849        } else {
850            let reason = if slot.invalidated_by_unwind() {
851                Some(RebuildReason::UnwindWhileLeased)
852            } else {
853                slot.invalidation_reason().cloned()
854            };
855            (reason, None)
856        };
857
858        if slot.is_populated() {
859            Self::discard_stale_slot(&mut slot, &mut timings);
860        }
861        let build = self.build_slot(&mut slot, &mut timings);
862        timings.total = total_started.elapsed();
863        if let Err(error) = build {
864            return Err(match original {
865                Some(original) => BaselinePoolError::RecoveryFailed {
866                    original: Box::new(original),
867                    rebuild: Box::new(error),
868                    timings: Box::new(timings),
869                },
870                None => BaselinePoolError::Preparation {
871                    error,
872                    timings: Box::new(timings),
873                },
874            });
875        }
876
877        let outcome = BaselinePoolOutcome {
878            slot: slot_index,
879            timings,
880            disposition: rebuild_reason.map_or(
881                BaselinePoolDisposition::Built,
882                BaselinePoolDisposition::Rebuilt,
883            ),
884        };
885        Ok((CachedPocketIcBaselinePoolGuard { slot }, outcome))
886    }
887
888    fn prepare_reused(
889        &self,
890        slot: &BoundedSlotLease<'_, CachedPocketIcBaseline<R::Metadata>, RebuildReason>,
891        timings: &mut BaselinePoolTimings,
892    ) -> Result<(), BaselinePoolPreparationError<R::Error>> {
893        let baseline = slot
894            .get()
895            .expect("reusable baseline slot must be populated");
896
897        let started = Instant::now();
898        let restore = self.recipe.restore_canisters(baseline);
899        add_timing(&mut timings.restore, started.elapsed());
900        let canisters = restore.map_err(|source| BaselinePoolPreparationError::Recipe {
901            stage: BaselinePreparationStage::RestoreCanisters,
902            source,
903        })?;
904        if !baseline
905            .snapshot_canister_ids()
906            .eq(canisters.canister_ids().iter().copied())
907        {
908            return Err(BaselinePoolPreparationError::Contract(
909                BaselinePoolContractError::RestoreCanisterSetMismatch {
910                    expected: baseline.snapshot_canister_ids().collect(),
911                    actual: canisters.canister_ids().to_vec(),
912                },
913            ));
914        }
915
916        let started = Instant::now();
917        let reset_result = self.recipe.reset_non_snapshot_state(baseline);
918        add_timing(&mut timings.reset, started.elapsed());
919        let reset = reset_result.map_err(|source| BaselinePoolPreparationError::Recipe {
920            stage: BaselinePreparationStage::ResetNonSnapshotState,
921            source,
922        })?;
923
924        let started = Instant::now();
925        let readiness_result = self.recipe.drive_to_readiness(baseline);
926        add_timing(&mut timings.readiness, started.elapsed());
927        let readiness =
928            readiness_result.map_err(|source| BaselinePoolPreparationError::Recipe {
929                stage: BaselinePreparationStage::DriveToReadiness,
930                source,
931            })?;
932        self.recipe
933            .reset_requirements()
934            .verify(&canisters, &reset)
935            .map_err(BaselinePoolPreparationError::Contract)?;
936
937        let preparation = PreparedBaseline::Restored {
938            canisters,
939            reset,
940            readiness,
941        };
942        self.validate_baseline(
943            baseline,
944            &preparation,
945            BaselinePreparationStage::ValidateRestored,
946            timings,
947        )?;
948        Ok(())
949    }
950
951    fn build_slot(
952        &self,
953        slot: &mut BoundedSlotLease<'_, CachedPocketIcBaseline<R::Metadata>, RebuildReason>,
954        timings: &mut BaselinePoolTimings,
955    ) -> Result<(), BaselinePoolPreparationError<R::Error>> {
956        let started = Instant::now();
957        let build = self.recipe.build();
958        add_timing(&mut timings.build, started.elapsed());
959        let baseline = build.map_err(|source| BaselinePoolPreparationError::Recipe {
960            stage: BaselinePreparationStage::Build,
961            source,
962        })?;
963
964        if let Err(error) = self.validate_baseline(
965            &baseline,
966            &PreparedBaseline::Built,
967            BaselinePreparationStage::ValidateBuilt,
968            timings,
969        ) {
970            drop_baseline_safely(baseline);
971            return Err(error);
972        }
973        let replaced = slot.replace(baseline);
974        debug_assert!(replaced.is_none());
975        Ok(())
976    }
977
978    fn validate_baseline(
979        &self,
980        baseline: &CachedPocketIcBaseline<R::Metadata>,
981        preparation: &PreparedBaseline,
982        stage: BaselinePreparationStage,
983        timings: &mut BaselinePoolTimings,
984    ) -> Result<(), BaselinePoolPreparationError<R::Error>> {
985        let started = Instant::now();
986        let validation = self.recipe.validate(baseline, preparation);
987        add_timing(&mut timings.validation, started.elapsed());
988        let receipt =
989            validation.map_err(|source| BaselinePoolPreparationError::Recipe { stage, source })?;
990        if receipt.recipe_id() != self.recipe.id() {
991            return Err(BaselinePoolPreparationError::Contract(
992                BaselinePoolContractError::RecipeIdentityMismatch {
993                    expected: self.recipe.id().clone(),
994                    actual: receipt.recipe_id().clone(),
995                },
996            ));
997        }
998        Ok(())
999    }
1000
1001    fn failure_disposition(
1002        &self,
1003        error: &BaselinePoolPreparationError<R::Error>,
1004    ) -> FailureDisposition {
1005        match error {
1006            BaselinePoolPreparationError::Recipe { stage, source } => {
1007                self.recipe.classify_failure(*stage, source)
1008            }
1009            BaselinePoolPreparationError::Contract(
1010                BaselinePoolContractError::RecipeIdentityMismatch { .. },
1011            ) => FailureDisposition::Fatal,
1012            BaselinePoolPreparationError::Contract(error) => {
1013                FailureDisposition::Rebuild(match error {
1014                    BaselinePoolContractError::MissingResetDomain { .. }
1015                    | BaselinePoolContractError::ResetPolicyMismatch { .. }
1016                    | BaselinePoolContractError::CyclePolicyMismatch { .. }
1017                    | BaselinePoolContractError::DuplicateResetDomain { .. }
1018                    | BaselinePoolContractError::RestoreCanisterSetMismatch { .. } => {
1019                        RebuildReason::ResetCoverageMismatch
1020                    }
1021                    _ => RebuildReason::InvariantValidationFailure,
1022                })
1023            }
1024        }
1025    }
1026
1027    fn discard_stale_slot(
1028        slot: &mut BoundedSlotLease<'_, CachedPocketIcBaseline<R::Metadata>, RebuildReason>,
1029        timings: &mut BaselinePoolTimings,
1030    ) {
1031        let started = Instant::now();
1032        if let Some(stale) = slot.take() {
1033            drop_baseline_safely(stale);
1034        }
1035        timings.stale_teardown = Some(started.elapsed());
1036    }
1037}
1038
1039impl<R> CachedPocketIcBaselinePoolGuard<'_, R>
1040where
1041    R: PocketIcBaselineRecipe,
1042{
1043    /// Diagnostic slot index held by this lease.
1044    #[must_use]
1045    pub const fn slot(&self) -> usize {
1046        self.slot.slot_index()
1047    }
1048
1049    /// Mark this slot non-reusable with a structured rebuild reason.
1050    pub fn invalidate(&mut self, reason: RebuildReason) {
1051        self.slot.invalidate(reason);
1052    }
1053}
1054
1055impl<R> Deref for CachedPocketIcBaselinePoolGuard<'_, R>
1056where
1057    R: PocketIcBaselineRecipe,
1058{
1059    type Target = CachedPocketIcBaseline<R::Metadata>;
1060
1061    fn deref(&self) -> &Self::Target {
1062        self.slot
1063            .get()
1064            .expect("leased baseline pool slot must be populated")
1065    }
1066}
1067
1068fn nonempty_receipt_identity(
1069    receipt: &'static str,
1070    identity: String,
1071) -> Result<String, BaselinePoolContractError> {
1072    if identity.trim().is_empty() {
1073        return Err(BaselinePoolContractError::EmptyReceiptIdentity { receipt });
1074    }
1075    Ok(identity)
1076}
1077
1078const fn add_timing(total: &mut Option<Duration>, elapsed: Duration) {
1079    *total = Some(match *total {
1080        None => elapsed,
1081        Some(previous) => previous.saturating_add(elapsed),
1082    });
1083}
1084
1085fn drop_baseline_safely<M>(baseline: CachedPocketIcBaseline<M>) {
1086    let _ = catch_unwind(AssertUnwindSafe(|| drop(baseline)));
1087}
1088
1089impl std::fmt::Display for FixtureRecipeId {
1090    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1091        formatter.write_str(&self.0)
1092    }
1093}
1094
1095impl std::fmt::Display for BaselinePreparationStage {
1096    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1097        formatter.write_str(match self {
1098            Self::Build => "build",
1099            Self::RestoreCanisters => "canister restore",
1100            Self::ResetNonSnapshotState => "non-snapshot reset",
1101            Self::DriveToReadiness => "readiness",
1102            Self::ValidateBuilt => "built-baseline validation",
1103            Self::ValidateRestored => "restored-baseline validation",
1104        })
1105    }
1106}
1107
1108impl std::fmt::Display for BaselinePoolContractError {
1109    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1110        match self {
1111            Self::EmptyRecipeIdentity => formatter.write_str("fixture recipe identity is empty"),
1112            Self::EmptyReceiptIdentity { receipt } => {
1113                write!(formatter, "{receipt} receipt identity is empty")
1114            }
1115            Self::DuplicateResetDomain { domain } => {
1116                write!(
1117                    formatter,
1118                    "reset domain {domain:?} was reported more than once"
1119                )
1120            }
1121            Self::DuplicateCanisterId { canister_id } => {
1122                write!(formatter, "restore receipt repeats canister {canister_id}")
1123            }
1124            Self::EmptyCanisterSet => formatter.write_str("restore receipt contains no canisters"),
1125            Self::CyclePolicyMismatch { required, achieved } => write!(
1126                formatter,
1127                "restore cycle policy {achieved:?} does not satisfy {required:?}",
1128            ),
1129            Self::RestoreCanisterSetMismatch { expected, actual } => write!(
1130                formatter,
1131                "restore receipt identified canisters {actual:?}, expected captured set {expected:?}",
1132            ),
1133            Self::MissingResetDomain { domain } => {
1134                write!(
1135                    formatter,
1136                    "required reset domain {domain:?} was not achieved"
1137                )
1138            }
1139            Self::ResetPolicyMismatch {
1140                requirement,
1141                achievement,
1142            } => write!(
1143                formatter,
1144                "reset achievement {achievement:?} does not satisfy {requirement:?}",
1145            ),
1146            Self::RecipeIdentityMismatch { expected, actual } => write!(
1147                formatter,
1148                "validation receipt used recipe `{actual}` instead of `{expected}`",
1149            ),
1150        }
1151    }
1152}
1153
1154impl std::error::Error for BaselinePoolContractError {}
1155
1156impl<E> std::fmt::Display for BaselinePoolPreparationError<E>
1157where
1158    E: std::fmt::Display,
1159{
1160    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1161        match self {
1162            Self::Recipe { stage, source } => {
1163                write!(formatter, "baseline {stage} failed: {source}")
1164            }
1165            Self::Contract(error) => write!(formatter, "baseline pool contract failed: {error}"),
1166        }
1167    }
1168}
1169
1170impl<E> std::error::Error for BaselinePoolPreparationError<E>
1171where
1172    E: std::error::Error + 'static,
1173{
1174    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1175        match self {
1176            Self::Recipe { source, .. } => Some(source),
1177            Self::Contract(error) => Some(error),
1178        }
1179    }
1180}
1181
1182impl<E> std::fmt::Display for BaselinePoolError<E>
1183where
1184    E: std::fmt::Display,
1185{
1186    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1187        match self {
1188            Self::Preparation { error, .. } => error.fmt(formatter),
1189            Self::RecoveryFailed {
1190                original, rebuild, ..
1191            } => write!(
1192                formatter,
1193                "baseline preparation failed ({original}); rebuilding the slot also failed: {rebuild}",
1194            ),
1195        }
1196    }
1197}
1198
1199impl<E> std::error::Error for BaselinePoolError<E>
1200where
1201    E: std::error::Error + 'static,
1202{
1203    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1204        match self {
1205            Self::Preparation { error, .. } => Some(error),
1206            Self::RecoveryFailed { original, .. } => Some(original.as_ref()),
1207        }
1208    }
1209}
1210
1211#[cfg(test)]
1212mod tests {
1213    use super::{
1214        BaselinePoolContractError, BaselinePoolTimings, CanisterRestoreReceipt, CycleResetPolicy,
1215        ExtraCanisterPolicy, FixtureRecipeId, ResetDomainKind, ResetDomainPolicy, ResetReceipt,
1216        ResetRequirements, StateResetPolicy, TimeResetPolicy, add_timing,
1217    };
1218    use candid::Principal;
1219    use std::time::Duration;
1220
1221    #[test]
1222    fn phase_timing_preserves_absence_zero_and_saturation() {
1223        let mut timings = BaselinePoolTimings::default();
1224        assert_eq!(timings.restore(), None);
1225        add_timing(&mut timings.restore, Duration::ZERO);
1226        assert_eq!(timings.restore(), Some(Duration::ZERO));
1227        add_timing(&mut timings.restore, Duration::from_secs(2));
1228        assert_eq!(timings.restore(), Some(Duration::from_secs(2)));
1229        add_timing(&mut timings.restore, Duration::MAX);
1230        assert_eq!(timings.restore(), Some(Duration::MAX));
1231        assert_eq!(timings.build(), None);
1232    }
1233
1234    #[test]
1235    fn recipe_identity_must_be_nonempty() {
1236        assert!(matches!(
1237            FixtureRecipeId::try_new("  "),
1238            Err(BaselinePoolContractError::EmptyRecipeIdentity)
1239        ));
1240    }
1241
1242    #[test]
1243    fn caller_restore_receipts_validate_and_order_canister_ids() {
1244        let first = Principal::from_slice(&[1]);
1245        let second = Principal::from_slice(&[2]);
1246        let policy = CycleResetPolicy::TopUpTo(123);
1247        let receipt = CanisterRestoreReceipt::try_new([second, first], policy).unwrap();
1248        assert_eq!(receipt.canister_ids(), [first, second]);
1249        assert_eq!(receipt.cycle_policy(), policy);
1250        assert_eq!(
1251            CanisterRestoreReceipt::try_new([], policy),
1252            Err(BaselinePoolContractError::EmptyCanisterSet),
1253        );
1254        assert_eq!(
1255            CanisterRestoreReceipt::try_new([second, first, second], policy),
1256            Err(BaselinePoolContractError::DuplicateCanisterId {
1257                canister_id: second,
1258            }),
1259        );
1260    }
1261
1262    #[test]
1263    fn reset_declarations_and_receipts_reject_duplicate_domains() {
1264        let policies = [
1265            ResetDomainPolicy::PocketIcTime(TimeResetPolicy::PreserveCurrent),
1266            ResetDomainPolicy::PocketIcTime(TimeResetPolicy::RebuildOnMutation),
1267        ];
1268        let error = BaselinePoolContractError::DuplicateResetDomain {
1269            domain: ResetDomainKind::PocketIcTime,
1270        };
1271        assert_eq!(
1272            ResetRequirements::try_new(CycleResetPolicy::PreserveCurrent, policies.clone()),
1273            Err(error.clone()),
1274        );
1275        assert_eq!(ResetReceipt::try_new(policies), Err(error));
1276    }
1277
1278    #[test]
1279    fn required_policy_must_match_achieved_policy() {
1280        let restore = CanisterRestoreReceipt::try_new(
1281            [Principal::anonymous()],
1282            CycleResetPolicy::PreserveCurrent,
1283        )
1284        .unwrap();
1285        for (requirement, achievement) in [
1286            (
1287                ResetDomainPolicy::PocketIcTime(TimeResetPolicy::PreserveCurrent),
1288                ResetDomainPolicy::PocketIcTime(TimeResetPolicy::RebuildOnMutation),
1289            ),
1290            (
1291                ResetDomainPolicy::ExtraCanisters(ExtraCanisterPolicy::RequireBaselineSet),
1292                ResetDomainPolicy::ExtraCanisters(ExtraCanisterPolicy::RemoveTracked),
1293            ),
1294            (
1295                ResetDomainPolicy::PendingMessages(StateResetPolicy::ValidateUnchanged),
1296                ResetDomainPolicy::PendingMessages(StateResetPolicy::IrrelevantByRecipeContract),
1297            ),
1298            (
1299                ResetDomainPolicy::SubnetState(StateResetPolicy::ResetByRecipe),
1300                ResetDomainPolicy::SubnetState(StateResetPolicy::RebuildOnChange),
1301            ),
1302            (
1303                ResetDomainPolicy::ExternalResources(StateResetPolicy::ValidateUnchanged),
1304                ResetDomainPolicy::ExternalResources(StateResetPolicy::ResetByRecipe),
1305            ),
1306        ] {
1307            let requirements = ResetRequirements::try_new(
1308                CycleResetPolicy::PreserveCurrent,
1309                [requirement.clone()],
1310            )
1311            .unwrap();
1312            let receipt = ResetReceipt::try_new([requirement.clone()]).unwrap();
1313            assert_eq!(requirements.verify(&restore, &receipt), Ok(()));
1314            assert_eq!(
1315                requirements.verify(&restore, &ResetReceipt::empty()),
1316                Err(BaselinePoolContractError::MissingResetDomain {
1317                    domain: requirement.domain(),
1318                }),
1319            );
1320            let receipt = ResetReceipt::try_new([achievement.clone()]).unwrap();
1321            assert_eq!(
1322                requirements.verify(&restore, &receipt),
1323                Err(BaselinePoolContractError::ResetPolicyMismatch {
1324                    requirement,
1325                    achievement,
1326                }),
1327            );
1328        }
1329    }
1330
1331    #[test]
1332    fn restore_cycle_policy_must_match_required_policy() {
1333        let requirements =
1334            ResetRequirements::try_new(CycleResetPolicy::RestoreExactBaseline, []).unwrap();
1335        let restore = CanisterRestoreReceipt::try_new(
1336            [Principal::anonymous()],
1337            CycleResetPolicy::PreserveCurrent,
1338        )
1339        .unwrap();
1340        assert!(matches!(
1341            requirements.verify(&restore, &ResetReceipt::empty()),
1342            Err(BaselinePoolContractError::CyclePolicyMismatch {
1343                required: CycleResetPolicy::RestoreExactBaseline,
1344                achieved: CycleResetPolicy::PreserveCurrent,
1345            })
1346        ));
1347    }
1348}