app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit e10c07f838c1b586fc0b0c22d0f489a7fb214be6
parent 6fbe43c45686dba084d2c0bbb759b6b05586855f
Author: triesap <tyson@radroots.org>
Date:   Tue, 21 Apr 2026 17:55:40 +0000

pack_day: add export contracts

Diffstat:
Mcrates/shared/models/src/lib.rs | 353++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/shared/state/src/lib.rs | 298+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
2 files changed, 624 insertions(+), 27 deletions(-)

diff --git a/crates/shared/models/src/lib.rs b/crates/shared/models/src/lib.rs @@ -1579,6 +1579,187 @@ impl PackDayProjection { } } +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PackDayExportArtifactKind { + PackSheet, + PickupRoster, + CustomerLabels, +} + +impl PackDayExportArtifactKind { + pub const fn all_v1() -> [Self; 3] { + [Self::PackSheet, Self::PickupRoster, Self::CustomerLabels] + } + + pub const fn storage_key(self) -> &'static str { + match self { + Self::PackSheet => "pack_sheet", + Self::PickupRoster => "pickup_roster", + Self::CustomerLabels => "customer_labels", + } + } + + pub const fn file_name(self) -> &'static str { + match self { + Self::PackSheet => "pack_sheet.txt", + Self::PickupRoster => "pickup_roster.txt", + Self::CustomerLabels => "customer_labels.txt", + } + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PackDayExportStatus { + #[default] + Idle, + Running, + Succeeded, + Failed, +} + +impl PackDayExportStatus { + pub const fn storage_key(self) -> &'static str { + match self { + Self::Idle => "idle", + Self::Running => "running", + Self::Succeeded => "succeeded", + Self::Failed => "failed", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PackDayOutputOrderState { + NeedsAction, + Scheduled, + Packed, +} + +impl PackDayOutputOrderState { + pub const fn all_v1() -> [Self; 3] { + [Self::NeedsAction, Self::Scheduled, Self::Packed] + } + + pub const fn storage_key(self) -> &'static str { + match self { + Self::NeedsAction => "needs_action", + Self::Scheduled => "scheduled", + Self::Packed => "packed", + } + } + + pub const fn from_order_status(status: OrderStatus) -> Option<Self> { + match status { + OrderStatus::NeedsAction => Some(Self::NeedsAction), + OrderStatus::Scheduled => Some(Self::Scheduled), + OrderStatus::Packed => Some(Self::Packed), + OrderStatus::Completed | OrderStatus::Refunded => None, + } + } +} + +impl From<PackDayOutputOrderState> for OrderStatus { + fn from(value: PackDayOutputOrderState) -> Self { + match value { + PackDayOutputOrderState::NeedsAction => Self::NeedsAction, + PackDayOutputOrderState::Scheduled => Self::Scheduled, + PackDayOutputOrderState::Packed => Self::Packed, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct PackDayOutputQuantity { + pub value: u32, + pub unit_label: String, +} + +impl PackDayOutputQuantity { + pub fn new(value: u32, unit_label: impl Into<String>) -> Self { + Self { + value, + unit_label: unit_label.into(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct PackDayOutputWindow { + pub fulfillment_window_id: FulfillmentWindowId, + pub farm_id: FarmId, + pub farm_display_name: String, + pub pickup_location_label: Option<String>, + pub starts_at: String, + pub ends_at: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct PackDayOutputProductTotal { + pub title: String, + pub quantity: PackDayOutputQuantity, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct PackDayOutputPackListEntry { + pub order_id: OrderId, + pub order_number: String, + pub customer_display_name: String, + pub order_state: PackDayOutputOrderState, + pub title: String, + pub quantity: PackDayOutputQuantity, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct PackDayOutputCustomerOrder { + pub order_id: OrderId, + pub order_number: String, + pub customer_display_name: String, + pub order_state: PackDayOutputOrderState, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct PackDayOutputSource { + pub fulfillment_window: PackDayOutputWindow, + pub totals_by_product: Vec<PackDayOutputProductTotal>, + pub pack_list: Vec<PackDayOutputPackListEntry>, + pub pickup_roster: Vec<PackDayOutputCustomerOrder>, +} + +impl PackDayOutputSource { + pub fn is_empty(&self) -> bool { + self.totals_by_product.is_empty() + && self.pack_list.is_empty() + && self.pickup_roster.is_empty() + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct PackDayExportArtifact { + pub kind: PackDayExportArtifactKind, + pub relative_path: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct PackDayExportBundle { + pub fulfillment_window_id: FulfillmentWindowId, + pub generated_at_utc: String, + pub bundle_directory: String, + pub artifacts: Vec<PackDayExportArtifact>, +} + +impl PackDayExportBundle { + pub fn artifact_count(&self) -> usize { + self.artifacts.len() + } + + pub fn includes_artifact(&self, kind: PackDayExportArtifactKind) -> bool { + self.artifacts.iter().any(|artifact| artifact.kind == kind) + } +} + #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct FarmSummary { pub farm_id: FarmId, @@ -2151,19 +2332,22 @@ mod tests { LoggedOutStartupPhase, LoggedOutStartupProjection, OrderDetailItemRow, OrderDetailProjection, OrderId, OrderListRow, OrderPrimaryAction, OrderRecoveryProjection, OrderStatus, OrdersFilter, OrdersListProjection, OrdersListRow, OrdersListSummary, - OrdersScreenQueryState, PackDayPackListRow, PackDayProductTotalRow, PackDayProjection, - PackDayRosterRow, PackDayScreenQueryState, ParseStartupSignerSourceError, - PersonalEntryProjection, PersonalEntryState, PersonalSection, PickupLocationId, - ProductAttentionState, ProductAvailabilityState, ProductAvailabilitySummary, - ProductEditorDraft, ProductListRow, ProductPricePresentation, ProductPublishBlocker, - ProductStatus, ProductStockState, ProductStockSummary, ProductsFilter, - ProductsListProjection, ProductsListRow, ProductsListSummary, ProductsSort, RecoveryKind, - RecoveryQueueProjection, RecoveryRecordId, RecoveryState, ReminderDeadlineProjection, - ReminderDeliveryState, ReminderFeedProjection, ReminderId, ReminderKind, - ReminderLogEntryProjection, ReminderLogProjection, ReminderSurface, ReminderUrgency, - RepeatDemandEligibility, RepeatDemandHandoffProjection, SelectedAccountProjection, - SelectedSurfaceProjection, SettingsPreference, SettingsSection, ShellSection, - StartupSignerEntryProjection, StartupSignerSource, StartupSignerSourceKind, + OrdersScreenQueryState, PackDayExportArtifact, PackDayExportArtifactKind, + PackDayExportBundle, PackDayExportStatus, PackDayOutputCustomerOrder, + PackDayOutputOrderState, PackDayOutputPackListEntry, PackDayOutputProductTotal, + PackDayOutputQuantity, PackDayOutputSource, PackDayOutputWindow, PackDayPackListRow, + PackDayProductTotalRow, PackDayProjection, PackDayRosterRow, PackDayScreenQueryState, + ParseStartupSignerSourceError, PersonalEntryProjection, PersonalEntryState, + PersonalSection, PickupLocationId, ProductAttentionState, ProductAvailabilityState, + ProductAvailabilitySummary, ProductEditorDraft, ProductListRow, ProductPricePresentation, + ProductPublishBlocker, ProductStatus, ProductStockState, ProductStockSummary, + ProductsFilter, ProductsListProjection, ProductsListRow, ProductsListSummary, ProductsSort, + RecoveryKind, RecoveryQueueProjection, RecoveryRecordId, RecoveryState, + ReminderDeadlineProjection, ReminderDeliveryState, ReminderFeedProjection, ReminderId, + ReminderKind, ReminderLogEntryProjection, ReminderLogProjection, ReminderSurface, + ReminderUrgency, RepeatDemandEligibility, RepeatDemandHandoffProjection, + SelectedAccountProjection, SelectedSurfaceProjection, SettingsPreference, SettingsSection, + ShellSection, StartupSignerEntryProjection, StartupSignerSource, StartupSignerSourceKind, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, TodaySummary, }; use std::{collections::BTreeSet, str::FromStr}; @@ -2758,6 +2942,149 @@ mod tests { } #[test] + fn pack_day_export_artifact_contract_is_frozen_for_v1() { + assert_eq!( + PackDayExportArtifactKind::all_v1(), + [ + PackDayExportArtifactKind::PackSheet, + PackDayExportArtifactKind::PickupRoster, + PackDayExportArtifactKind::CustomerLabels, + ] + ); + assert_eq!( + PackDayExportArtifactKind::PackSheet.storage_key(), + "pack_sheet" + ); + assert_eq!( + PackDayExportArtifactKind::PackSheet.file_name(), + "pack_sheet.txt" + ); + assert_eq!( + PackDayExportArtifactKind::PickupRoster.file_name(), + "pickup_roster.txt" + ); + assert_eq!( + PackDayExportArtifactKind::CustomerLabels.file_name(), + "customer_labels.txt" + ); + assert_eq!(PackDayExportStatus::default(), PackDayExportStatus::Idle); + assert_eq!(PackDayExportStatus::Running.storage_key(), "running"); + assert_eq!(PackDayExportStatus::Succeeded.storage_key(), "succeeded"); + assert_eq!(PackDayExportStatus::Failed.storage_key(), "failed"); + } + + #[test] + fn pack_day_output_order_state_freezes_the_v1_status_subset() { + assert_eq!( + PackDayOutputOrderState::all_v1(), + [ + PackDayOutputOrderState::NeedsAction, + PackDayOutputOrderState::Scheduled, + PackDayOutputOrderState::Packed, + ] + ); + assert_eq!( + PackDayOutputOrderState::from_order_status(OrderStatus::NeedsAction), + Some(PackDayOutputOrderState::NeedsAction) + ); + assert_eq!( + PackDayOutputOrderState::from_order_status(OrderStatus::Scheduled), + Some(PackDayOutputOrderState::Scheduled) + ); + assert_eq!( + PackDayOutputOrderState::from_order_status(OrderStatus::Packed), + Some(PackDayOutputOrderState::Packed) + ); + assert_eq!( + PackDayOutputOrderState::from_order_status(OrderStatus::Completed), + None + ); + assert_eq!( + PackDayOutputOrderState::from_order_status(OrderStatus::Refunded), + None + ); + assert_eq!( + OrderStatus::from(PackDayOutputOrderState::Packed), + OrderStatus::Packed + ); + } + + #[test] + fn pack_day_output_source_keeps_export_truth_out_of_ui_display_strings() { + let farm_id = FarmId::new(); + let fulfillment_window_id = FulfillmentWindowId::new(); + let order_id = OrderId::new(); + let screen_row = PackDayPackListRow { + title: "Salad mix".to_owned(), + quantity_display: "Casey: 2 bags".to_owned(), + }; + let source = PackDayOutputSource { + fulfillment_window: PackDayOutputWindow { + fulfillment_window_id, + farm_id, + farm_display_name: "Willow farm".to_owned(), + pickup_location_label: Some("North barn".to_owned()), + starts_at: "2026-04-23T16:00:00Z".to_owned(), + ends_at: "2026-04-23T19:00:00Z".to_owned(), + }, + totals_by_product: vec![PackDayOutputProductTotal { + title: "Salad mix".to_owned(), + quantity: PackDayOutputQuantity::new(2, "bags"), + }], + pack_list: vec![PackDayOutputPackListEntry { + order_id, + order_number: "R-1001".to_owned(), + customer_display_name: "Casey".to_owned(), + order_state: PackDayOutputOrderState::Scheduled, + title: "Salad mix".to_owned(), + quantity: PackDayOutputQuantity::new(2, "bags"), + }], + pickup_roster: vec![PackDayOutputCustomerOrder { + order_id, + order_number: "R-1001".to_owned(), + customer_display_name: "Casey".to_owned(), + order_state: PackDayOutputOrderState::Scheduled, + }], + }; + + assert_eq!(screen_row.quantity_display, "Casey: 2 bags"); + assert!(!source.is_empty()); + assert_eq!(source.pack_list[0].customer_display_name, "Casey"); + assert_eq!(source.pack_list[0].quantity.value, 2); + assert_eq!(source.pack_list[0].quantity.unit_label, "bags"); + assert_eq!( + source.pickup_roster[0].order_state.storage_key(), + "scheduled" + ); + } + + #[test] + fn pack_day_export_bundle_tracks_output_directory_and_artifacts() { + let fulfillment_window_id = FulfillmentWindowId::new(); + let bundle = PackDayExportBundle { + fulfillment_window_id, + generated_at_utc: "2026-04-23T15:00:00Z".to_owned(), + bundle_directory: "exports/pack_day/window-1/20260423T150000Z".to_owned(), + artifacts: vec![ + PackDayExportArtifact { + kind: PackDayExportArtifactKind::PackSheet, + relative_path: "pack_sheet.txt".to_owned(), + }, + PackDayExportArtifact { + kind: PackDayExportArtifactKind::PickupRoster, + relative_path: "pickup_roster.txt".to_owned(), + }, + ], + }; + + assert_eq!(bundle.fulfillment_window_id, fulfillment_window_id); + assert_eq!(bundle.artifact_count(), 2); + assert!(bundle.includes_artifact(PackDayExportArtifactKind::PackSheet)); + assert!(bundle.includes_artifact(PackDayExportArtifactKind::PickupRoster)); + assert!(!bundle.includes_artifact(PackDayExportArtifactKind::CustomerLabels)); + } + + #[test] fn orders_and_pack_day_projections_hold_truthful_execution_data() { let fulfillment_window_id = super::FulfillmentWindowId::new(); let farm_id = FarmId::new(); diff --git a/crates/shared/state/src/lib.rs b/crates/shared/state/src/lib.rs @@ -14,9 +14,10 @@ use radroots_app_models::{ FarmReadinessBlocker, FarmRulesProjection, FarmSetupBlocker, FarmSetupProjection, FarmSetupReadiness, FarmTimingConflict, FulfillmentWindowId, LoggedOutStartupPhase, LoggedOutStartupProjection, OrderDetailProjection, OrderId, OrdersFilter, OrdersListProjection, - OrdersScreenQueryState, PackDayProjection, PackDayScreenQueryState, PersonalEntryProjection, - ProductEditorDraft, ProductId, ProductPublishBlocker, ProductsFilter, ProductsListProjection, - ProductsSort, RecoveryQueueProjection, ReminderFeedProjection, ReminderLogProjection, + OrdersScreenQueryState, PackDayExportArtifactKind, PackDayExportBundle, PackDayExportStatus, + PackDayProjection, PackDayScreenQueryState, PersonalEntryProjection, ProductEditorDraft, + ProductId, ProductPublishBlocker, ProductsFilter, ProductsListProjection, ProductsSort, + RecoveryQueueProjection, ReminderFeedProjection, ReminderLogProjection, SelectedSurfaceProjection, SettingsAccountProjection, SettingsPreference, SettingsSection, ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, }; @@ -307,12 +308,90 @@ impl OrdersScreenProjection { pub struct PackDayScreenProjection { pub query: PackDayScreenQueryState, pub projection: PackDayProjection, + pub export: PackDayExportProjection, } impl PackDayScreenProjection { fn select_fulfillment_window(&mut self, fulfillment_window_id: Option<FulfillmentWindowId>) { + if self.query.fulfillment_window_id != fulfillment_window_id { + self.export = PackDayExportProjection::default(); + } self.query.fulfillment_window_id = fulfillment_window_id; } + + fn replace_projection(&mut self, projection: PackDayProjection) { + let previous_window_id = self + .projection + .fulfillment_window + .as_ref() + .map(|window| window.fulfillment_window_id); + let next_window_id = projection + .fulfillment_window + .as_ref() + .map(|window| window.fulfillment_window_id); + + if previous_window_id != next_window_id { + self.export = PackDayExportProjection::default(); + } + + self.projection = projection; + } + + fn replace_export(&mut self, export: PackDayExportProjection) { + self.export = export; + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PackDayExportRequest { + pub fulfillment_window_id: FulfillmentWindowId, + pub artifact_kinds: Vec<PackDayExportArtifactKind>, +} + +impl PackDayExportRequest { + pub fn for_fulfillment_window(fulfillment_window_id: FulfillmentWindowId) -> Self { + Self { + fulfillment_window_id, + artifact_kinds: Vec::from(PackDayExportArtifactKind::all_v1()), + } + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct PackDayExportProjection { + pub status: PackDayExportStatus, + pub request: Option<PackDayExportRequest>, + pub bundle: Option<PackDayExportBundle>, + pub error_message: Option<String>, +} + +impl PackDayExportProjection { + pub fn running(request: PackDayExportRequest) -> Self { + Self { + status: PackDayExportStatus::Running, + request: Some(request), + bundle: None, + error_message: None, + } + } + + pub fn succeeded(request: PackDayExportRequest, bundle: PackDayExportBundle) -> Self { + Self { + status: PackDayExportStatus::Succeeded, + request: Some(request), + bundle: Some(bundle), + error_message: None, + } + } + + pub fn failed(request: PackDayExportRequest, message: impl Into<String>) -> Self { + Self { + status: PackDayExportStatus::Failed, + request: Some(request), + bundle: None, + error_message: Some(message.into()), + } + } } #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] @@ -756,6 +835,16 @@ pub enum AppStateCommand { ReplaceOrderDetail(Option<OrderDetailProjection>), SetPackDayFulfillmentWindow(Option<FulfillmentWindowId>), ReplacePackDayProjection(PackDayProjection), + BeginPackDayExport(PackDayExportRequest), + SucceedPackDayExport { + request: PackDayExportRequest, + bundle: PackDayExportBundle, + }, + FailPackDayExport { + request: PackDayExportRequest, + message: String, + }, + ResetPackDayExport, OpenNewProductEditor, OpenExistingProductEditor { product_id: ProductId, @@ -878,6 +967,28 @@ impl AppStateCommand { Self::ReplacePackDayProjection(projection) } + pub fn begin_pack_day_export(request: PackDayExportRequest) -> Self { + Self::BeginPackDayExport(request) + } + + pub fn succeed_pack_day_export( + request: PackDayExportRequest, + bundle: PackDayExportBundle, + ) -> Self { + Self::SucceedPackDayExport { request, bundle } + } + + pub fn fail_pack_day_export(request: PackDayExportRequest, message: impl Into<String>) -> Self { + Self::FailPackDayExport { + request, + message: message.into(), + } + } + + pub const fn reset_pack_day_export() -> Self { + Self::ResetPackDayExport + } + pub const fn open_new_product_editor() -> Self { Self::OpenNewProductEditor } @@ -1365,7 +1476,27 @@ fn apply_command(projection: &mut AppProjection, command: AppStateCommand) -> Ap .select_fulfillment_window(fulfillment_window_id); } AppStateCommand::ReplacePackDayProjection(pack_day_projection) => { - projection.pack_day.projection = pack_day_projection; + projection.pack_day.replace_projection(pack_day_projection); + } + AppStateCommand::BeginPackDayExport(request) => { + projection + .pack_day + .replace_export(PackDayExportProjection::running(request)); + } + AppStateCommand::SucceedPackDayExport { request, bundle } => { + projection + .pack_day + .replace_export(PackDayExportProjection::succeeded(request, bundle)); + } + AppStateCommand::FailPackDayExport { request, message } => { + projection + .pack_day + .replace_export(PackDayExportProjection::failed(request, message)); + } + AppStateCommand::ResetPackDayExport => { + projection + .pack_day + .replace_export(PackDayExportProjection::default()); } AppStateCommand::OpenNewProductEditor => { projection @@ -1699,9 +1830,10 @@ mod tests { use super::{ AppProjection, AppShellProjection, AppStateCommand, AppStateRepository, AppStateRepositoryError, AppStateStore, AppStateStoreError, FarmSetupFlowStage, HomeRoute, - InMemoryAppStateRepository, OrdersScreenProjection, PackDayScreenProjection, - PersistedAppState, ProductEditorState, ProductsScreenProjection, ProductsScreenQueryState, - SettingsPreference, derive_sync_projection, derive_sync_run_status, + InMemoryAppStateRepository, OrdersScreenProjection, PackDayExportProjection, + PackDayExportRequest, PackDayScreenProjection, PersistedAppState, ProductEditorState, + ProductsScreenProjection, ProductsScreenQueryState, SettingsPreference, + derive_sync_projection, derive_sync_run_status, }; use radroots_app_models::{ AccountCustody, AccountSummary, ActiveSurface, AppIdentityProjection, AppStartupGate, @@ -1709,13 +1841,14 @@ mod tests { FarmerActivationProjection, FarmerSection, FulfillmentWindowId, LoggedOutStartupPhase, LoggedOutStartupProjection, OrderDetailItemRow, OrderDetailProjection, OrderId, OrderPrimaryAction, OrderStatus, OrdersFilter, OrdersListProjection, OrdersListRow, - OrdersListSummary, OrdersScreenQueryState, PackDayPackListRow, PackDayProductTotalRow, - PackDayProjection, PackDayRosterRow, PackDayScreenQueryState, PersonalEntryState, - PersonalSection, ProductEditorDraft, ProductId, ProductPublishBlocker, ProductsFilter, - ProductsListProjection, ProductsSort, ReminderDeliveryState, ReminderFeedProjection, - ReminderKind, ReminderLogEntryProjection, ReminderLogProjection, SelectedAccountProjection, - SelectedSurfaceProjection, SettingsSection, ShellSection, TodayAgendaProjection, - TodaySetupTask, TodaySetupTaskKind, + OrdersListSummary, OrdersScreenQueryState, PackDayExportArtifact, + PackDayExportArtifactKind, PackDayExportBundle, PackDayExportStatus, PackDayPackListRow, + PackDayProductTotalRow, PackDayProjection, PackDayRosterRow, PackDayScreenQueryState, + PersonalEntryState, PersonalSection, ProductEditorDraft, ProductId, ProductPublishBlocker, + ProductsFilter, ProductsListProjection, ProductsSort, ReminderDeliveryState, + ReminderFeedProjection, ReminderKind, ReminderLogEntryProjection, ReminderLogProjection, + SelectedAccountProjection, SelectedSurfaceProjection, SettingsSection, ShellSection, + TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, }; use radroots_app_sync::{ AppSyncProjection, AppSyncRunStatus, SyncCheckpointState, SyncCheckpointStatus, @@ -1754,6 +1887,36 @@ mod tests { ) } + fn sample_pack_day_export_request( + fulfillment_window_id: FulfillmentWindowId, + ) -> PackDayExportRequest { + PackDayExportRequest::for_fulfillment_window(fulfillment_window_id) + } + + fn sample_pack_day_export_bundle( + fulfillment_window_id: FulfillmentWindowId, + ) -> PackDayExportBundle { + PackDayExportBundle { + fulfillment_window_id, + generated_at_utc: "2026-04-23T15:00:00Z".to_owned(), + bundle_directory: "exports/pack_day/window-1/20260423T150000Z".to_owned(), + artifacts: vec![ + PackDayExportArtifact { + kind: PackDayExportArtifactKind::PackSheet, + relative_path: "pack_sheet.txt".to_owned(), + }, + PackDayExportArtifact { + kind: PackDayExportArtifactKind::PickupRoster, + relative_path: "pickup_roster.txt".to_owned(), + }, + PackDayExportArtifact { + kind: PackDayExportArtifactKind::CustomerLabels, + relative_path: "customer_labels.txt".to_owned(), + }, + ], + } + } + #[test] fn default_projection_starts_on_personal_setup_gate() { let projection = AppProjection::default(); @@ -2095,6 +2258,113 @@ mod tests { fulfillment_window_id: Some(fulfillment_window_id), } ); + assert_eq!( + store.projection().pack_day.export, + PackDayExportProjection::default() + ); + } + + #[test] + fn pack_day_export_projection_defaults_to_idle() { + assert_eq!( + PackDayScreenProjection::default().export, + PackDayExportProjection { + status: PackDayExportStatus::Idle, + request: None, + bundle: None, + error_message: None, + } + ); + } + + #[test] + fn pack_day_export_state_is_restart_ephemeral_and_skips_persistence() { + let mut store = + AppStateStore::load(FailingRepository).expect("failing repository should still load"); + let fulfillment_window_id = FulfillmentWindowId::new(); + let request = sample_pack_day_export_request(fulfillment_window_id); + let bundle = sample_pack_day_export_bundle(fulfillment_window_id); + + assert_eq!( + store.apply(AppStateCommand::begin_pack_day_export(request.clone())), + Ok(true) + ); + assert_eq!( + store.pack_day_projection().export, + PackDayExportProjection::running(request.clone()) + ); + assert_eq!( + store.persisted_state().seller.pack_day_query, + PackDayScreenQueryState::default() + ); + + assert_eq!( + store.apply(AppStateCommand::succeed_pack_day_export( + request.clone(), + bundle.clone(), + )), + Ok(true) + ); + assert_eq!( + store.pack_day_projection().export, + PackDayExportProjection::succeeded(request.clone(), bundle) + ); + + assert_eq!( + store.apply(AppStateCommand::fail_pack_day_export( + request.clone(), + "disk unavailable", + )), + Ok(true) + ); + assert_eq!( + store.pack_day_projection().export, + PackDayExportProjection::failed(request, "disk unavailable") + ); + + assert_eq!( + store.apply(AppStateCommand::reset_pack_day_export()), + Ok(true) + ); + assert_eq!( + store.pack_day_projection().export, + PackDayExportProjection::default() + ); + } + + #[test] + fn changing_pack_day_window_clears_stale_export_state() { + let mut store = AppStateStore::load(InMemoryAppStateRepository::default()) + .expect("in-memory repository should load"); + let fulfillment_window_id = FulfillmentWindowId::new(); + let next_window_id = FulfillmentWindowId::new(); + let request = sample_pack_day_export_request(fulfillment_window_id); + + assert_eq!( + store.apply(AppStateCommand::begin_pack_day_export(request)), + Ok(true) + ); + assert_eq!( + store.pack_day_projection().export.status, + PackDayExportStatus::Running + ); + + assert_eq!( + store.apply(AppStateCommand::set_pack_day_fulfillment_window(Some( + next_window_id, + ))), + Ok(true) + ); + assert_eq!( + store.pack_day_projection().query, + PackDayScreenQueryState { + fulfillment_window_id: Some(next_window_id), + } + ); + assert_eq!( + store.pack_day_projection().export, + PackDayExportProjection::default() + ); } #[test]