use std::collections::HashMap;
use super::{
AppSettings, BACKLOG_EXCLUDED_SPRINT_NAME_FRAGMENTS_SETTING, BACKLOG_FILTERS_SETTING,
BACKLOG_FIXED_SPRINT_CAPACITY_SETTING, BACKLOG_FIXED_TICKET_SIZE_SETTING,
BACKLOG_SPRINT_TOLERANCE_PERCENT_SETTING, BACKLOG_USE_AVERAGE_TICKET_SIZE_SETTING,
BACKLOG_USE_JIRA_VELOCITY_SETTING, COMPOSER_ADD_CHILD_KEY_SETTING,
COMPOSER_ADD_SIBLING_KEY_SETTING, COMPOSER_COMMIT_KEY_SETTING,
COMPOSER_CREATE_SUBMIT_KEY_SETTING, COMPOSER_DESCRIPTION_FOCUS_KEY_SETTING,
COMPOSER_DESCRIPTION_READER_KEY_SETTING, COMPOSER_ISSUE_TYPE_KEY_SETTING,
COMPOSER_VIEW_KEY_SETTING, JIRA_COMPANY_MANAGED_URLS_SETTING,
JIRA_STORY_POINTS_BOARD_ID_SETTING, JIRA_STORY_POINTS_FIELD_ID_SETTING,
RECENT_TICKETS_LIMIT_SETTING, SPEED_READER_WPM_SETTING,
};
#[test]
fn persistence_only_includes_changed_settings() {
let previous = AppSettings {
jira_api_token: "environment-secret".into(),
..AppSettings::default()
};
let mut current = previous.clone();
current.speed_reader.wpm = 500;
assert_eq!(
current.changed_values(&previous),
vec![(SPEED_READER_WPM_SETTING, "500".into())]
);
}
#[test]
fn story_points_field_id_round_trips_through_settings_values() {
let settings = AppSettings::resolve(&HashMap::from([
(
JIRA_STORY_POINTS_FIELD_ID_SETTING.into(),
"customfield_10016".into(),
),
(JIRA_STORY_POINTS_BOARD_ID_SETTING.into(), "42".into()),
]))
.unwrap();
assert_eq!(settings.jira_story_points_field_id, "customfield_10016");
assert_eq!(settings.jira_story_points_board_id, "42");
assert!(settings.values().contains(&(
JIRA_STORY_POINTS_FIELD_ID_SETTING,
"customfield_10016".into(),
)));
assert!(
settings
.values()
.contains(&(JIRA_STORY_POINTS_BOARD_ID_SETTING, "42".into()))
);
}
#[test]
fn company_managed_url_setting_round_trips_through_settings_values() {
let settings = AppSettings::resolve(&HashMap::from([(
JIRA_COMPANY_MANAGED_URLS_SETTING.into(),
"true".into(),
)]))
.unwrap();
assert!(settings.jira_company_managed_urls);
assert!(
settings
.values()
.contains(&(JIRA_COMPANY_MANAGED_URLS_SETTING, "true".into()))
);
}
#[test]
fn jira_issue_url_uses_the_configured_site_and_trims_trailing_slashes() {
let settings = AppSettings {
jira_base_url: "https://finery.atlassian.net///".into(),
..AppSettings::default()
};
assert_eq!(
settings.jira_issue_url("FIN-42").as_deref(),
Some("https://finery.atlassian.net/browse/FIN-42")
);
}
#[test]
fn jira_board_url_uses_the_configured_project_and_board() {
let settings = AppSettings {
jira_base_url: "https://finery.atlassian.net///".into(),
jira_default_project: "FIN".into(),
jira_default_board: "42".into(),
..AppSettings::default()
};
assert_eq!(
settings.jira_board_url(Some("timeline")).as_deref(),
Some("https://finery.atlassian.net/jira/software/projects/FIN/boards/42/timeline")
);
assert_eq!(
settings.jira_board_url(None).as_deref(),
Some("https://finery.atlassian.net/jira/software/projects/FIN/boards/42")
);
let company_managed = AppSettings {
jira_company_managed_urls: true,
..settings.clone()
};
assert_eq!(
company_managed.jira_board_url(Some("reports")).as_deref(),
Some("https://finery.atlassian.net/jira/software/c/projects/FIN/boards/42/reports")
);
assert_eq!(
settings.jira_releases_url().as_deref(),
Some(
"https://finery.atlassian.net/projects/FIN?selectedItem=com.atlassian.jira.jira-projects-plugin%3Arelease-page"
)
);
}
#[test]
fn backlog_runway_settings_round_trip_and_reject_invalid_values() {
let settings = AppSettings::resolve(&HashMap::from([
(BACKLOG_USE_JIRA_VELOCITY_SETTING.into(), "true".into()),
(BACKLOG_FIXED_SPRINT_CAPACITY_SETTING.into(), "18.5".into()),
(
BACKLOG_USE_AVERAGE_TICKET_SIZE_SETTING.into(),
"true".into(),
),
(BACKLOG_FIXED_TICKET_SIZE_SETTING.into(), "2.5".into()),
(BACKLOG_SPRINT_TOLERANCE_PERCENT_SETTING.into(), "15".into()),
]))
.unwrap();
assert!(settings.backlog_runway.use_jira_velocity);
assert_eq!(settings.backlog_runway.jira_velocity_sprints, 4);
assert_eq!(settings.backlog_runway.fixed_sprint_capacity, 18.5);
assert!(settings.backlog_runway.use_average_ticket_size);
assert_eq!(settings.backlog_runway.fixed_ticket_size, 2.5);
assert_eq!(settings.backlog_runway.sprint_tolerance_percent, 15);
assert!(
settings
.values()
.contains(&(BACKLOG_FIXED_SPRINT_CAPACITY_SETTING, "18.5".into()))
);
let defaults = AppSettings::resolve(&HashMap::from([(
BACKLOG_FIXED_SPRINT_CAPACITY_SETTING.into(),
"0".into(),
)]))
.unwrap();
assert_eq!(defaults.backlog_runway.fixed_sprint_capacity, 20.0);
assert_eq!(defaults.backlog_runway.sprint_tolerance_percent, 20);
}
#[test]
fn backlog_filters_round_trip_in_their_display_order() {
let settings = AppSettings::resolve(&HashMap::from([(
BACKLOG_FILTERS_SETTING.into(),
"hide_unestimated,hide_done,unknown,hide_estimated".into(),
)]))
.unwrap();
assert_eq!(
settings
.values()
.into_iter()
.find(|(key, _)| *key == BACKLOG_FILTERS_SETTING),
Some((
BACKLOG_FILTERS_SETTING,
"hide_done,hide_estimated,hide_unestimated".into()
))
);
}
#[test]
fn excluded_sprint_name_fragments_round_trip_and_match_case_insensitively() {
let settings = AppSettings::resolve(&HashMap::from([(
BACKLOG_EXCLUDED_SPRINT_NAME_FRAGMENTS_SETTING.into(),
" abc , Archive,ABC,,".into(),
)]))
.unwrap();
assert_eq!(settings.excluded_sprint_name_fragments, ["abc", "Archive"]);
assert!(settings.excludes_sprint("AbCde"));
assert!(settings.excludes_sprint("2026 archive"));
assert!(!settings.excludes_sprint("Current sprint"));
assert!(settings.values().contains(&(
BACKLOG_EXCLUDED_SPRINT_NAME_FRAGMENTS_SETTING,
"abc,Archive".into(),
)));
}
#[test]
fn recent_ticket_limit_defaults_to_fifteen_and_rejects_invalid_values() {
assert_eq!(AppSettings::default().recent_tickets_limit, 15);
let settings = AppSettings::resolve(&HashMap::from([(
RECENT_TICKETS_LIMIT_SETTING.into(),
"25".into(),
)]))
.unwrap();
assert_eq!(settings.recent_tickets_limit, 25);
assert!(
settings
.values()
.contains(&(RECENT_TICKETS_LIMIT_SETTING, "25".into()))
);
let invalid = AppSettings::resolve(&HashMap::from([(
RECENT_TICKETS_LIMIT_SETTING.into(),
"0".into(),
)]))
.unwrap();
assert_eq!(invalid.recent_tickets_limit, 15);
}
#[test]
fn manual_story_points_field_clears_discovery_provenance() {
let mut settings = AppSettings {
jira_story_points_board_id: "42".into(),
jira_story_points_discovery_complete: true,
..AppSettings::default()
};
settings.set_manual_story_points_field("customfield_10016".into());
assert_eq!(settings.jira_story_points_field_id, "customfield_10016");
assert!(settings.jira_story_points_board_id.is_empty());
assert!(!settings.jira_story_points_discovery_complete);
}
#[test]
fn board_changes_invalidate_discovery_without_clearing_manual_story_points() {
let mut discovered = AppSettings {
jira_story_points_field_id: "customfield_discovered".into(),
jira_story_points_board_id: "42".into(),
jira_story_points_discovery_complete: true,
..AppSettings::default()
};
discovered.invalidate_discovered_story_points();
assert!(discovered.jira_story_points_field_id.is_empty());
assert!(discovered.jira_story_points_board_id.is_empty());
let mut manual = AppSettings {
jira_story_points_field_id: "customfield_manual".into(),
..AppSettings::default()
};
manual.invalidate_discovered_story_points();
assert_eq!(manual.jira_story_points_field_id, "customfield_manual");
assert!(manual.story_points_field_is_manual());
}
#[test]
fn composer_keys_resolve_labels_and_reject_active_duplicates() {
let values = HashMap::from([
(COMPOSER_ADD_SIBLING_KEY_SETTING.into(), "alt+s".into()),
(COMPOSER_ADD_CHILD_KEY_SETTING.into(), "shift+c".into()),
(COMPOSER_COMMIT_KEY_SETTING.into(), "ctrl+m".into()),
(COMPOSER_VIEW_KEY_SETTING.into(), "shift+v".into()),
]);
let settings = AppSettings::resolve(&values).unwrap();
assert_eq!(settings.composer_keys.add_sibling.label(), "⌥s");
assert_eq!(settings.composer_keys.commit.label(), "⌃m");
assert_eq!(settings.composer_keys.create_submit.label(), "⌃Enter");
assert_eq!(settings.composer_keys.description_focus.label(), "dd");
let duplicate = HashMap::from([
(COMPOSER_ADD_SIBLING_KEY_SETTING.into(), "shift+s".into()),
(COMPOSER_COMMIT_KEY_SETTING.into(), "shift+s".into()),
]);
assert!(super::ComposerKeyBindings::from_values(&duplicate).is_err());
assert!(AppSettings::resolve(&duplicate).is_err());
let duplicate_sequence = HashMap::from([
(COMPOSER_DESCRIPTION_FOCUS_KEY_SETTING.into(), "dd".into()),
(COMPOSER_DESCRIPTION_READER_KEY_SETTING.into(), "dd".into()),
]);
assert!(AppSettings::resolve(&duplicate_sequence).is_err());
let duplicate_create_dialog =
HashMap::from([(COMPOSER_CREATE_SUBMIT_KEY_SETTING.into(), "o".into())]);
assert!(AppSettings::resolve(&duplicate_create_dialog).is_err());
let prefix_across_workspace = HashMap::from([
(COMPOSER_ADD_SIBLING_KEY_SETTING.into(), "d".into()),
(COMPOSER_DESCRIPTION_FOCUS_KEY_SETTING.into(), "dd".into()),
]);
assert!(AppSettings::resolve(&prefix_across_workspace).is_err());
let exact_across_workspace = HashMap::from([
(COMPOSER_ADD_SIBLING_KEY_SETTING.into(), "d".into()),
(COMPOSER_ISSUE_TYPE_KEY_SETTING.into(), "d".into()),
]);
assert!(AppSettings::resolve(&exact_across_workspace).is_err());
}