Confirming "/" in the folder picker set an empty root, which the launch model read as no root at all: "Open library" stayed disabled after the question had plainly been answered, and a folder library — whose folder is the whole library — could never be opened without first descending into a subfolder of it. The empty string was carrying two meanings. Record the choice as its own fact on the account (`root_chosen`, defaulted so existing configuration loads unchanged), treat a folder endpoint as chosen by definition, and let the launch screen say so: a folder is shown as a LIBRARY rather than an ACCOUNT, the second question becomes an optional "scan only a subfolder", and the library header names the folder instead of calling it "· whole account".
781 lines
27 KiB
Rust
781 lines
27 KiB
Rust
//! Launch screen state: connect an account, choose a library, sign out.
|
|
//!
|
|
//! The state machine lives here, separate from the Slint bindings, so it can
|
|
//! be tested without a display server. `dr-ui` owns presentation; what a
|
|
//! login *is* belongs to the connector.
|
|
//!
|
|
//! # Two shapes of sign-in, not two screens
|
|
//!
|
|
//! A Nextcloud account is established through a browser handshake the app
|
|
//! polls for; a folder library is established by naming a directory. The model
|
|
//! below does not know which is which — it asks the
|
|
//! [`BackendProvider`](dr_sync::BackendProvider) whether the account it is
|
|
//! being asked to make needs a credential
|
|
//! ([`SignIn`](dr_sync::SignIn)), and the screen draws the waiting state only
|
|
//! where there is something to wait for. Everything after that point — picking
|
|
//! a library root, ticking formats, opening the grid — is identical, because
|
|
//! it goes through `dr-sync` rather than through a connector.
|
|
|
|
use dr_sync::{Account, AccountStore};
|
|
use dr_types::{Format, FormatFilter};
|
|
|
|
/// What the launch screen is currently doing.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum LaunchState {
|
|
/// No account configured; waiting for a server address.
|
|
SignedOut,
|
|
/// A login flow is open and the user must approve it in a browser.
|
|
///
|
|
/// Carries the URL so the screen can show and copy it — the app never
|
|
/// handles the password itself (FR-NC-1).
|
|
AwaitingApproval { login_url: String },
|
|
/// An account is configured.
|
|
SignedIn { session: Account },
|
|
/// Working; the reason is shown so a pause is never unexplained.
|
|
///
|
|
/// Carries the session where there is one, so a failure mid-work returns
|
|
/// to the signed-in screen rather than signing the user out.
|
|
Busy {
|
|
message: String,
|
|
session: Option<Box<Account>>,
|
|
},
|
|
}
|
|
|
|
/// What the app opens on.
|
|
///
|
|
/// Three outcomes, and the middle one is easy to lose: a configured library
|
|
/// means the launch screen is skipped, and skipping it must not also skip
|
|
/// opening the library — otherwise the app lands on an empty view with no
|
|
/// route back to the grid, because the "Open library" button is on the screen
|
|
/// that was never shown.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Startup {
|
|
/// Files were named on the command line; show those.
|
|
ShowLocalFiles,
|
|
/// An account and a folder are configured; scan and show the grid.
|
|
OpenLibrary,
|
|
/// Nothing configured; ask the user to sign in.
|
|
ShowLaunchScreen,
|
|
}
|
|
|
|
/// TRACES: FR-NC-1 | FR-NC-4 | M-1 | M-3 | M-4
|
|
/// Everything the launch screen renders from.
|
|
#[derive(Debug, Clone)]
|
|
pub struct LaunchModel {
|
|
pub state: LaunchState,
|
|
/// Last-used server, prefilled so a returning user need not retype it.
|
|
pub server_url: String,
|
|
/// Last-used folder, prefilled for the same reason.
|
|
///
|
|
/// Separate from `server_url` rather than one "endpoint" field, because
|
|
/// the screen shows both at once: someone deciding between the two should
|
|
/// not have to clear one to try the other.
|
|
pub folder_path: String,
|
|
pub error: Option<String>,
|
|
pub status: Option<String>,
|
|
/// False where no secrets daemon exists (FR-NC-2). The screen must say so
|
|
/// up front rather than letting the user discover it next launch.
|
|
pub can_remember: bool,
|
|
/// Which formats to scan for, in `Format::ALL` order.
|
|
pub formats: Vec<(Format, bool)>,
|
|
/// Folder picker state, `None` when it is closed.
|
|
pub browser: Option<FolderBrowser>,
|
|
}
|
|
|
|
/// The folder picker: where it is, and what is there.
|
|
///
|
|
/// Descends one level at a time because that is what the backend supports —
|
|
/// `Depth: infinity` is frequently disabled server-side and prohibitively
|
|
/// expensive where it is not (ARCH §8.4).
|
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
|
pub struct FolderBrowser {
|
|
/// Path being listed. Empty is the account root.
|
|
pub path: String,
|
|
/// Child folder names at `path`, not full paths.
|
|
pub entries: Vec<String>,
|
|
/// True while a listing is in flight.
|
|
pub loading: bool,
|
|
}
|
|
|
|
impl FolderBrowser {
|
|
/// Breadcrumb segments, root first.
|
|
pub fn breadcrumbs(&self) -> Vec<String> {
|
|
let mut out = vec!["/".to_string()];
|
|
out.extend(
|
|
self.path
|
|
.split('/')
|
|
.filter(|s| !s.is_empty())
|
|
.map(str::to_string),
|
|
);
|
|
out
|
|
}
|
|
|
|
/// The path after descending into `name`.
|
|
pub fn child_path(&self, name: &str) -> String {
|
|
if self.path.is_empty() {
|
|
name.to_string()
|
|
} else {
|
|
format!("{}/{}", self.path, name)
|
|
}
|
|
}
|
|
|
|
/// The parent path, or `None` at the root.
|
|
pub fn parent_path(&self) -> Option<String> {
|
|
if self.path.is_empty() {
|
|
None
|
|
} else {
|
|
Some(match self.path.rsplit_once('/') {
|
|
Some((head, _)) => head.to_string(),
|
|
None => String::new(),
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Truncate to the breadcrumb at `index` (0 is the root).
|
|
pub fn path_at_crumb(&self, index: usize) -> String {
|
|
if index == 0 {
|
|
return String::new();
|
|
}
|
|
self.path
|
|
.split('/')
|
|
.filter(|s| !s.is_empty())
|
|
.take(index)
|
|
.collect::<Vec<_>>()
|
|
.join("/")
|
|
}
|
|
}
|
|
|
|
impl Default for LaunchModel {
|
|
fn default() -> Self {
|
|
Self {
|
|
state: LaunchState::SignedOut,
|
|
server_url: String::new(),
|
|
folder_path: String::new(),
|
|
error: None,
|
|
status: None,
|
|
can_remember: true,
|
|
formats: Format::ALL.iter().map(|f| (*f, f.is_raw())).collect(),
|
|
browser: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl LaunchModel {
|
|
/// Build from stored sessions, resuming the last account if there is one.
|
|
pub fn from_store(store: &AccountStore) -> Self {
|
|
let can_remember = store.can_remember();
|
|
|
|
match store.current() {
|
|
Some(session) => {
|
|
let filter = session.format_filter();
|
|
let (server_url, folder_path) = prefill(&session);
|
|
Self {
|
|
server_url,
|
|
folder_path,
|
|
formats: Format::ALL
|
|
.iter()
|
|
.map(|f| (*f, filter.allows(*f)))
|
|
.collect(),
|
|
state: LaunchState::SignedIn { session },
|
|
can_remember,
|
|
..Default::default()
|
|
}
|
|
}
|
|
None => Self {
|
|
can_remember,
|
|
..Default::default()
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn is_signed_in(&self) -> bool {
|
|
matches!(self.state, LaunchState::SignedIn { .. })
|
|
}
|
|
|
|
pub fn is_busy(&self) -> bool {
|
|
matches!(self.state, LaunchState::Busy { .. })
|
|
}
|
|
|
|
pub fn session(&self) -> Option<&Account> {
|
|
match &self.state {
|
|
LaunchState::SignedIn { session } => Some(session),
|
|
// A session survives a busy period; a scan failure must not log
|
|
// the user out.
|
|
LaunchState::Busy {
|
|
session: Some(s), ..
|
|
} => Some(s),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// The account line, e.g. "duncan on cloud.example".
|
|
pub fn account_label(&self) -> String {
|
|
self.session().map(|s| s.describe()).unwrap_or_default()
|
|
}
|
|
|
|
/// The chosen library folder, empty until one is picked.
|
|
pub fn library_root(&self) -> String {
|
|
self.session().map(|s| s.root.clone()).unwrap_or_default()
|
|
}
|
|
|
|
/// Whether a root has been settled on, including the top level itself.
|
|
///
|
|
/// Three ways to have one: a subfolder is named; the picker confirmed
|
|
/// the top level, which `Account::root_chosen` records because the
|
|
/// empty string cannot; or the endpoint is a folder, whose top level is
|
|
/// the library by definition.
|
|
pub fn root_is_chosen(&self) -> bool {
|
|
self.session()
|
|
.is_some_and(|s| !s.root.is_empty() || s.root_chosen)
|
|
|| self.endpoint_is_library()
|
|
}
|
|
|
|
/// What the signed-in section shows for the root: empty when nothing is
|
|
/// chosen, `/` for the top level, else the path.
|
|
pub fn library_root_label(&self) -> String {
|
|
let root = self.library_root();
|
|
if root.is_empty() && self.root_is_chosen() {
|
|
"/".to_string()
|
|
} else {
|
|
root
|
|
}
|
|
}
|
|
|
|
/// Whether the endpoint itself is the library.
|
|
///
|
|
/// A folder account has no sub-root to choose: the directory the user
|
|
/// typed is the whole library, and asking them to pick a "library
|
|
/// folder" inside it a second time reads as though the first answer was
|
|
/// not taken. A cloud account is the opposite — its endpoint is an
|
|
/// entire server-side tree, and a root inside it is required (see
|
|
/// [`can_open_library`](Self::can_open_library)). Keyed on the absence
|
|
/// of a login, the same fact [`prefill`] keys on, rather than on the
|
|
/// connector's id.
|
|
pub fn endpoint_is_library(&self) -> bool {
|
|
self.session().is_some_and(|s| s.login.is_empty())
|
|
}
|
|
|
|
/// The login URL while approval is pending.
|
|
pub fn login_url(&self) -> String {
|
|
match &self.state {
|
|
LaunchState::AwaitingApproval { login_url } => login_url.clone(),
|
|
_ => String::new(),
|
|
}
|
|
}
|
|
|
|
/// Whether "Open library" should be clickable.
|
|
///
|
|
/// A cloud account requires a signed-in session *and* a chosen folder:
|
|
/// opening without one would scan the whole account, which on a real
|
|
/// library is thousands of directories the user did not ask for. Chosen
|
|
/// is the operative word, not non-empty — the top level is a legitimate
|
|
/// choice, and a folder library is the whole folder by definition.
|
|
pub fn can_open_library(&self) -> bool {
|
|
self.is_signed_in() && self.root_is_chosen()
|
|
}
|
|
|
|
/// What the app should do on startup.
|
|
///
|
|
/// Pure so it can be tested without a secret store or a display server —
|
|
/// and it needs testing, because the interesting case is the one with no
|
|
/// visible symptom until you are staring at an empty window.
|
|
pub fn startup_action(&self, have_local_paths: bool) -> Startup {
|
|
if have_local_paths {
|
|
// Files named on the command line win: the user asked for those
|
|
// specifically, not for their library.
|
|
Startup::ShowLocalFiles
|
|
} else if self.can_open_library() {
|
|
Startup::OpenLibrary
|
|
} else {
|
|
Startup::ShowLaunchScreen
|
|
}
|
|
}
|
|
|
|
pub fn format_filter(&self) -> FormatFilter {
|
|
FormatFilter::from_formats(self.formats.iter().filter(|(_, on)| *on).map(|(f, _)| *f))
|
|
}
|
|
|
|
/// Toggle one format tick-box.
|
|
pub fn set_format(&mut self, index: usize, enabled: bool) {
|
|
if let Some(entry) = self.formats.get_mut(index) {
|
|
entry.1 = enabled;
|
|
}
|
|
}
|
|
|
|
// --- transitions ---------------------------------------------------
|
|
|
|
pub fn begin_sign_in(&mut self, server: impl Into<String>) {
|
|
self.server_url = server.into();
|
|
self.error = None;
|
|
self.state = LaunchState::Busy {
|
|
message: "Contacting server…".into(),
|
|
session: None,
|
|
};
|
|
}
|
|
|
|
/// TRACES: FR-NC-1
|
|
/// Begin a sign-in with an app password the user supplied directly.
|
|
///
|
|
/// The browser flow is the recommended route because it never exposes a
|
|
/// credential to us, but it needs a browser and a round trip through one.
|
|
/// An app password is what Nextcloud offers instead: device-scoped,
|
|
/// individually revocable, and created by the user under Settings →
|
|
/// Security. That makes it the workable option where no browser can
|
|
/// complete the handshake.
|
|
pub fn begin_direct_sign_in(&mut self, server: impl Into<String>) {
|
|
self.server_url = server.into();
|
|
self.error = None;
|
|
self.state = LaunchState::Busy {
|
|
message: "Checking the credentials…".into(),
|
|
session: None,
|
|
};
|
|
}
|
|
|
|
pub fn await_approval(&mut self, login_url: impl Into<String>) {
|
|
self.status = Some("Approve the sign-in in your browser.".into());
|
|
self.state = LaunchState::AwaitingApproval {
|
|
login_url: login_url.into(),
|
|
};
|
|
}
|
|
|
|
pub fn signed_in(&mut self, session: Account) {
|
|
self.error = None;
|
|
self.status = None;
|
|
let (server_url, folder_path) = prefill(&session);
|
|
self.server_url = server_url;
|
|
if !folder_path.is_empty() {
|
|
self.folder_path = folder_path;
|
|
}
|
|
let filter = session.format_filter();
|
|
// Adopt the session's stored selection, so a returning user sees the
|
|
// tick-boxes they left.
|
|
if !session.formats.is_empty() {
|
|
self.formats = Format::ALL
|
|
.iter()
|
|
.map(|f| (*f, filter.allows(*f)))
|
|
.collect();
|
|
}
|
|
self.state = LaunchState::SignedIn { session };
|
|
}
|
|
|
|
pub fn signed_out(&mut self) {
|
|
self.error = None;
|
|
self.status = None;
|
|
self.state = LaunchState::SignedOut;
|
|
}
|
|
|
|
pub fn fail(&mut self, message: impl Into<String>) {
|
|
self.status = None;
|
|
self.error = Some(message.into());
|
|
// Return to whichever resting state makes sense, so a failure never
|
|
// strands the screen in Busy with no way forward.
|
|
self.state = match self.session() {
|
|
Some(s) => LaunchState::SignedIn { session: s.clone() },
|
|
None => LaunchState::SignedOut,
|
|
};
|
|
}
|
|
|
|
// --- folder picker --------------------------------------------------
|
|
|
|
/// Open the picker at the account root.
|
|
pub fn open_browser(&mut self) {
|
|
self.error = None;
|
|
self.browser = Some(FolderBrowser {
|
|
path: String::new(),
|
|
entries: Vec::new(),
|
|
loading: true,
|
|
});
|
|
}
|
|
|
|
pub fn close_browser(&mut self) {
|
|
self.browser = None;
|
|
}
|
|
|
|
/// Begin listing `path`.
|
|
pub fn browse_to(&mut self, path: impl Into<String>) {
|
|
let path = path.into();
|
|
match &mut self.browser {
|
|
Some(b) => {
|
|
b.path = path;
|
|
b.entries.clear();
|
|
b.loading = true;
|
|
}
|
|
None => {
|
|
self.browser = Some(FolderBrowser {
|
|
path,
|
|
entries: Vec::new(),
|
|
loading: true,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Record a completed listing.
|
|
pub fn browser_loaded(&mut self, entries: Vec<String>) {
|
|
if let Some(b) = &mut self.browser {
|
|
b.entries = entries;
|
|
b.loading = false;
|
|
}
|
|
}
|
|
|
|
/// Adopt the picker's current path as the library root.
|
|
///
|
|
/// Returns the session to persist, or `None` when signed out.
|
|
pub fn choose_current_folder(&mut self) -> Option<Account> {
|
|
let path = self.browser.as_ref()?.path.clone();
|
|
let mut session = self.session()?.clone();
|
|
session.root = path;
|
|
// Even when `path` is the top level: that is a choice, and the
|
|
// empty string alone would read as none having been made.
|
|
session.root_chosen = true;
|
|
self.browser = None;
|
|
self.state = LaunchState::SignedIn {
|
|
session: session.clone(),
|
|
};
|
|
Some(session)
|
|
}
|
|
|
|
pub fn busy(&mut self, message: impl Into<String>) {
|
|
self.error = None;
|
|
let session = self.session().cloned().map(Box::new);
|
|
self.state = LaunchState::Busy {
|
|
message: message.into(),
|
|
session,
|
|
};
|
|
}
|
|
}
|
|
|
|
/// Which of the two entry fields an account's endpoint belongs in.
|
|
///
|
|
/// A returning user should find what they typed last time where they typed
|
|
/// it. Keyed on whether the connector has a login rather than on its id, so a
|
|
/// third backend does not have to be named here to be prefilled correctly.
|
|
fn prefill(account: &Account) -> (String, String) {
|
|
if account.login.is_empty() {
|
|
(String::new(), account.endpoint.clone())
|
|
} else {
|
|
(account.endpoint.clone(), String::new())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use dr_plat::EphemeralSecretStore;
|
|
use dr_sync::Secret;
|
|
|
|
fn session_with_root(root: &str) -> Account {
|
|
let mut s =
|
|
Account::new("nextcloud", "https://cloud.example").with_login("duncan", "duncan");
|
|
s.root = root.into();
|
|
s
|
|
}
|
|
|
|
fn folder_with_root(root: &str) -> Account {
|
|
let mut s = Account::new("folder", "/mnt/photos");
|
|
s.root = root.into();
|
|
s
|
|
}
|
|
|
|
#[test]
|
|
fn a_fresh_model_is_signed_out_with_raw_preselected() {
|
|
let m = LaunchModel::default();
|
|
assert!(!m.is_signed_in());
|
|
// RAW ticked, JPEG not: a RAW editor's sensible default.
|
|
let f = m.format_filter();
|
|
assert!(f.allows(Format::Cr2));
|
|
assert!(!f.allows(Format::Jpeg));
|
|
}
|
|
|
|
#[test]
|
|
fn opening_a_library_needs_both_an_account_and_a_folder() {
|
|
let mut m = LaunchModel::default();
|
|
assert!(!m.can_open_library(), "signed out");
|
|
|
|
m.signed_in(session_with_root(""));
|
|
assert!(
|
|
!m.can_open_library(),
|
|
"no folder — would scan the whole account"
|
|
);
|
|
|
|
m.signed_in(session_with_root("PhotosRaw"));
|
|
assert!(m.can_open_library());
|
|
}
|
|
|
|
#[test]
|
|
fn a_configured_library_opens_rather_than_showing_nothing() {
|
|
// The regression this guards: skipping the launch screen because a
|
|
// library is configured used to mean the library was never opened,
|
|
// since `on_open_library` only fires from a button nobody saw.
|
|
let mut m = LaunchModel::default();
|
|
m.signed_in(session_with_root("PhotosRaw"));
|
|
assert_eq!(m.startup_action(false), Startup::OpenLibrary);
|
|
}
|
|
|
|
#[test]
|
|
fn no_configuration_shows_the_launch_screen() {
|
|
let m = LaunchModel::default();
|
|
assert_eq!(m.startup_action(false), Startup::ShowLaunchScreen);
|
|
}
|
|
|
|
#[test]
|
|
fn a_signed_in_account_without_a_folder_still_needs_the_screen() {
|
|
// Opening without a chosen folder would scan the whole account.
|
|
let mut m = LaunchModel::default();
|
|
m.signed_in(session_with_root(""));
|
|
assert_eq!(m.startup_action(false), Startup::ShowLaunchScreen);
|
|
}
|
|
|
|
#[test]
|
|
fn command_line_files_win_over_a_configured_library() {
|
|
// The user asked for those files specifically.
|
|
let mut m = LaunchModel::default();
|
|
m.signed_in(session_with_root("PhotosRaw"));
|
|
assert_eq!(m.startup_action(true), Startup::ShowLocalFiles);
|
|
}
|
|
|
|
#[test]
|
|
fn a_failure_never_strands_the_screen_in_busy() {
|
|
let mut m = LaunchModel::default();
|
|
m.begin_sign_in("cloud.example");
|
|
assert!(m.is_busy());
|
|
|
|
m.fail("server unreachable");
|
|
assert!(!m.is_busy(), "must return to a resting state");
|
|
assert_eq!(m.state, LaunchState::SignedOut);
|
|
assert!(m.error.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn a_failure_while_signed_in_returns_to_signed_in() {
|
|
let mut m = LaunchModel::default();
|
|
m.signed_in(session_with_root("PhotosRaw"));
|
|
m.busy("Scanning…");
|
|
m.fail("scan failed");
|
|
|
|
assert!(m.is_signed_in(), "a failed scan must not sign the user out");
|
|
assert!(m.error.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn the_login_url_is_exposed_only_while_pending() {
|
|
let mut m = LaunchModel::default();
|
|
assert_eq!(m.login_url(), "");
|
|
|
|
m.await_approval("https://cloud.example/login/v2/flow/abc");
|
|
assert!(m.login_url().contains("/flow/abc"));
|
|
|
|
m.signed_in(session_with_root(""));
|
|
assert_eq!(m.login_url(), "", "must not linger after sign-in");
|
|
}
|
|
|
|
#[test]
|
|
fn a_returning_user_finds_their_endpoint_where_they_typed_it() {
|
|
// Two entry fields are shown at once. Prefilling the wrong one — a
|
|
// folder path into the server box — reads as a corrupted setting.
|
|
let mut m = LaunchModel::default();
|
|
m.signed_in(session_with_root("PhotosRaw"));
|
|
assert_eq!(m.server_url, "https://cloud.example");
|
|
assert_eq!(m.folder_path, "");
|
|
|
|
let mut m = LaunchModel::default();
|
|
m.signed_in(folder_with_root("2026"));
|
|
assert_eq!(m.folder_path, "/mnt/photos");
|
|
assert_eq!(m.server_url, "");
|
|
}
|
|
|
|
#[test]
|
|
fn a_folder_library_reaches_the_grid_the_same_way_a_server_one_does() {
|
|
// Everything past sign-in is backend-neutral, and this is the check
|
|
// that keeps it so: no branch on the account's connector below here.
|
|
let mut m = LaunchModel::default();
|
|
m.signed_in(folder_with_root("2026"));
|
|
assert!(m.is_signed_in());
|
|
assert!(m.can_open_library());
|
|
assert_eq!(m.startup_action(false), Startup::OpenLibrary);
|
|
assert_eq!(m.account_label(), "/mnt/photos/2026");
|
|
}
|
|
|
|
#[test]
|
|
fn a_folder_is_the_whole_library_without_choosing_a_root() {
|
|
// The directory typed on the launch screen is the answer to "which
|
|
// folder"; a second question with the same name is what confused
|
|
// the first person to open one. A server account still needs a
|
|
// root, because its endpoint is the entire tree.
|
|
let mut m = LaunchModel::default();
|
|
m.signed_in(folder_with_root(""));
|
|
assert!(m.endpoint_is_library());
|
|
assert!(m.can_open_library(), "the folder itself is the library");
|
|
assert_eq!(m.library_root_label(), "/");
|
|
assert_eq!(m.startup_action(false), Startup::OpenLibrary);
|
|
|
|
m.signed_in(session_with_root(""));
|
|
assert!(!m.endpoint_is_library());
|
|
assert!(!m.can_open_library(), "a server account needs a root");
|
|
assert_eq!(m.startup_action(false), Startup::ShowLaunchScreen);
|
|
}
|
|
|
|
#[test]
|
|
fn format_toggles_apply_and_out_of_range_is_ignored() {
|
|
let mut m = LaunchModel::default();
|
|
let jpeg = Format::ALL.iter().position(|f| *f == Format::Jpeg).unwrap();
|
|
|
|
m.set_format(jpeg, true);
|
|
assert!(m.format_filter().allows(Format::Jpeg));
|
|
|
|
// Must not panic on a stale index from the UI.
|
|
m.set_format(9999, true);
|
|
}
|
|
|
|
#[test]
|
|
fn a_stored_session_is_resumed_with_its_format_selection() {
|
|
let dir = std::env::temp_dir().join("darkroom-launch-test");
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
|
|
let store = AccountStore::open_at(
|
|
dir.join("sessions.json"),
|
|
Box::new(EphemeralSecretStore::new()),
|
|
);
|
|
let mut s = session_with_root("PhotosRaw");
|
|
s.set_format_filter(&FormatFilter::from_formats([Format::Cr2]));
|
|
store.save(&s, Some(&Secret::new("token"))).unwrap();
|
|
|
|
let m = LaunchModel::from_store(&store);
|
|
assert!(m.is_signed_in());
|
|
assert_eq!(m.library_root(), "PhotosRaw");
|
|
assert!(m.format_filter().allows(Format::Cr2));
|
|
assert!(!m.format_filter().allows(Format::Dng));
|
|
assert_eq!(m.server_url, "https://cloud.example");
|
|
}
|
|
|
|
#[test]
|
|
fn no_stored_session_yields_a_signed_out_model() {
|
|
let dir = std::env::temp_dir().join("darkroom-launch-empty");
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
|
|
let store = AccountStore::open_at(
|
|
dir.join("sessions.json"),
|
|
Box::new(EphemeralSecretStore::new()),
|
|
);
|
|
let m = LaunchModel::from_store(&store);
|
|
assert!(!m.is_signed_in());
|
|
}
|
|
|
|
#[test]
|
|
fn browsing_descends_and_ascends() {
|
|
let mut m = LaunchModel::default();
|
|
m.signed_in(session_with_root(""));
|
|
m.open_browser();
|
|
|
|
let b = m.browser.as_ref().unwrap();
|
|
assert_eq!(b.path, "", "opens at the account root");
|
|
assert!(b.loading);
|
|
|
|
m.browser_loaded(vec!["Photos".into(), "Archive".into()]);
|
|
assert!(!m.browser.as_ref().unwrap().loading);
|
|
|
|
let child = m.browser.as_ref().unwrap().child_path("Photos");
|
|
assert_eq!(child, "Photos");
|
|
m.browse_to(child);
|
|
m.browser_loaded(vec!["2026".into()]);
|
|
|
|
let deeper = m.browser.as_ref().unwrap().child_path("2026");
|
|
assert_eq!(deeper, "Photos/2026");
|
|
m.browse_to(deeper);
|
|
|
|
assert_eq!(
|
|
m.browser.as_ref().unwrap().parent_path(),
|
|
Some("Photos".into())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_root_has_no_parent() {
|
|
let b = FolderBrowser::default();
|
|
assert_eq!(b.parent_path(), None, "no way up from the account root");
|
|
}
|
|
|
|
#[test]
|
|
fn ascending_from_a_top_level_folder_reaches_the_root() {
|
|
let b = FolderBrowser {
|
|
path: "Photos".into(),
|
|
..Default::default()
|
|
};
|
|
assert_eq!(b.parent_path(), Some(String::new()));
|
|
}
|
|
|
|
#[test]
|
|
fn confirming_sets_the_library_root() {
|
|
let mut m = LaunchModel::default();
|
|
m.signed_in(session_with_root(""));
|
|
m.open_browser();
|
|
m.browse_to("Photos/2026");
|
|
|
|
let session = m.choose_current_folder().expect("a session");
|
|
assert_eq!(session.root, "Photos/2026");
|
|
assert_eq!(m.library_root(), "Photos/2026");
|
|
assert!(m.browser.is_none(), "picker closes on confirm");
|
|
assert!(m.can_open_library());
|
|
}
|
|
|
|
#[test]
|
|
fn confirming_at_the_root_selects_the_whole_account() {
|
|
// Legitimate: a user may keep everything at the top level — and it
|
|
// must then open. It used to be recorded as an empty root, which
|
|
// is indistinguishable from no root, so "Open library" stayed
|
|
// disabled after the picker had plainly been answered.
|
|
let mut m = LaunchModel::default();
|
|
m.signed_in(session_with_root(""));
|
|
assert!(!m.can_open_library(), "nothing chosen yet");
|
|
assert_eq!(m.library_root_label(), "");
|
|
m.open_browser();
|
|
|
|
let session = m.choose_current_folder().expect("a session");
|
|
assert_eq!(session.root, "");
|
|
assert!(
|
|
session.root_chosen,
|
|
"the choice survives to the config file"
|
|
);
|
|
assert!(m.can_open_library());
|
|
assert_eq!(m.library_root_label(), "/");
|
|
assert_eq!(m.startup_action(false), Startup::OpenLibrary);
|
|
}
|
|
|
|
#[test]
|
|
fn cancelling_leaves_the_root_unchanged() {
|
|
let mut m = LaunchModel::default();
|
|
m.signed_in(session_with_root("Original"));
|
|
m.open_browser();
|
|
m.browse_to("Somewhere/Else");
|
|
m.close_browser();
|
|
|
|
assert!(m.browser.is_none());
|
|
assert_eq!(m.library_root(), "Original", "cancel must not select");
|
|
}
|
|
|
|
#[test]
|
|
fn confirming_while_signed_out_does_nothing() {
|
|
let mut m = LaunchModel::default();
|
|
m.open_browser();
|
|
assert!(m.choose_current_folder().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn breadcrumbs_start_at_the_root() {
|
|
let b = FolderBrowser {
|
|
path: "Photos/2026/Trip".into(),
|
|
..Default::default()
|
|
};
|
|
assert_eq!(b.breadcrumbs(), vec!["/", "Photos", "2026", "Trip"]);
|
|
assert_eq!(b.path_at_crumb(0), "");
|
|
assert_eq!(b.path_at_crumb(2), "Photos/2026");
|
|
}
|
|
|
|
#[test]
|
|
fn account_label_is_empty_when_signed_out() {
|
|
assert_eq!(LaunchModel::default().account_label(), "");
|
|
}
|
|
}
|