|
|
|
@@ -0,0 +1,527 @@
|
|
|
|
|
//! One-shot "What's new" card on the first launch after an update
|
|
|
|
|
//! (Phase I of the 2026-07 UI redesign).
|
|
|
|
|
//!
|
|
|
|
|
//! ObtainX updates install silently, so shipped features go unnoticed —
|
|
|
|
|
//! nobody found the theme store on their own. On the first launch where
|
|
|
|
|
//! the running release differs from `Settings::last_seen_whats_new`,
|
|
|
|
|
//! this plugin shows a single dismissible card summarising the latest
|
|
|
|
|
//! changelog section, then records the version so the card never
|
|
|
|
|
//! repeats. Fresh installs never see it: onboarding completion stamps
|
|
|
|
|
//! the current version silently, so the card only ever describes an
|
|
|
|
|
//! *upgrade*.
|
|
|
|
|
//!
|
|
|
|
|
//! # Launch beat
|
|
|
|
|
//!
|
|
|
|
|
//! Splash → (first run only: onboarding) → **what's new** → Home →
|
|
|
|
|
//! table. `spawn_home_on_launch` waits on [`WhatsNewPending`] the same
|
|
|
|
|
//! way it waits for the onboarding modal.
|
|
|
|
|
//!
|
|
|
|
|
//! # Version source
|
|
|
|
|
//!
|
|
|
|
|
//! The embedded `CHANGELOG.md`'s topmost release section provides both
|
|
|
|
|
//! the card's content and the "current version" for change detection —
|
|
|
|
|
//! one source of truth, no build-time version plumbing. (The APK
|
|
|
|
|
//! `versionName` comes from the release tag at package time and never
|
|
|
|
|
//! reaches Rust; the workspace `Cargo.toml` version is static.)
|
|
|
|
|
|
|
|
|
|
use bevy::input::ButtonInput;
|
|
|
|
|
use bevy::prelude::*;
|
|
|
|
|
use solitaire_data::save_settings_to;
|
|
|
|
|
|
|
|
|
|
use crate::font_plugin::FontResource;
|
|
|
|
|
use crate::onboarding_plugin::OnboardingScreen;
|
|
|
|
|
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
|
|
|
|
use crate::ui_modal::{
|
|
|
|
|
ButtonVariant, ModalScrim, spawn_modal, spawn_modal_actions, spawn_modal_button,
|
|
|
|
|
spawn_modal_header,
|
|
|
|
|
};
|
|
|
|
|
use crate::ui_theme::{
|
|
|
|
|
TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, Z_MODAL_PANEL,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/// The changelog ships inside the binary — small, changes only at
|
|
|
|
|
/// release cadence, and the card must work offline (§4.2).
|
|
|
|
|
const CHANGELOG: &str = include_str!("../../CHANGELOG.md");
|
|
|
|
|
|
|
|
|
|
/// Most bullets shown on the card; a giant release stays skimmable.
|
|
|
|
|
const MAX_CARD_BULLETS: usize = 8;
|
|
|
|
|
|
|
|
|
|
/// Marker on the What's-new modal's scrim root.
|
|
|
|
|
#[derive(Component, Debug)]
|
|
|
|
|
pub struct WhatsNewScreen;
|
|
|
|
|
|
|
|
|
|
/// Marker on the card's "Got it" button.
|
|
|
|
|
#[derive(Component, Debug)]
|
|
|
|
|
struct WhatsNewCloseButton;
|
|
|
|
|
|
|
|
|
|
/// `true` until this plugin has made its launch-beat decision — either
|
|
|
|
|
/// the card was shown and dismissed, or there was nothing to show.
|
|
|
|
|
/// `spawn_home_on_launch` waits on this so the card gets the beat
|
|
|
|
|
/// before Home.
|
|
|
|
|
#[derive(Resource, Debug)]
|
|
|
|
|
pub struct WhatsNewPending(pub bool);
|
|
|
|
|
|
|
|
|
|
impl Default for WhatsNewPending {
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
Self(true)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// One display line of the card.
|
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
|
|
|
enum NoteLine {
|
|
|
|
|
/// A `###` section heading ("Added", "Fixed", …).
|
|
|
|
|
Heading(String),
|
|
|
|
|
/// The lead sentence of one changelog bullet.
|
|
|
|
|
Bullet(String),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The changelog's topmost release section, reduced to card content.
|
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
struct ReleaseNotes {
|
|
|
|
|
/// Version string without the `v` prefix, e.g. `"0.46.0"`.
|
|
|
|
|
version: String,
|
|
|
|
|
lines: Vec<NoteLine>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The version of the topmost changelog release — the engine's notion
|
|
|
|
|
/// of "the running release". Empty only if the changelog is malformed.
|
|
|
|
|
pub fn current_release_version() -> String {
|
|
|
|
|
parse_latest_release(CHANGELOG).map_or_else(String::new, |notes| notes.version)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Parses the first `## [x.y.z]` section of `changelog` into card
|
|
|
|
|
/// content. `## [Unreleased]` is skipped; `### Internal` subsections
|
|
|
|
|
/// are dropped (players don't care about CI); each bullet is reduced
|
|
|
|
|
/// to its lead sentence with markdown emphasis stripped.
|
|
|
|
|
fn parse_latest_release(changelog: &str) -> Option<ReleaseNotes> {
|
|
|
|
|
let mut lines_iter = changelog.lines();
|
|
|
|
|
let mut version = None;
|
|
|
|
|
for line in lines_iter.by_ref() {
|
|
|
|
|
if let Some(rest) = line.strip_prefix("## [") {
|
|
|
|
|
let (v, _) = rest.split_once(']')?;
|
|
|
|
|
if v.eq_ignore_ascii_case("Unreleased") {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
version = Some(v.to_string());
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let version = version?;
|
|
|
|
|
|
|
|
|
|
let mut lines = Vec::new();
|
|
|
|
|
let mut skipping_section = false;
|
|
|
|
|
let mut current_bullet: Option<String> = None;
|
|
|
|
|
|
|
|
|
|
let flush = |bullet: &mut Option<String>, lines: &mut Vec<NoteLine>| {
|
|
|
|
|
if let Some(text) = bullet.take() {
|
|
|
|
|
lines.push(NoteLine::Bullet(lead_sentence(&text)));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
for line in lines_iter {
|
|
|
|
|
if line.starts_with("## [") {
|
|
|
|
|
break; // next (older) release section
|
|
|
|
|
}
|
|
|
|
|
if let Some(heading) = line.strip_prefix("### ") {
|
|
|
|
|
flush(&mut current_bullet, &mut lines);
|
|
|
|
|
skipping_section = heading.trim().eq_ignore_ascii_case("internal");
|
|
|
|
|
if !skipping_section {
|
|
|
|
|
lines.push(NoteLine::Heading(heading.trim().to_string()));
|
|
|
|
|
}
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if skipping_section {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if let Some(rest) = line.strip_prefix("- ") {
|
|
|
|
|
flush(&mut current_bullet, &mut lines);
|
|
|
|
|
current_bullet = Some(rest.trim().to_string());
|
|
|
|
|
} else if current_bullet.is_some() && !line.trim().is_empty() {
|
|
|
|
|
// Wrapped continuation of the current bullet.
|
|
|
|
|
if let Some(bullet) = current_bullet.as_mut() {
|
|
|
|
|
bullet.push(' ');
|
|
|
|
|
bullet.push_str(line.trim());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
flush(&mut current_bullet, &mut lines);
|
|
|
|
|
|
|
|
|
|
Some(ReleaseNotes { version, lines })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Strips markdown emphasis / code ticks and truncates to the first
|
|
|
|
|
/// sentence — changelog bullets lead with a bold summary sentence, and
|
|
|
|
|
/// that is exactly the card-sized version.
|
|
|
|
|
fn lead_sentence(bullet: &str) -> String {
|
|
|
|
|
let stripped: String = bullet.replace("**", "").replace('`', "");
|
|
|
|
|
// Trailing "(#123)" references never survive the sentence cut, but
|
|
|
|
|
// guard against a bullet that is only a reference.
|
|
|
|
|
let end = stripped.find(". ").map_or(stripped.len(), |i| i + 1);
|
|
|
|
|
stripped[..end].trim().trim_end_matches('.').to_string()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Registers the launch-gate spawn system and the dismiss handlers.
|
|
|
|
|
pub struct WhatsNewPlugin;
|
|
|
|
|
|
|
|
|
|
impl Plugin for WhatsNewPlugin {
|
|
|
|
|
fn build(&self, app: &mut App) {
|
|
|
|
|
app.init_resource::<WhatsNewPending>()
|
|
|
|
|
.init_resource::<ButtonInput<KeyCode>>()
|
|
|
|
|
.add_systems(
|
|
|
|
|
Update,
|
|
|
|
|
(maybe_spawn_whats_new, handle_whats_new_close).chain(),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Shows the card once the launch surface is clear, or stands down when
|
|
|
|
|
/// there is nothing to show. See the module docs for the exact beat.
|
|
|
|
|
///
|
|
|
|
|
/// The seen-version stamp persists on *spawn*, not dismissal — if the
|
|
|
|
|
/// app dies with the card open the player has still seen it once, and
|
|
|
|
|
/// the card must never nag.
|
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
|
|
|
fn maybe_spawn_whats_new(
|
|
|
|
|
mut commands: Commands,
|
|
|
|
|
mut pending: ResMut<WhatsNewPending>,
|
|
|
|
|
splash: Query<(), With<crate::splash_plugin::SplashRoot>>,
|
|
|
|
|
onboarding: Query<(), With<OnboardingScreen>>,
|
|
|
|
|
restore_prompts: Query<(), With<crate::game_plugin::RestorePromptScreen>>,
|
|
|
|
|
pending_restore: Option<Res<crate::game_plugin::PendingRestoredGame>>,
|
|
|
|
|
other_scrims: Query<(), With<ModalScrim>>,
|
|
|
|
|
mut settings: Option<ResMut<SettingsResource>>,
|
|
|
|
|
storage_path: Option<Res<SettingsStoragePath>>,
|
|
|
|
|
font_res: Option<Res<FontResource>>,
|
|
|
|
|
screens: Query<(), With<WhatsNewScreen>>,
|
|
|
|
|
) {
|
|
|
|
|
if !pending.0 {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if !screens.is_empty() {
|
|
|
|
|
// Card already open — the seen-version stamp landed on spawn,
|
|
|
|
|
// so without this guard the next frame would read "already
|
|
|
|
|
// seen" and release the beat under the open card.
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if !splash.is_empty()
|
|
|
|
|
|| !onboarding.is_empty()
|
|
|
|
|
|| !restore_prompts.is_empty()
|
|
|
|
|
|| pending_restore.as_ref().is_some_and(|p| p.0.is_some())
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
let Some(settings) = settings.as_mut() else {
|
|
|
|
|
// Headless / no settings wired: nothing to compare against.
|
|
|
|
|
pending.0 = false;
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
if !settings.0.first_run_complete {
|
|
|
|
|
// Fresh install mid-onboarding — completion stamps the version
|
|
|
|
|
// itself (see `complete_onboarding`), which resolves this gate
|
|
|
|
|
// on a later frame without ever showing the card.
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
let Some(notes) = parse_latest_release(CHANGELOG) else {
|
|
|
|
|
pending.0 = false;
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
if settings.0.last_seen_whats_new == notes.version {
|
|
|
|
|
pending.0 = false;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if !other_scrims.is_empty() {
|
|
|
|
|
// Another modal owns the beat right now; try again next frame.
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
spawn_whats_new_card(&mut commands, ¬es, font_res.as_deref());
|
|
|
|
|
|
|
|
|
|
settings.0.last_seen_whats_new = notes.version.clone();
|
|
|
|
|
if let Some(p) = storage_path
|
|
|
|
|
&& let Some(path) = p.0.as_deref()
|
|
|
|
|
&& let Err(e) = save_settings_to(path, &settings.0)
|
|
|
|
|
{
|
|
|
|
|
warn!("whats-new: failed to persist seen version: {e}");
|
|
|
|
|
}
|
|
|
|
|
// `pending` stays true until dismissal so Home keeps waiting.
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Dismisses the card on "Got it" or Esc and releases the launch beat.
|
|
|
|
|
fn handle_whats_new_close(
|
|
|
|
|
mut commands: Commands,
|
|
|
|
|
keys: Option<Res<ButtonInput<KeyCode>>>,
|
|
|
|
|
buttons: Query<&Interaction, (With<WhatsNewCloseButton>, Changed<Interaction>)>,
|
|
|
|
|
screens: Query<Entity, With<WhatsNewScreen>>,
|
|
|
|
|
other_scrims: Query<(), (With<ModalScrim>, Without<WhatsNewScreen>)>,
|
|
|
|
|
mut pending: ResMut<WhatsNewPending>,
|
|
|
|
|
) {
|
|
|
|
|
if screens.is_empty() {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
let click = buttons.iter().any(|i| *i == Interaction::Pressed);
|
|
|
|
|
let esc = keys.is_some_and(|k| k.just_pressed(KeyCode::Escape)) && other_scrims.is_empty();
|
|
|
|
|
if !click && !esc {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
for entity in &screens {
|
|
|
|
|
commands.entity(entity).despawn();
|
|
|
|
|
}
|
|
|
|
|
pending.0 = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Spawns the card: header with the version, the parsed changelog
|
|
|
|
|
/// lines, and a "Got it" action.
|
|
|
|
|
fn spawn_whats_new_card(
|
|
|
|
|
commands: &mut Commands,
|
|
|
|
|
notes: &ReleaseNotes,
|
|
|
|
|
font_res: Option<&FontResource>,
|
|
|
|
|
) {
|
|
|
|
|
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
|
|
|
|
let font_heading = TextFont {
|
|
|
|
|
font: font_handle.clone(),
|
|
|
|
|
font_size: TYPE_CAPTION,
|
|
|
|
|
..default()
|
|
|
|
|
};
|
|
|
|
|
let font_bullet = TextFont {
|
|
|
|
|
font: font_handle,
|
|
|
|
|
font_size: TYPE_BODY,
|
|
|
|
|
..default()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Deliberately NOT ScrimDismissible: scrim-tap despawns without
|
|
|
|
|
// running the close handler, which would leave the launch beat
|
|
|
|
|
// held and Home never spawning. Esc and "Got it" both release it.
|
|
|
|
|
let title = format!("What's new in v{}", notes.version);
|
|
|
|
|
spawn_modal(commands, WhatsNewScreen, Z_MODAL_PANEL, |card| {
|
|
|
|
|
spawn_modal_header(card, &title, font_res);
|
|
|
|
|
|
|
|
|
|
card.spawn(Node {
|
|
|
|
|
flex_direction: FlexDirection::Column,
|
|
|
|
|
row_gap: VAL_SPACE_2,
|
|
|
|
|
width: Val::Percent(100.0),
|
|
|
|
|
max_height: Val::Vh(60.0),
|
|
|
|
|
overflow: Overflow::scroll_y(),
|
|
|
|
|
..default()
|
|
|
|
|
})
|
|
|
|
|
.with_children(|body| {
|
|
|
|
|
let mut bullets_shown = 0usize;
|
|
|
|
|
for line in ¬es.lines {
|
|
|
|
|
match line {
|
|
|
|
|
NoteLine::Heading(heading) => {
|
|
|
|
|
body.spawn((
|
|
|
|
|
Text::new(heading.clone()),
|
|
|
|
|
font_heading.clone(),
|
|
|
|
|
TextColor(TEXT_SECONDARY),
|
|
|
|
|
Node {
|
|
|
|
|
margin: UiRect::top(VAL_SPACE_1),
|
|
|
|
|
..default()
|
|
|
|
|
},
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
NoteLine::Bullet(text) => {
|
|
|
|
|
if bullets_shown >= MAX_CARD_BULLETS {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
bullets_shown += 1;
|
|
|
|
|
body.spawn((
|
|
|
|
|
Text::new(format!("\u{2022} {text}")),
|
|
|
|
|
font_bullet.clone(),
|
|
|
|
|
TextColor(TEXT_PRIMARY),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
spawn_modal_actions(card, |actions| {
|
|
|
|
|
spawn_modal_button(
|
|
|
|
|
actions,
|
|
|
|
|
WhatsNewCloseButton,
|
|
|
|
|
"Got it",
|
|
|
|
|
None,
|
|
|
|
|
ButtonVariant::Primary,
|
|
|
|
|
font_res,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
use solitaire_data::Settings;
|
|
|
|
|
|
|
|
|
|
const SAMPLE: &str = "# Changelog\n\n## [Unreleased]\n\n## [0.46.0] — 2026-07-13\n\n\
|
|
|
|
|
### Added\n\n- **Theme-store previews.** The store modal now shows each theme's\n \
|
|
|
|
|
preview image next to its name. (#179)\n- **Hint ghost preview.** You see the move. (#179)\n\n\
|
|
|
|
|
### Internal\n\n- **CI is faster.** Nobody cares in-game. (#176)\n\n\
|
|
|
|
|
### Fixed\n\n- **Touch onboarding copy.** No more left-click. (#178)\n\n\
|
|
|
|
|
## [0.45.0] — 2026-07-13\n\n### Added\n\n- Old stuff.\n";
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parses_top_section_version_and_skips_unreleased() {
|
|
|
|
|
let notes = parse_latest_release(SAMPLE).expect("sample must parse");
|
|
|
|
|
assert_eq!(notes.version, "0.46.0");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn drops_internal_sections_and_older_releases() {
|
|
|
|
|
let notes = parse_latest_release(SAMPLE).expect("sample must parse");
|
|
|
|
|
let headings: Vec<&str> = notes
|
|
|
|
|
.lines
|
|
|
|
|
.iter()
|
|
|
|
|
.filter_map(|l| match l {
|
|
|
|
|
NoteLine::Heading(h) => Some(h.as_str()),
|
|
|
|
|
_ => None,
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
assert_eq!(headings, vec!["Added", "Fixed"]);
|
|
|
|
|
assert!(
|
|
|
|
|
!notes
|
|
|
|
|
.lines
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|l| matches!(l, NoteLine::Bullet(b) if b.contains("CI is faster"))),
|
|
|
|
|
"Internal bullets must not reach the card"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
!notes
|
|
|
|
|
.lines
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|l| matches!(l, NoteLine::Bullet(b) if b.contains("Old stuff"))),
|
|
|
|
|
"older release sections must not bleed in"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn bullets_reduce_to_their_lead_sentence() {
|
|
|
|
|
let notes = parse_latest_release(SAMPLE).expect("sample must parse");
|
|
|
|
|
assert!(
|
|
|
|
|
notes
|
|
|
|
|
.lines
|
|
|
|
|
.contains(&NoteLine::Bullet("Theme-store previews".to_string()))
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
notes
|
|
|
|
|
.lines
|
|
|
|
|
.contains(&NoteLine::Bullet("Hint ghost preview".to_string()))
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn embedded_changelog_parses_to_a_nonempty_version() {
|
|
|
|
|
let version = current_release_version();
|
|
|
|
|
assert!(
|
|
|
|
|
!version.is_empty(),
|
|
|
|
|
"the real CHANGELOG.md must yield a version"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
version.chars().next().is_some_and(|c| c.is_ascii_digit()),
|
|
|
|
|
"version must be bare (no v prefix): {version}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn app_with(settings: Settings) -> App {
|
|
|
|
|
let mut app = App::new();
|
|
|
|
|
app.add_plugins(MinimalPlugins).add_plugins(WhatsNewPlugin);
|
|
|
|
|
app.insert_resource(SettingsResource(settings));
|
|
|
|
|
app.update();
|
|
|
|
|
app
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn card_count(app: &mut App) -> usize {
|
|
|
|
|
app.world_mut()
|
|
|
|
|
.query::<&WhatsNewScreen>()
|
|
|
|
|
.iter(app.world())
|
|
|
|
|
.count()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn upgrade_shows_card_and_stamps_version() {
|
|
|
|
|
let mut app = app_with(Settings {
|
|
|
|
|
first_run_complete: true,
|
|
|
|
|
last_seen_whats_new: "0.1.0".into(),
|
|
|
|
|
..Settings::default()
|
|
|
|
|
});
|
|
|
|
|
app.update();
|
|
|
|
|
|
|
|
|
|
assert_eq!(card_count(&mut app), 1, "an upgrade must show the card");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
app.world()
|
|
|
|
|
.resource::<SettingsResource>()
|
|
|
|
|
.0
|
|
|
|
|
.last_seen_whats_new,
|
|
|
|
|
current_release_version(),
|
|
|
|
|
"the seen version must stamp on spawn"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
app.world().resource::<WhatsNewPending>().0,
|
|
|
|
|
"the launch beat stays held until dismissal"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn seen_version_shows_nothing_and_releases_the_beat() {
|
|
|
|
|
let mut app = app_with(Settings {
|
|
|
|
|
first_run_complete: true,
|
|
|
|
|
last_seen_whats_new: current_release_version(),
|
|
|
|
|
..Settings::default()
|
|
|
|
|
});
|
|
|
|
|
app.update();
|
|
|
|
|
|
|
|
|
|
assert_eq!(card_count(&mut app), 0);
|
|
|
|
|
assert!(
|
|
|
|
|
!app.world().resource::<WhatsNewPending>().0,
|
|
|
|
|
"nothing to show must release the launch beat"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn fresh_install_waits_for_onboarding_and_never_shows() {
|
|
|
|
|
let mut app = app_with(Settings {
|
|
|
|
|
first_run_complete: false,
|
|
|
|
|
..Settings::default()
|
|
|
|
|
});
|
|
|
|
|
app.update();
|
|
|
|
|
assert_eq!(card_count(&mut app), 0);
|
|
|
|
|
assert!(
|
|
|
|
|
app.world().resource::<WhatsNewPending>().0,
|
|
|
|
|
"mid-onboarding the beat stays held"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Onboarding completion stamps the version (mirrored from
|
|
|
|
|
// complete_onboarding) — afterwards the card must stand down.
|
|
|
|
|
{
|
|
|
|
|
let mut settings = app.world_mut().resource_mut::<SettingsResource>();
|
|
|
|
|
settings.0.first_run_complete = true;
|
|
|
|
|
settings.0.last_seen_whats_new = current_release_version();
|
|
|
|
|
}
|
|
|
|
|
app.update();
|
|
|
|
|
assert_eq!(card_count(&mut app), 0, "fresh installs never see the card");
|
|
|
|
|
assert!(!app.world().resource::<WhatsNewPending>().0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn got_it_dismisses_and_releases_the_beat() {
|
|
|
|
|
let mut app = app_with(Settings {
|
|
|
|
|
first_run_complete: true,
|
|
|
|
|
..Settings::default()
|
|
|
|
|
});
|
|
|
|
|
app.update();
|
|
|
|
|
assert_eq!(card_count(&mut app), 1);
|
|
|
|
|
|
|
|
|
|
let button = app
|
|
|
|
|
.world_mut()
|
|
|
|
|
.query_filtered::<Entity, With<WhatsNewCloseButton>>()
|
|
|
|
|
.single(app.world())
|
|
|
|
|
.expect("Got it button must exist");
|
|
|
|
|
app.world_mut()
|
|
|
|
|
.entity_mut(button)
|
|
|
|
|
.insert(Interaction::Pressed);
|
|
|
|
|
app.update();
|
|
|
|
|
app.update();
|
|
|
|
|
|
|
|
|
|
assert_eq!(card_count(&mut app), 0, "Got it must dismiss the card");
|
|
|
|
|
assert!(!app.world().resource::<WhatsNewPending>().0);
|
|
|
|
|
}
|
|
|
|
|
}
|