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