Add Live Activities as configurable bar items (#30)

* Add Live Activities as configurable bar items

* Fix activity owner monitoring and Morph list interactions

* Keep activity rows stable during updates and tighten their layout

* Use subtle theme tint for activity cards

* Tighten activity title and progress spacing

* Use theme accent for active progress rings

* Fit compact activities to measured titles and badge

* Expose Live Activities display controls in Settings

* Open activity terminals and streamline the Morph header

* Give Morph continuous icons and prioritize workspace navigation

* Show activity progress inside its ring

* Inset activity progress rings and strengthen their stroke

* Fix activity lifecycle, command output handling and list navigation

* Reduce activity update work and harden publisher lifetimes

* Set up Live Activities for the detected user shell during installation

* Document Activities setup, daily use and troubleshooting

* Preserve Morph idle width across empty activity updates

* Fix dimming flicker, hide inactive music tab and consolidate Activities docs

* Fix panel clearance across styles and match previews to theme settings

* Prevent panel blur from repainting neighboring islands

---------

Co-authored-by: Samuel Onoja <[email protected]>
This commit is contained in:
Samuel OnojaandSamuel Onoja authored and GitHub committed 2026-10-12 22:52:25 +01:00
1 parent 7a7df0106c
commit 6b0d903ad0
107 files changed
+9340 -291

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
[package]
name = "ferese-activities"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[dependencies]
serde.workspace = true
+553
View File
@@ -0,0 +1,553 @@
//! Typed activity data and deterministic policy. The host supplies verified owners
//! and suspend-aware milliseconds; this crate performs no I/O or rendering.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
pub type Time = u64;
const BOOST_MS: u64 = 5_000;
pub const MAX_TEXT: usize = 256;
pub const MAX_LEASE_MS: u64 = 300_000;
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Owner {
pub pid: u32,
pub birth: u64,
}
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Id {
pub owner: Owner,
pub local: String,
pub generation: u64,
pub session: u64,
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(tag = "kind", content = "value", rename_all = "kebab-case", deny_unknown_fields)]
pub enum Value {
Indeterminate,
Progress(f32),
Text(String),
Countdown { remaining_ms: u64, sampled_at: Time },
}
impl Value {
pub fn remaining_ms(&self, now: Time) -> Option<u64> {
match self {
Self::Countdown {
remaining_ms,
sampled_at,
} => Some(remaining_ms.saturating_sub(now.saturating_sub(*sampled_at))),
_ => None,
}
}
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(tag = "kind", content = "data", rename_all = "kebab-case", deny_unknown_fields)]
pub enum Template {
Progress,
Timer,
Media { artist: String, playing: bool },
List { rows: Vec<String> },
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Source {
Publisher,
Command,
Media,
Capture,
Download,
Timer,
Notification,
Process,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum State {
Running,
Done,
Failed,
Cancelled,
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Data {
pub source: Source,
pub output: Option<String>,
pub priority: u8,
pub icon: String,
pub title: String,
pub value: Value,
pub template: Template,
}
impl Data {
pub fn validate(&mut self, now: Time) -> Result<(), &'static str> {
text(&self.title)?;
if self.output.as_ref().is_some_and(|output| text(output).is_err()) {
return Err("invalid output name");
}
if self.icon.len() > 64 || !self.icon.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') {
return Err("icon must be a symbolic name");
}
if !matches!(
self.icon.as_str(),
"hammer" | "timer" | "download" | "media" | "microphone" | "camera" | "call" | "notification" | "ferese"
) {
self.icon = "ferese".into();
}
match &mut self.value {
Value::Progress(progress) if !progress.is_finite() || !(0.0..=1.0).contains(progress) => {
return Err("progress must be finite and between zero and one");
}
Value::Text(value) => text(value)?,
Value::Countdown {
remaining_ms,
sampled_at,
} => {
if *remaining_ms > 7 * 24 * 60 * 60 * 1_000 {
return Err("countdown exceeds seven days");
}
*sampled_at = now;
}
_ => {}
}
match &self.template {
Template::Timer if !matches!(self.value, Value::Countdown { .. }) => {
return Err("timer requires a countdown");
}
Template::Progress if matches!(self.value, Value::Countdown { .. }) => {
return Err("countdown requires timer template");
}
Template::Media { artist, .. } => {
text(artist)?;
if !matches!(self.value, Value::Text(_) | Value::Indeterminate | Value::Progress(_)) {
return Err("invalid media value");
}
}
Template::List { rows } => {
if rows.len() > 8 {
return Err("list exceeds eight rows");
}
for row in rows {
text(row)?;
}
if !matches!(self.value, Value::Text(_) | Value::Indeterminate) {
return Err("invalid list value");
}
}
_ => {}
}
Ok(())
}
}
fn text(value: &str) -> Result<(), &'static str> {
if value.len() > MAX_TEXT || value.chars().any(char::is_control) {
Err("text exceeds its budget or contains control characters")
} else {
Ok(())
}
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct Limits {
pub max_visible: usize,
pub show_badge: bool,
pub max_total: usize,
pub owner_ceiling: u8,
pub start_delay_ms: u64,
pub result_flash_ms: u64,
}
impl Default for Limits {
fn default() -> Self {
Self {
max_visible: 2,
show_badge: true,
max_total: 128,
owner_ceiling: 160,
start_delay_ms: 300,
result_flash_ms: 1_500,
}
}
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
pub struct Activity {
pub id: Id,
pub data: Data,
pub state: State,
pub created_at: Time,
pub eligible_at: Time,
pub refreshed_at: Time,
pub lease_deadline: Option<Time>,
pub result_deadline: Option<Time>,
pub result_message: Option<String>,
pub flash_on_fail: bool,
pub result_flash_ms: u64,
/// A presentation group only; every invocation retains its own lifetime.
pub group: Option<String>,
}
impl Activity {
pub fn eligible(&self, now: Time, dnd: bool) -> bool {
now >= self.eligible_at
&& match self.state {
State::Running => self.lease_deadline.is_none_or(|deadline| now < deadline),
State::Done | State::Failed => !dnd && self.result_deadline.is_some_and(|deadline| now < deadline),
State::Cancelled => false,
}
}
pub fn score(&self, now: Time, ceiling: u8) -> u16 {
u16::from(self.data.priority.min(ceiling))
+ if now < self.eligible_at.saturating_add(BOOST_MS) {
30
} else {
0
}
+ match self.state {
State::Failed => 100,
State::Done => 40,
_ => 0,
}
}
}
#[derive(Debug)]
pub struct Store {
pub limits: Limits,
session: u64,
generation: u64,
entries: BTreeMap<Id, Activity>,
}
impl Store {
pub fn new(session: u64) -> Self {
Self {
limits: Limits::default(),
session,
generation: 0,
entries: BTreeMap::new(),
}
}
pub fn entries(&self) -> impl Iterator<Item = &Activity> {
self.entries.values()
}
pub fn resolve(&self, owner: Owner, local: &str) -> Option<&Id> {
self.entries.keys().find(|id| id.owner == owner && id.local == local)
}
#[allow(clippy::too_many_arguments)]
pub fn start(
&mut self,
owner: Owner,
local: String,
mut data: Data,
now: Time,
min_duration_ms: u64,
lease_ms: Option<u64>,
monitored: bool,
flash_on_fail: bool,
group: Option<String>,
) -> Result<Id, &'static str> {
self.advance(now);
text(&local)?;
if local.is_empty() {
return Err("activity ID must not be empty");
}
data.validate(now)?;
validate_lease(lease_ms, monitored)?;
if self.resolve(owner, &local).is_some() {
return Err("duplicate activity ID");
}
if self.entries.len() >= self.limits.max_total {
return Err("session activity quota exceeded");
}
if self.entries.keys().filter(|id| id.owner == owner).count() >= 3 {
return Err("owner activity quota exceeded");
}
self.generation = self.generation.checked_add(1).ok_or("generation exhausted")?;
let id = Id {
owner,
local,
generation: self.generation,
session: self.session,
};
self.entries.insert(
id.clone(),
Activity {
id: id.clone(),
data,
state: State::Running,
created_at: now,
eligible_at: now.saturating_add(self.limits.start_delay_ms.max(min_duration_ms)),
refreshed_at: now,
lease_deadline: lease_ms.map(|ms| now.saturating_add(ms)),
result_deadline: None,
result_message: None,
flash_on_fail,
result_flash_ms: self.limits.result_flash_ms,
group,
},
);
Ok(id)
}
fn entry(&mut self, owner: Owner, id: &Id) -> Result<&mut Activity, &'static str> {
if id.session != self.session {
return Err("stale activity session; register again");
}
if id.owner != owner {
return Err("activity owner rejected");
}
self.entries.get_mut(id).ok_or("stale activity generation")
}
pub fn update(
&mut self,
owner: Owner,
id: &Id,
value: Option<Value>,
lease_ms: Option<u64>,
now: Time,
) -> Result<(), &'static str> {
self.advance(now);
validate_lease(lease_ms, true)?;
let entry = self.entry(owner, id)?;
if entry.state != State::Running {
return Err("activity already completed");
}
if let Some(value) = value {
let mut candidate = entry.data.clone();
candidate.value = value;
candidate.validate(now)?;
entry.data = candidate;
}
if let Some(ms) = lease_ms {
validate_lease(Some(ms), true)?;
entry.lease_deadline = Some(now.saturating_add(ms));
} else if let Some(deadline) = entry.lease_deadline {
entry.lease_deadline = Some(now.saturating_add(deadline.saturating_sub(entry.refreshed_at)));
}
entry.refreshed_at = now;
Ok(())
}
pub fn finish(
&mut self,
owner: Owner,
id: &Id,
state: State,
message: Option<String>,
now: Time,
) -> Result<(), &'static str> {
self.advance(now);
if state == State::Running {
return Err("finish requires a terminal result");
}
if let Some(message) = &message {
text(message)?;
}
let entry = self.entry(owner, id)?;
if entry.state != State::Running {
return if entry.state == state {
Ok(())
} else {
Err("terminal result already accepted")
};
}
if state == State::Cancelled || now < entry.eligible_at || (state == State::Failed && !entry.flash_on_fail) {
self.entries.remove(id);
return Ok(());
}
entry.state = state;
if let Some(message) = message {
entry.result_message = Some(message);
}
entry.result_deadline = Some(now.saturating_add(entry.result_flash_ms));
Ok(())
}
pub fn owner_exit(&mut self, owner: Owner) {
self.entries
.retain(|id, entry| id.owner != owner || entry.state != State::Running);
}
pub fn remove_output(&mut self, output: &str, fallback: Option<&str>) {
for entry in self
.entries
.values_mut()
.filter(|entry| entry.data.output.as_deref() == Some(output))
{
entry.data.output = fallback.map(str::to_owned);
}
}
pub fn attach_output(&mut self, output: &str) -> bool {
let mut changed = false;
for entry in self.entries.values_mut().filter(|entry| entry.data.output.is_none()) {
entry.data.output = Some(output.into());
changed = true;
}
changed
}
pub fn advance(&mut self, now: Time) -> bool {
let before = self.entries.len();
self.entries.retain(|_, entry| match entry.state {
State::Running => entry.lease_deadline.is_none_or(|deadline| now < deadline),
_ => entry.result_deadline.is_some_and(|deadline| now < deadline),
});
before != self.entries.len()
}
pub fn next_deadline(&self, now: Time) -> Option<Time> {
self.entries
.values()
.flat_map(|entry| {
[
Some(entry.eligible_at),
Some(entry.eligible_at.saturating_add(BOOST_MS)),
entry.lease_deadline,
entry.result_deadline,
entry
.result_deadline
.map(|deadline| deadline.saturating_sub(entry.result_flash_ms).saturating_add(400)),
]
})
.flatten()
.filter(|deadline| *deadline > now)
.min()
}
}
fn validate_lease(lease: Option<u64>, monitored: bool) -> Result<(), &'static str> {
match lease {
Some(0) => Err("lease must be positive"),
Some(ms) if ms > MAX_LEASE_MS => Err("lease exceeds five minutes"),
None if !monitored => Err("unmonitored owner requires a lease"),
_ => Ok(()),
}
}
/// Recording/battery occupy a separate host-only class. Notifications always
/// follow every other ordinary source, even when a result boost is active.
pub fn ranked<'a>(
entries: impl IntoIterator<Item = &'a Activity>,
now: Time,
limits: Limits,
output: &str,
dnd: bool,
) -> Vec<&'a Activity> {
let mut entries: Vec<_> = entries
.into_iter()
.filter(|entry| entry.eligible(now, dnd) && entry.data.output.as_deref() == Some(output))
.collect();
entries.sort_by(|a, b| {
(a.data.source == Source::Notification)
.cmp(&(b.data.source == Source::Notification))
.then_with(|| {
b.score(now, limits.owner_ceiling)
.cmp(&a.score(now, limits.owner_ceiling))
})
.then_with(|| a.eligible_at.cmp(&b.eligible_at))
.then_with(|| a.id.cmp(&b.id))
});
entries
}
#[derive(Clone, Debug, Default)]
pub struct Selection {
pinned: Option<Vec<Id>>,
pub visible: Vec<Id>,
pub hidden: usize,
policy: Option<(bool, usize, usize)>,
}
impl Selection {
pub fn enter(&mut self) {
self.pinned = Some(self.visible.clone());
}
pub fn leave(&mut self) {
self.pinned = None;
}
pub fn override_pin(&mut self) {
self.pinned = None;
}
pub fn select(&mut self, ranked: &[&Activity], limits: Limits, dnd: bool, protected_slots: usize) {
let policy = (dnd, protected_slots, limits.max_visible);
if self.policy != Some(policy) {
self.override_pin();
self.policy = Some(policy);
}
let count = limits
.max_visible
.clamp(1, 2)
.saturating_sub(protected_slots)
.min(if dnd { 1 } else { 2 });
let eligible: Vec<_> = ranked
.iter()
.filter(|entry| !dnd || entry.state == State::Running)
.collect();
let mut groups = std::collections::BTreeSet::new();
let ids: Vec<_> = eligible
.iter()
.filter(|entry| {
let Some(group) = &entry.group else {
return true;
};
if entry.state != State::Running
&& eligible.iter().any(|other| {
other.id.owner == entry.id.owner
&& other.group.as_ref() == Some(group)
&& other.state == State::Running
})
{
return false;
}
groups.insert((entry.id.owner, group.clone()))
})
.map(|entry| entry.id.clone())
.collect();
self.visible = match &mut self.pinned {
Some(pinned) => {
pinned.retain(|id| ids.contains(id));
pinned.iter().take(count).cloned().collect()
}
None => ids.iter().take(count).cloned().collect(),
};
self.hidden = if dnd || !limits.show_badge {
0
} else {
eligible.len().saturating_sub(self.visible.len())
};
}
}
/// Focus survives reordered/updated rows by identity; removal chooses the next
/// surviving row at the former position.
pub fn reconcile_focus(focus: &mut Option<Id>, previous: &[Id], current: &[Id]) {
if focus.as_ref().is_some_and(|id| current.contains(id)) {
return;
}
let index = focus
.as_ref()
.and_then(|id| previous.iter().position(|old| old == id))
.unwrap_or(0);
*focus = current.get(index.min(current.len().saturating_sub(1))).cloned();
}
#[cfg(test)]
mod tests;
+323
View File
@@ -0,0 +1,323 @@
use super::*;
fn owner() -> Owner {
Owner { pid: 42, birth: 100 }
}
fn data(priority: u8) -> Data {
Data {
source: Source::Publisher,
output: Some("one".into()),
priority,
icon: "hammer".into(),
title: "Build".into(),
value: Value::Indeterminate,
template: Template::Progress,
}
}
fn start(store: &mut Store, local: &str, now: Time, priority: u8) -> Id {
store
.start(
owner(),
local.into(),
data(priority),
now,
0,
Some(10_000),
true,
true,
None,
)
.unwrap()
}
#[test]
fn badge_visibility_and_visible_limit_reload_while_hovered() {
let mut store = Store::new(1);
for name in ["a", "b", "c"] {
start(&mut store, name, 0, 100);
}
let entries = ranked(store.entries(), 500, store.limits, "one", false);
let mut limits = store.limits;
limits.max_visible = 1;
let mut selection = Selection::default();
selection.select(&entries, limits, false, 0);
assert_eq!(selection.visible.len(), 1);
assert_eq!(selection.hidden, 2);
selection.enter();
limits.show_badge = false;
selection.select(&entries, limits, false, 0);
assert_eq!(selection.visible.len(), 1);
assert_eq!(selection.hidden, 0);
assert_eq!(entries.len(), 3, "hiding the badge must keep the full list");
limits.max_visible = 2;
selection.select(&entries, limits, false, 0);
assert_eq!(selection.visible.len(), 2);
assert_eq!(selection.hidden, 0);
limits.show_badge = true;
selection.select(&entries, limits, false, 0);
assert_eq!(selection.hidden, 1);
}
#[test]
fn boundaries_and_updates_do_not_restart_delay_or_boost() {
let mut store = Store::new(1);
let id = start(&mut store, "a", 10, 255);
assert!(!store.entries().next().unwrap().eligible(309, false));
store
.update(owner(), &id, Some(Value::Progress(0.5)), None, 200)
.unwrap();
let entry = store.entries().next().unwrap();
assert_eq!(entry.eligible_at, 310);
assert!(entry.eligible(310, false));
assert_eq!(entry.score(5_309, 255), 285);
assert_eq!(entry.score(5_310, 255), 255);
assert_eq!(store.next_deadline(310), Some(5_310));
}
#[test]
fn fast_failures_cancel_and_suppressed_failure_never_flash() {
for state in [State::Done, State::Failed, State::Cancelled] {
let mut store = Store::new(1);
let id = start(&mut store, "a", 0, 100);
store.finish(owner(), &id, state, None, 299).unwrap();
assert_eq!(store.entries().count(), 0);
}
let mut store = Store::new(1);
let id = store
.start(owner(), "a".into(), data(100), 0, 0, None, true, false, None)
.unwrap();
store.finish(owner(), &id, State::Failed, None, 300).unwrap();
assert_eq!(store.entries().count(), 0);
}
#[test]
fn result_retry_and_owner_exit_preserve_original_deadline() {
let mut store = Store::new(1);
let id = start(&mut store, "a", 0, 255);
store
.finish(owner(), &id, State::Failed, Some("Failed".into()), 300)
.unwrap();
assert_eq!(store.entries().next().unwrap().score(300, 255), 385);
store.finish(owner(), &id, State::Failed, None, 1_000).unwrap();
store.owner_exit(owner());
assert_eq!(store.entries().next().unwrap().result_deadline, Some(1_800));
assert!(!store.advance(1_799));
assert!(store.advance(1_800));
assert_eq!(store.entries().count(), 0);
}
#[test]
fn leases_refresh_but_abandonment_never_claims_a_result() {
let mut store = Store::new(1);
let id = store
.start(owner(), "a".into(), data(100), 0, 0, Some(500), false, true, None)
.unwrap();
store.update(owner(), &id, None, None, 499).unwrap();
assert_eq!(store.entries().next().unwrap().lease_deadline, Some(999));
store.advance(998);
assert_eq!(store.entries().count(), 1);
store.advance(999);
assert_eq!(store.entries().count(), 0);
start(&mut store, "a", 1_000, 100);
store.owner_exit(owner());
assert_eq!(store.entries().count(), 0);
assert!(
store
.start(owner(), "b".into(), data(0), 0, 0, None, false, true, None)
.is_err()
);
}
#[test]
fn owner_session_generation_and_quotas_are_independent_of_connections() {
let mut store = Store::new(12);
let id = start(&mut store, "a", 0, 100);
let other = Owner { pid: 42, birth: 101 };
assert!(store.update(other, &id, None, None, 0).is_err());
let mut stale = id.clone();
stale.session = 11;
assert!(store.update(owner(), &stale, None, None, 0).is_err());
assert!(
store
.start(owner(), "a".into(), data(100), 0, 0, None, true, true, None)
.is_err()
);
start(&mut store, "b", 0, 0);
start(&mut store, "c", 0, 0);
assert!(
store
.start(owner(), "d".into(), data(100), 0, 0, None, true, true, None)
.is_err()
);
store.finish(owner(), &id, State::Cancelled, None, 0).unwrap();
let replacement = start(&mut store, "a", 0, 100);
assert_ne!(replacement.generation, id.generation);
assert!(store.update(owner(), &id, None, None, 0).is_err());
}
#[test]
fn stable_ties_boost_expiry_notification_priority_and_hover_pinning() {
let mut store = Store::new(1);
let a = start(&mut store, "a", 0, 100);
let b = start(&mut store, "b", 0, 100);
let c = start(&mut store, "c", 5_000, 110);
let mut selection = Selection::default();
selection.select(
&ranked(store.entries(), 300, store.limits, "one", false),
store.limits,
false,
0,
);
assert_eq!(selection.visible, [a.clone(), b.clone()]);
selection.enter();
selection.select(
&ranked(store.entries(), 5_300, store.limits, "one", false),
store.limits,
false,
0,
);
assert_eq!(selection.visible, [a.clone(), b.clone()]);
assert_eq!(selection.hidden, 1);
store.owner_exit(owner());
selection.select(
&ranked(store.entries(), 5_300, store.limits, "one", false),
store.limits,
false,
0,
);
assert!(selection.visible.is_empty());
selection.leave();
let _ = c;
}
#[test]
fn dnd_protected_slots_and_output_routing() {
let mut store = Store::new(1);
let a = start(&mut store, "a", 0, 100);
start(&mut store, "b", 0, 100);
store.finish(owner(), &a, State::Done, None, 300).unwrap();
let mut selection = Selection::default();
selection.select(
&ranked(store.entries(), 300, store.limits, "one", true),
store.limits,
true,
0,
);
assert_eq!(selection.visible.len(), 1);
assert_eq!(selection.hidden, 0);
selection.select(
&ranked(store.entries(), 300, store.limits, "one", false),
store.limits,
false,
2,
);
assert!(selection.visible.is_empty());
assert_eq!(selection.hidden, 2);
store.remove_output("one", Some("two"));
assert!(ranked(store.entries(), 300, store.limits, "one", false).is_empty());
assert_eq!(ranked(store.entries(), 300, store.limits, "two", false).len(), 2);
}
#[test]
fn typed_validation_countdown_clock_and_focus_identity() {
let mut value = data(0);
for progress in [f32::NAN, f32::INFINITY, -0.1, 1.1] {
value.value = Value::Progress(progress);
assert!(value.validate(0).is_err());
}
value.value = Value::Indeterminate;
value.icon = "/tmp/icon.svg".into();
assert!(value.validate(0).is_err());
value.icon = "unknown".into();
value.validate(0).unwrap();
assert_eq!(value.icon, "ferese");
value.template = Template::Timer;
value.value = Value::Countdown {
remaining_ms: 1_000,
sampled_at: u64::MAX,
};
value.validate(100).unwrap();
assert_eq!(value.value.remaining_ms(900), Some(200));
let mut store = Store::new(1);
let a = start(&mut store, "a", 0, 0);
let b = start(&mut store, "b", 0, 0);
let mut focus = Some(b.clone());
reconcile_focus(&mut focus, &[a.clone(), b.clone()], &[b.clone(), a.clone()]);
assert_eq!(focus, Some(b.clone()));
reconcile_focus(&mut focus, &[b, a.clone()], std::slice::from_ref(&a));
assert_eq!(focus, Some(a));
}
#[test]
fn notification_boost_cannot_outrank_ordinary_and_grouped_lifetimes_remain_independent() {
let mut store = Store::new(1);
let a = store
.start(
owner(),
"a".into(),
data(0),
0,
0,
None,
true,
true,
Some("cargo:directory".into()),
)
.unwrap();
let b = store
.start(
owner(),
"b".into(),
data(1),
0,
0,
None,
true,
true,
Some("cargo:directory".into()),
)
.unwrap();
let mut notification = data(255);
notification.source = Source::Notification;
let notice = store
.start(owner(), "notice".into(), notification, 0, 0, None, true, true, None)
.unwrap();
store.finish(owner(), &notice, State::Failed, None, 300).unwrap();
let entries = ranked(store.entries(), 300, store.limits, "one", false);
assert_eq!(entries.last().unwrap().id, notice);
let mut selection = Selection::default();
selection.select(&entries, store.limits, false, 0);
assert_eq!(selection.visible[0], b);
store.finish(owner(), &b, State::Done, None, 300).unwrap();
let entries = ranked(store.entries(), 301, store.limits, "one", false);
selection.select(&entries, store.limits, false, 0);
assert_eq!(selection.visible[0], a);
assert_eq!(
store.entries().find(|entry| entry.id == a).unwrap().state,
State::Running
);
store.remove_output("one", None);
assert!(store.attach_output("two"));
assert_eq!(ranked(store.entries(), 301, store.limits, "two", false).len(), 3);
}
#[test]
fn invalid_updates_are_atomic_and_suspend_aware_eligibility_hides_expired_records() {
let mut store = Store::new(1);
let id = store
.start(owner(), "atomic".into(), data(100), 0, 0, Some(1_000), true, true, None)
.unwrap();
assert!(
store
.update(owner(), &id, Some(Value::Progress(0.8)), Some(0), 300)
.is_err()
);
let entry = store.entries().next().unwrap();
assert_eq!(entry.data.value, Value::Indeterminate);
assert_eq!(entry.refreshed_at, 0);
assert_eq!(entry.lease_deadline, Some(1_000));
assert!(
!entry.eligible(5_000, false),
"a resumed host cannot present an expired lease before its timer callback"
);
}