| f2d2776b Andy McCall ↶ |
1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
use super::projects::{board_columns, rights_of, CardFilter}; |
| 8 |
use super::{Error, Page, Shell}; |
| 9 |
use crate::auth::Viewer; |
| 10 |
use crate::board_stats::{self as bs, Move, DAY_MS, WEEK_MS}; |
| 11 |
use crate::db::{self, projects as pdb}; |
| 12 |
use crate::Shared; |
| 13 |
use askama::Template; |
| 14 |
use axum::extract::{Path, Query, State}; |
| 15 |
use axum::response::{IntoResponse, Response}; |
| 16 |
use serde::Deserialize; |
| 17 |
use std::fmt::Write; |
| 18 |
|
| 19 |
|
| 20 |
|
| 21 |
pub const FLOW_DAYS: usize = 60; |
| 22 |
pub const HISTORY_WEEKS: usize = 12; |
| 23 |
|
| 24 |
const TRIALS: usize = 4000; |
| 25 |
|
| 26 |
pub const TRIALS_SHOWN: usize = TRIALS; |
| 27 |
|
| 28 |
const W: i64 = 640; |
| 29 |
const H: i64 = 170; |
| 30 |
|
| 31 |
fn day_of(ms: i64) -> String { |
| 32 |
let t = time::OffsetDateTime::from_unix_timestamp(ms.div_euclid(1000)).unwrap_or(time::OffsetDateTime::UNIX_EPOCH); |
| 33 |
format!("{:04}-{:02}-{:02}", t.year(), u8::from(t.month()), t.day()) |
| 34 |
} |
| 35 |
|
| 36 |
fn esc(s: &str) -> String { |
| 37 |
s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) |
| 38 |
} |
| 39 |
|
| 40 |
|
| 41 |
|
| 42 |
#[derive(Clone, Debug)] |
| 43 |
pub struct ForecastRow { |
| 44 |
pub scope: String, |
| 45 |
pub remaining: usize, |
| 46 |
pub weeks_of_history: usize, |
| 47 |
pub p50: Option<String>, |
| 48 |
pub p85: Option<String>, |
| 49 |
} |
| 50 |
|
| 51 |
|
| 52 |
pub type Burn = (String, i64, Vec<(usize, usize)>); |
| 53 |
|
| 54 |
|
| 55 |
pub struct Charts { |
| 56 |
pub columns: Vec<String>, |
| 57 |
|
| 58 |
pub flow_first: i64, |
| 59 |
pub flow: Vec<Vec<usize>>, |
| 60 |
|
| 61 |
pub cycle_median_days: Option<f64>, |
| 62 |
pub cycle_p85_days: Option<f64>, |
| 63 |
pub cycle_finished: usize, |
| 64 |
|
| 65 |
|
| 66 |
pub throughput: Vec<usize>, |
| 67 |
pub throughput_from: i64, |
| 68 |
|
| 69 |
pub burn: Option<Burn>, |
| 70 |
pub forecasts: Vec<ForecastRow>, |
| 71 |
|
| 72 |
|
| 73 |
pub timeline: Vec<TimelineRow>, |
| 74 |
} |
| 75 |
|
| 76 |
|
| 77 |
|
| 78 |
#[derive(Clone, Debug)] |
| 79 |
pub struct TimelineRow { |
| 80 |
pub name: String, |
| 81 |
pub milestone: bool, |
| 82 |
pub start: Option<i64>, |
| 83 |
pub end: Option<i64>, |
| 84 |
pub due: Option<i64>, |
| 85 |
pub forecast: Option<(i64, i64)>, |
| 86 |
pub open: bool, |
| 87 |
} |
| 88 |
|
| 89 |
|
| 90 |
|
| 91 |
pub async fn compute( |
| 92 |
app: &Shared, |
| 93 |
account: Option<&db::accounts::Account>, |
| 94 |
project: &pdb::Project, |
| 95 |
q: &str, |
| 96 |
now: i64, |
| 97 |
) -> sqlx::Result<Charts> { |
| 98 |
let (columns, _) = board_columns(app, account, &project.owner, project, false).await?; |
| 99 |
let ids: Vec<i64> = columns.iter().map(|c| c.column.id).collect(); |
| 100 |
let done = pdb::done_column(&columns.iter().map(|c| c.column.clone()).collect::<Vec<_>>()); |
| 101 |
|
| 102 |
let visible: std::collections::HashSet<i64> = { |
| 103 |
let (archived, _) = board_columns(app, account, &project.owner, project, true).await?; |
| 104 |
columns.iter().chain(archived.iter()).flat_map(|c| c.cards.iter().map(|v| v.card.id)).collect() |
| 105 |
}; |
| 106 |
let moves: Vec<Move> = pdb::moves(&app.db, project.id, 0) |
| 107 |
.await? |
| 108 |
.into_iter() |
| 109 |
.filter(|(card, _, _)| visible.contains(card)) |
| 110 |
.map(|(card, column, at)| Move { card, column, at }) |
| 111 |
.collect(); |
| 112 |
let today = now - now.rem_euclid(DAY_MS); |
| 113 |
let flow_first = (today - (FLOW_DAYS as i64 - 1) * DAY_MS).max(project.created_ms - project.created_ms.rem_euclid(DAY_MS)); |
| 114 |
let days = ((today - flow_first) / DAY_MS + 1) as usize; |
| 115 |
let flow = bs::cumulative_flow(&moves, &ids, flow_first, days); |
| 116 |
let (mut cycle_median_days, mut cycle_p85_days, mut cycle_finished) = (None, None, 0); |
| 117 |
let mut throughput = vec![0; HISTORY_WEEKS]; |
| 118 |
if let (Some(first), Some(done)) = (ids.first().copied(), done) { |
| 119 |
let times = bs::cycle_times(&moves, first, done); |
| 120 |
cycle_finished = times.len(); |
| 121 |
let days = |ms: i64| (ms as f64 / DAY_MS as f64 * 10.0).round() / 10.0; |
| 122 |
cycle_median_days = bs::percentile(×, 50).map(days); |
| 123 |
cycle_p85_days = bs::percentile(×, 85).map(days); |
| 124 |
throughput = bs::weekly_throughput(&moves, done, now, HISTORY_WEEKS); |
| 125 |
} |
| 126 |
|
| 127 |
let weeks_old = ((now - project.created_ms) / WEEK_MS).max(1) as usize; |
| 128 |
let history: Vec<usize> = throughput[HISTORY_WEEKS.saturating_sub(weeks_old)..].to_vec(); |
| 129 |
let iterations = pdb::iterations(&app.db, project.id).await?; |
| 130 |
let open = iterations.iter().find(|i| i.closed_ms.is_none()); |
| 131 |
let burn = match (open, done) { |
| 132 |
(Some(it), Some(done)) => { |
| 133 |
let so_far = ((now.min(it.end_ms - 1) - it.start_ms) / DAY_MS + 1).clamp(1, pdb::iteration_days(it)) as usize; |
| 134 |
Some((it.name.clone(), it.start_ms, bs::burn_down(&moves, done, it.start_ms, so_far))) |
| 135 |
} |
| 136 |
_ => None, |
| 137 |
}; |
| 138 |
|
| 139 |
|
| 140 |
let filter = CardFilter::parse(q); |
| 141 |
let open_cards: Vec<&super::projects::CardView> = columns |
| 142 |
.iter() |
| 143 |
.filter(|c| Some(c.column.id) != done) |
| 144 |
.flat_map(|c| c.cards.iter()) |
| 145 |
.filter(|c| !matches!(c.state.as_str(), "closed" | "landed")) |
| 146 |
.collect(); |
| 147 |
let seed = (project.id as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ (today / DAY_MS) as u64; |
| 148 |
let row = |scope: String, remaining: usize| { |
| 149 |
let f = bs::forecast(&history, remaining, TRIALS, seed); |
| 150 |
let date = |w: Option<usize>| w.map(|w| day_of(now + w as i64 * WEEK_MS)); |
| 151 |
ForecastRow { scope, remaining, weeks_of_history: f.weeks_of_history, p50: date(f.p50_weeks), p85: date(f.p85_weeks) } |
| 152 |
}; |
| 153 |
let mut forecasts = Vec::new(); |
| 154 |
let mut timeline: Vec<TimelineRow> = iterations |
| 155 |
.iter() |
| 156 |
.take(6) |
| 157 |
.rev() |
| 158 |
.map(|it| TimelineRow { |
| 159 |
name: it.name.clone(), |
| 160 |
milestone: false, |
| 161 |
start: Some(it.start_ms), |
| 162 |
end: Some(it.end_ms), |
| 163 |
due: None, |
| 164 |
forecast: None, |
| 165 |
open: it.closed_ms.is_none(), |
| 166 |
}) |
| 167 |
.collect(); |
| 168 |
if !q.trim().is_empty() { |
| 169 |
forecasts.push(row(format!("Cards matching {}", q.trim()), open_cards.iter().filter(|c| filter.keeps(c, now)).count())); |
| 170 |
} |
| 171 |
let scope = match open { |
| 172 |
Some(it) => format!("{} (open cards on the board)", it.name), |
| 173 |
None => "Open cards on the board".to_string(), |
| 174 |
}; |
| 175 |
forecasts.push(row(scope, open_cards.len())); |
| 176 |
let mut milestones: Vec<String> = open_cards.iter().map(|c| c.milestone.clone()).filter(|m| !m.is_empty()).collect(); |
| 177 |
milestones.sort(); |
| 178 |
milestones.dedup(); |
| 179 |
|
| 180 |
let mut due: std::collections::HashMap<String, i64> = Default::default(); |
| 181 |
let mut repos: Vec<i64> = open_cards.iter().filter(|c| !c.milestone.is_empty()).filter_map(|c| c.card.repo_id).collect(); |
| 182 |
repos.sort(); |
| 183 |
repos.dedup(); |
| 184 |
for rid in repos { |
| 185 |
for ms in db::issues::milestones(&app.db, rid).await? { |
| 186 |
if let Some(d) = ms.due_ms { |
| 187 |
due.entry(ms.title).and_modify(|e| *e = (*e).min(d)).or_insert(d); |
| 188 |
} |
| 189 |
} |
| 190 |
} |
| 191 |
for m in milestones { |
| 192 |
let n = open_cards.iter().filter(|c| c.milestone == m).count(); |
| 193 |
let f = bs::forecast(&history, n, TRIALS, seed); |
| 194 |
let at = |w: Option<usize>| w.map(|w| now + w as i64 * WEEK_MS); |
| 195 |
timeline.push(TimelineRow { |
| 196 |
name: m.clone(), |
| 197 |
milestone: true, |
| 198 |
start: None, |
| 199 |
end: None, |
| 200 |
due: due.get(&m).copied(), |
| 201 |
forecast: at(f.p50_weeks).zip(at(f.p85_weeks)), |
| 202 |
open: true, |
| 203 |
}); |
| 204 |
forecasts.push(row(format!("Milestone {m}"), n)); |
| 205 |
} |
| 206 |
Ok(Charts { |
| 207 |
columns: columns.iter().map(|c| c.column.name.clone()).collect(), |
| 208 |
flow_first, |
| 209 |
flow, |
| 210 |
cycle_median_days, |
| 211 |
cycle_p85_days, |
| 212 |
cycle_finished, |
| 213 |
throughput, |
| 214 |
throughput_from: now - HISTORY_WEEKS as i64 * WEEK_MS, |
| 215 |
burn, |
| 216 |
forecasts, |
| 217 |
timeline, |
| 218 |
}) |
| 219 |
} |
| 220 |
|
| 221 |
|
| 222 |
|
| 223 |
pub fn timeline_svg(rows: &[TimelineRow], now: i64) -> String { |
| 224 |
let times: Vec<i64> = rows |
| 225 |
.iter() |
| 226 |
.flat_map(|r| [r.start, r.end, r.due, r.forecast.map(|f| f.0), r.forecast.map(|f| f.1)]) |
| 227 |
.flatten() |
| 228 |
.chain([now - 14 * DAY_MS, now + 14 * DAY_MS]) |
| 229 |
.collect(); |
| 230 |
let (lo, hi) = (*times.iter().min().unwrap_or(&now), *times.iter().max().unwrap_or(&now)); |
| 231 |
let span = (hi - lo).max(DAY_MS) as f64; |
| 232 |
const LABEL: f64 = 150.0; |
| 233 |
let x = |t: i64| LABEL + (t - lo) as f64 / span * (W as f64 - LABEL - 4.0); |
| 234 |
let row_h = 22.0; |
| 235 |
let h = rows.len() as f64 * row_h + 22.0; |
| 236 |
let mut out = format!( |
| 237 |
r#"<svg class="chart timeline" viewBox="0 0 {W} {h:.0}" role="img" aria-label="Timeline: {} iterations and milestones from {} to {}">"#, |
| 238 |
rows.len(), |
| 239 |
day_of(lo), |
| 240 |
day_of(hi) |
| 241 |
); |
| 242 |
for (i, r) in rows.iter().enumerate() { |
| 243 |
let y = i as f64 * row_h + 4.0; |
| 244 |
let _ = write!(out, r#"<text class="axis" x="0" y="{:.1}">{}</text>"#, y + 13.0, esc(&r.name.chars().take(22).collect::<String>())); |
| 245 |
if let (Some(a), Some(b)) = (r.start, r.end) { |
| 246 |
let class = if r.open { "s-main" } else { "s-mute" }; |
| 247 |
let _ = write!( |
| 248 |
out, |
| 249 |
r#"<rect class="bar {class}" x="{:.1}" y="{:.1}" width="{:.1}" height="12"><title>{}: {} to {}</title></rect>"#, |
| 250 |
x(a), |
| 251 |
y + 3.0, |
| 252 |
(x(b) - x(a)).max(2.0), |
| 253 |
esc(&r.name), |
| 254 |
day_of(a), |
| 255 |
day_of(b - 1) |
| 256 |
); |
| 257 |
} |
| 258 |
if let Some((a, b)) = r.forecast { |
| 259 |
let _ = write!( |
| 260 |
out, |
| 261 |
r#"<rect class="bar s-warn forecast" x="{:.1}" y="{:.1}" width="{:.1}" height="12"><title>{}: done 50% by {}, 85% by {}</title></rect>"#, |
| 262 |
x(a), |
| 263 |
y + 3.0, |
| 264 |
(x(b) - x(a)).max(3.0), |
| 265 |
esc(&r.name), |
| 266 |
day_of(a), |
| 267 |
day_of(b) |
| 268 |
); |
| 269 |
} |
| 270 |
if let Some(d) = r.due { |
| 271 |
let _ = write!( |
| 272 |
out, |
| 273 |
r#"<path class="line s-bad" d="M{0:.1},{1:.1} l5,7 l-5,7 l-5,-7 z"><title>{2} due {3}</title></path>"#, |
| 274 |
x(d), |
| 275 |
y + 2.0, |
| 276 |
esc(&r.name), |
| 277 |
day_of(d) |
| 278 |
); |
| 279 |
} |
| 280 |
} |
| 281 |
let _ = write!(out, r#"<line class="grid today" x1="{0:.1}" x2="{0:.1}" y1="0" y2="{1:.0}"/>"#, x(now), h - 18.0); |
| 282 |
let _ = write!(out, r#"<text class="axis" x="{LABEL}" y="{:.0}">{}</text>"#, h - 4.0, day_of(lo)); |
| 283 |
let _ = write!(out, r#"<text class="axis" x="{:.1}" y="{:.0}" text-anchor="middle">today</text>"#, x(now), h - 4.0); |
| 284 |
let _ = write!(out, r#"<text class="axis" x="{W}" y="{:.0}" text-anchor="end">{}</text></svg>"#, h - 4.0, day_of(hi)); |
| 285 |
out |
| 286 |
} |
| 287 |
|
| 288 |
|
| 289 |
fn flow_svg(c: &Charts) -> String { |
| 290 |
let bands = bs::flow_bands(&c.flow, W, H); |
| 291 |
let mut out = format!( |
| 292 |
r#"<svg class="chart flow" viewBox="0 0 {W} {}" role="img" aria-label="Cumulative flow: cards per column each day for {} days">"#, |
| 293 |
H + 18, |
| 294 |
c.flow.len() |
| 295 |
); |
| 296 |
for (k, pts) in bands.iter().enumerate() { |
| 297 |
let class = if k + 1 == bands.len() { 5 } else { k % 5 }; |
| 298 |
let _ = write!(out, r#"<polygon class="band cf-{class}" points="{pts}"><title>{}</title></polygon>"#, esc(&c.columns[k])); |
| 299 |
} |
| 300 |
let _ = write!(out, r#"<text class="axis" x="0" y="{}">{}</text>"#, H + 14, day_of(c.flow_first)); |
| 301 |
let _ = write!(out, r#"<text class="axis" x="{W}" y="{}" text-anchor="end">today</text></svg>"#, H + 14); |
| 302 |
out |
| 303 |
} |
| 304 |
|
| 305 |
|
| 306 |
fn bars_svg(values: &[usize], label: &str) -> String { |
| 307 |
let max = values.iter().copied().max().unwrap_or(0).max(1); |
| 308 |
let step = W as f64 / values.len().max(1) as f64; |
| 309 |
let mut out = format!(r#"<svg class="chart" viewBox="0 0 {W} {}" role="img" aria-label="{}">"#, H + 18, esc(label)); |
| 310 |
for (i, v) in values.iter().enumerate() { |
| 311 |
let h = (*v as f64 / max as f64) * H as f64; |
| 312 |
let _ = write!( |
| 313 |
out, |
| 314 |
r#"<rect class="bar s-ok" x="{:.1}" y="{:.1}" width="{:.1}" height="{h:.1}"><title>{v} done</title></rect>"#, |
| 315 |
i as f64 * step + step * 0.15, |
| 316 |
H as f64 - h, |
| 317 |
step * 0.7 |
| 318 |
); |
| 319 |
} |
| 320 |
let _ = write!(out, r#"<text class="axis" x="0" y="{}">{} weeks ago</text>"#, H + 14, values.len()); |
| 321 |
let _ = write!(out, r#"<text class="axis" x="{W}" y="{}" text-anchor="end">this week</text></svg>"#, H + 14); |
| 322 |
out |
| 323 |
} |
| 324 |
|
| 325 |
|
| 326 |
fn burn_svg(days: &[(usize, usize)]) -> String { |
| 327 |
let max = days.iter().map(|(_, s)| *s).max().unwrap_or(0).max(1); |
| 328 |
let left: Vec<usize> = days.iter().map(|(l, _)| *l).collect(); |
| 329 |
let scope: Vec<usize> = days.iter().map(|(_, s)| *s).collect(); |
| 330 |
format!( |
| 331 |
r#"<svg class="chart" viewBox="0 0 {W} {H}" role="img" aria-label="Burn-down: {} cards left of {} after {} days"><polyline class="line s-mute" points="{}"><title>scope</title></polyline><polyline class="line s-main" points="{}"><title>left</title></polyline></svg>"#, |
| 332 |
left.last().unwrap_or(&0), |
| 333 |
scope.last().unwrap_or(&0), |
| 334 |
days.len(), |
| 335 |
bs::polyline(&scope, max, W, H), |
| 336 |
bs::polyline(&left, max, W, H) |
| 337 |
) |
| 338 |
} |
| 339 |
|
| 340 |
#[derive(Template)] |
| 341 |
#[template(path = "project_charts.html")] |
| 342 |
pub struct ChartsPage { |
| 343 |
shell: Shell, |
| 344 |
project: pdb::Project, |
| 345 |
views: Vec<pdb::View>, |
| 346 |
c: Charts, |
| 347 |
flow_svg: String, |
| 348 |
throughput_svg: String, |
| 349 |
burn_svg: String, |
| 350 |
timeline_svg: String, |
| 351 |
|
| 352 |
flow_table: Vec<(String, Vec<usize>)>, |
| 353 |
week_table: Vec<(String, usize)>, |
| 354 |
q: String, |
| 355 |
} |
| 356 |
|
| 357 |
#[derive(Deserialize, Default)] |
| 358 |
pub struct ChartsQuery { |
| 359 |
#[serde(default)] |
| 360 |
q: Option<String>, |
| 361 |
} |
| 362 |
|
| 363 |
pub async fn show( |
| 364 |
State(app): State<Shared>, |
| 365 |
viewer: Viewer, |
| 366 |
Path((owner, number)): Path<(String, i64)>, |
| 367 |
Query(query): Query<ChartsQuery>, |
| 368 |
) -> Result<Response, Error> { |
| 369 |
let shell = Shell::new(&app, &format!("/projects/{owner}/{number}/charts"), &viewer); |
| 370 |
let e = |e| Error::from_db(shell.clone(), e); |
| 371 |
let r = rights_of(&app, viewer.account(), &owner).await.map_err(e)?.ok_or_else(|| Error::not_found(shell.clone()))?; |
| 372 |
let project = pdb::get(&app.db, &owner, number) |
| 373 |
.await |
| 374 |
.map_err(e)? |
| 375 |
.filter(|p| p.public || r.member) |
| 376 |
.ok_or_else(|| Error::not_found(shell.clone()))?; |
| 377 |
let now = db::now_ms(); |
| 378 |
let q = query.q.unwrap_or_default(); |
| 379 |
let c = compute(&app, viewer.account(), &project, &q, now).await.map_err(e)?; |
| 380 |
let flow_table = c |
| 381 |
.flow |
| 382 |
.iter() |
| 383 |
.enumerate() |
| 384 |
.skip(c.flow.len().saturating_sub(14)) |
| 385 |
.map(|(d, row)| (day_of(c.flow_first + d as i64 * DAY_MS), row.clone())) |
| 386 |
.collect(); |
| 387 |
let week_table = c.throughput.iter().enumerate().map(|(i, n)| (day_of(c.throughput_from + i as i64 * WEEK_MS), *n)).collect(); |
| 388 |
let page = ChartsPage { |
| 389 |
flow_svg: flow_svg(&c), |
| 390 |
throughput_svg: bars_svg(&c.throughput, "Throughput: cards done each week"), |
| 391 |
burn_svg: c.burn.as_ref().map(|(_, _, d)| burn_svg(d)).unwrap_or_default(), |
| 392 |
timeline_svg: if c.timeline.is_empty() { String::new() } else { timeline_svg(&c.timeline, now) }, |
| 393 |
views: pdb::views(&app.db, project.id).await.map_err(e)?, |
| 394 |
shell, |
| 395 |
project, |
| 396 |
c, |
| 397 |
flow_table, |
| 398 |
week_table, |
| 399 |
q, |
| 400 |
}; |
| 401 |
Ok(Page(page).into_response()) |
| 402 |
} |
| 403 |
|
| 404 |
#[cfg(test)] |
| 405 |
mod tests { |
| 406 |
use super::*; |
| 407 |
|
| 408 |
#[test] |
| 409 |
fn the_timeline_draws_iterations_milestones_and_forecasts() { |
| 410 |
let now = 1_000 * DAY_MS; |
| 411 |
let rows = vec![ |
| 412 |
TimelineRow { |
| 413 |
name: "Sprint 1".into(), |
| 414 |
milestone: false, |
| 415 |
start: Some(now - 20 * DAY_MS), |
| 416 |
end: Some(now - 6 * DAY_MS), |
| 417 |
due: None, |
| 418 |
forecast: None, |
| 419 |
open: false, |
| 420 |
}, |
| 421 |
TimelineRow { |
| 422 |
name: "Sprint 2".into(), |
| 423 |
milestone: false, |
| 424 |
start: Some(now - 6 * DAY_MS), |
| 425 |
end: Some(now + 8 * DAY_MS), |
| 426 |
due: None, |
| 427 |
forecast: None, |
| 428 |
open: true, |
| 429 |
}, |
| 430 |
TimelineRow { |
| 431 |
name: "Release <1>".into(), |
| 432 |
milestone: true, |
| 433 |
start: None, |
| 434 |
end: None, |
| 435 |
due: Some(now + 10 * DAY_MS), |
| 436 |
forecast: Some((now + 7 * DAY_MS, now + 21 * DAY_MS)), |
| 437 |
open: true, |
| 438 |
}, |
| 439 |
]; |
| 440 |
let svg = timeline_svg(&rows, now); |
| 441 |
assert!(svg.starts_with("<svg class=\"chart timeline\"") && svg.ends_with("</svg>")); |
| 442 |
assert_eq!(svg.matches("class=\"bar s-mute\"").count(), 1, "a past iteration"); |
| 443 |
assert_eq!(svg.matches("class=\"bar s-main\"").count(), 1, "the open one"); |
| 444 |
assert!(svg.contains("Release <1>: done 50% by") && svg.contains("class=\"line s-bad\""), "{svg}"); |
| 445 |
assert!(svg.contains("aria-label=\"Timeline: 3 iterations and milestones"), "named for a screen reader"); |
| 446 |
assert!(!svg.contains("style="), "coloured by the theme's classes only"); |
| 447 |
} |
| 448 |
} |