mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-12 00:47:03 -06:00
Merge pull request #3448 from arnav-hiwarkar/fix/3365-aep-loss-report
.aep import: every loss detail in the prompt; no false shape loss; Orientation expressions (#3365, #3087)
This commit is contained in:
@@ -231,15 +231,21 @@ pub(crate) fn import_transform(
|
||||
}
|
||||
import_expression(&node, prop, col);
|
||||
}
|
||||
if let Some(PropNode::Other(_)) = group.get("ADBE Orientation") {
|
||||
if let Some(PropNode::Other(otst)) = group.get("ADBE Orientation") {
|
||||
match (layer.orientation(), target.get_mut("orientation")) {
|
||||
(Some(o), Some(prop)) if !o.animated && !o.has_expression && sane(&Value::Vec3(o.degrees)) => prop.value = Value::Vec3(o.degrees),
|
||||
(Some(o), Some(prop)) if o.animated || sane(&Value::Vec3(o.degrees)) => {
|
||||
if o.animated {
|
||||
import_orientation_keys(layer, layer_name, tm, prop, col);
|
||||
} else {
|
||||
prop.value = Value::Vec3(o.degrees);
|
||||
}
|
||||
// The expression is stored in the `otst`'s `tdbs` as on any property (#3087).
|
||||
if let Some(node) = otst.child_list(b"tdbs").and_then(AepProp::from_chunk) {
|
||||
import_expression(&node, prop, col);
|
||||
}
|
||||
}
|
||||
// A layer without an orientation (an ambient light) loses nothing with the default.
|
||||
(Some(o), None) if !o.animated && !o.has_expression && o.degrees == [0.0; 3] => {}
|
||||
(Some(o), Some(prop)) if o.animated && !o.has_expression => import_orientation_keys(layer, layer_name, tm, prop, col),
|
||||
(Some(o), Some(_)) if o.has_expression => {
|
||||
col.lose(LossCategory::TransformProperties, format!("{layer_name}: ADBE Orientation expression (not decoded)"));
|
||||
}
|
||||
_ => col.lose(LossCategory::TransformProperties, format!("{layer_name}: ADBE Orientation")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,8 +98,12 @@ pub struct Loss {
|
||||
pub category: LossCategory,
|
||||
/// How many occurrences (exact).
|
||||
pub count: usize,
|
||||
/// Up to [`MAX_DETAILS`] example lines.
|
||||
/// Example lines: up to [`MAX_DETAILS`], and for effects with no counterpart one more line per
|
||||
/// effect (with its number of instances).
|
||||
pub details: Vec<String>,
|
||||
/// How many of the `count` occurrences no line of `details` describes.
|
||||
#[serde(default)]
|
||||
pub unlisted: usize,
|
||||
}
|
||||
|
||||
/// What arrived in the project.
|
||||
@@ -281,7 +285,7 @@ const MAX_UNMAPPED_EFFECTS: usize = 100;
|
||||
impl Collector {
|
||||
/// Records one loss.
|
||||
pub fn lose(&mut self, category: LossCategory, detail: impl Into<String>) {
|
||||
let loss = self.losses.entry(category).or_insert_with(|| Loss { category, count: 0, details: Vec::new() });
|
||||
let loss = self.losses.entry(category).or_insert_with(|| Loss { category, count: 0, details: Vec::new(), unlisted: 0 });
|
||||
loss.count = loss.count.saturating_add(1);
|
||||
if loss.details.len() < MAX_DETAILS {
|
||||
loss.details.push(detail.into());
|
||||
@@ -292,7 +296,8 @@ impl Collector {
|
||||
/// made in [`Collector::finish`], one per effect with the number of instances, so that many
|
||||
/// other losses cannot hide which effects were dropped.
|
||||
pub fn lose_unmapped_effect(&mut self, match_name: &str, detail: String) {
|
||||
let loss = self.losses.entry(LossCategory::Effects).or_insert_with(|| Loss { category: LossCategory::Effects, count: 0, details: Vec::new() });
|
||||
let loss =
|
||||
self.losses.entry(LossCategory::Effects).or_insert_with(|| Loss { category: LossCategory::Effects, count: 0, details: Vec::new(), unlisted: 0 });
|
||||
loss.count = loss.count.saturating_add(1);
|
||||
if let Some(seen) = self.unmapped_effects.get_mut(match_name) {
|
||||
seen.1 = seen.1.saturating_add(1);
|
||||
@@ -303,12 +308,22 @@ impl Collector {
|
||||
}
|
||||
|
||||
pub fn finish(mut self) -> ImportReport {
|
||||
// Every line describes one occurrence, except an unmapped effect's, which describes all
|
||||
// its instances.
|
||||
for loss in self.losses.values_mut() {
|
||||
loss.unlisted = loss.count.saturating_sub(loss.details.len());
|
||||
}
|
||||
if let Some(loss) = self.losses.get_mut(&LossCategory::Effects) {
|
||||
let named = std::mem::take(&mut self.unmapped_effects).into_values().map(|(first, n)| match n {
|
||||
// The most used effects first.
|
||||
let mut named: Vec<(String, usize)> = std::mem::take(&mut self.unmapped_effects).into_values().collect();
|
||||
named.sort_by_key(|n| std::cmp::Reverse(n.1));
|
||||
let described = named.iter().fold(0usize, |sum, (_, n)| sum.saturating_add(*n));
|
||||
loss.unlisted = loss.unlisted.saturating_sub(described);
|
||||
let lines = named.into_iter().map(|(first, n)| match n {
|
||||
1 => first,
|
||||
_ => format!("{first}, and {} more like it", n - 1),
|
||||
});
|
||||
loss.details.splice(0..0, named);
|
||||
loss.details.splice(0..0, lines);
|
||||
}
|
||||
ImportReport {
|
||||
imported: self.imported,
|
||||
@@ -355,6 +370,25 @@ mod tests {
|
||||
// One instance stays the single line it was.
|
||||
assert_eq!(named("(Pseudo/)"), ["C: 'D' has the effect 'Dropdown' (Pseudo/)"]);
|
||||
assert!(effects.details.len() <= MAX_DETAILS + 2, "the examples stay bounded");
|
||||
// 20 parameter lines, the cylinders' line and the dropdown's describe 46; 10 parameters
|
||||
// have no line.
|
||||
assert_eq!(effects.unlisted, 10);
|
||||
// The most used effect comes first.
|
||||
assert!(effects.details[0].contains("(CC Cylinder)"), "{:?}", effects.details);
|
||||
}
|
||||
|
||||
/// #3365: each category says how many of its occurrences have no example line.
|
||||
#[test]
|
||||
fn a_loss_counts_the_occurrences_without_a_line() {
|
||||
let mut col = Collector::default();
|
||||
for i in 0..27 {
|
||||
col.lose(LossCategory::Masks, format!("mask {i}"));
|
||||
}
|
||||
col.lose(LossCategory::ShapeLayers, "one");
|
||||
let report = col.finish();
|
||||
let unlisted = |c| report.losses.iter().find(|l| l.category == c).map(|l| (l.details.len(), l.unlisted));
|
||||
assert_eq!(unlisted(LossCategory::Masks), Some((MAX_DETAILS, 7)));
|
||||
assert_eq!(unlisted(LossCategory::ShapeLayers), Some((1, 0)));
|
||||
}
|
||||
|
||||
/// A file with more than [`MAX_UNMAPPED_EFFECTS`] different unmapped effects names that many
|
||||
@@ -369,5 +403,6 @@ mod tests {
|
||||
let effects = report.losses.iter().find(|l| l.category == LossCategory::Effects).unwrap();
|
||||
assert_eq!(effects.count, MAX_UNMAPPED_EFFECTS + 1, "every instance is counted");
|
||||
assert_eq!(effects.details.len(), MAX_UNMAPPED_EFFECTS, "{} lines", effects.details.len());
|
||||
assert_eq!(effects.unlisted, 1, "the effect past the limit has no line");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,18 +457,22 @@ fn shift_copy_offset(p: &mut effectcraft_project::Property, cx: &mut Cx<'_, '_>)
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports a property whose only sampled value is the default 1.
|
||||
/// Reports a property whose only sampled value is the default 1. A record in the default state
|
||||
/// holds After Effects' default (docs/aep-format.md 3.4, state), so it holds nothing to lose.
|
||||
fn not_default(g: &AepGroup<'_, '_>, match_name: &str, what: &str, cx: &mut Cx<'_, '_>) {
|
||||
if let Some(PropNode::Property(p)) = g.get(match_name)
|
||||
&& stored(&p)
|
||||
&& (p.is_animated() || p.expression().is_some() || p.static_value().and_then(|v| v.first().copied()).is_some_and(|v| v != 1.0))
|
||||
{
|
||||
cx.col.lose(LossCategory::ShapeLayers, format!("{}: {what} not imported", cx.what));
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports a property whose only sampled value is 0.
|
||||
/// Reports a property whose only sampled value is 0 (a record in the default state, as above,
|
||||
/// holds nothing to lose).
|
||||
fn not_zero(g: &AepGroup<'_, '_>, match_name: &str, what: &str, cx: &mut Cx<'_, '_>) {
|
||||
if let Some(PropNode::Property(p)) = g.get(match_name)
|
||||
&& stored(&p)
|
||||
&& (p.is_animated() || p.expression().is_some() || p.static_value().and_then(|v| v.first().copied()).is_some_and(|v| v != 0.0))
|
||||
{
|
||||
cx.col.lose(LossCategory::ShapeLayers, format!("{}: {what} not imported", cx.what));
|
||||
@@ -490,3 +494,41 @@ fn unknown(g: &AepGroup<'_, '_>, known: &[&str], what: &str, cx: &mut Cx<'_, '_>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Chunk;
|
||||
|
||||
/// A group holding `match_name` as a one-component property in state `tdsb` with value `v`.
|
||||
fn group_with(match_name: &str, tdsb: u32, v: f64) -> Chunk<'static> {
|
||||
let mut name = match_name.as_bytes().to_vec();
|
||||
name.resize(40, 0);
|
||||
let mut tdb4 = vec![0u8; 124];
|
||||
tdb4[3] = 1;
|
||||
let tdbs = Chunk::list(
|
||||
*b"tdbs",
|
||||
vec![Chunk::data(*b"tdsb", tdsb.to_be_bytes().to_vec()), Chunk::data(*b"tdb4", tdb4), Chunk::data(*b"cdat", v.to_be_bytes().to_vec())],
|
||||
);
|
||||
Chunk::list(*b"tdgp", vec![Chunk::data(*b"tdmn", name), tdbs])
|
||||
}
|
||||
|
||||
fn reported(match_name: &str, tdsb: u32, v: f64, check: fn(&AepGroup<'_, '_>, &str, &str, &mut Cx<'_, '_>)) -> usize {
|
||||
let chunk = group_with(match_name, tdsb, v);
|
||||
let g = AepGroup::from_chunk(&chunk).unwrap();
|
||||
let tm = TimeMap { timescale: 30, frame_duration: 1, rate: crate::import::time::frame_rate(30, 1).unwrap() };
|
||||
let (mut next, mut col) = (1, Collector::default());
|
||||
check(&g, match_name, "it", &mut Cx { what: "L", tm: &tm, ids: &mut Ids(&mut next), col: &mut col });
|
||||
col.finish().losses.iter().map(|l| l.count).sum()
|
||||
}
|
||||
|
||||
/// #3087: a guide rectangle's Shape Direction recorded in the default state (`tdsb` 3, holding
|
||||
/// 2) is After Effects' default and is no loss; written (`tdsb` 1) with 2, it is one.
|
||||
#[test]
|
||||
fn a_default_state_record_is_no_loss() {
|
||||
assert_eq!(reported("ADBE Vector Shape Direction", 3, 2.0, not_default), 0);
|
||||
assert_eq!(reported("ADBE Vector Shape Direction", 1, 2.0, not_default), 1);
|
||||
assert_eq!(reported("ADBE Vector Grad HiLite Length", 3, 5.0, not_zero), 0);
|
||||
assert_eq!(reported("ADBE Vector Grad HiLite Length", 1, 5.0, not_zero), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -843,6 +843,48 @@ fn a_default_state_colour_record_holds_the_value() {
|
||||
assert_ne!(colour(&default_state), colour(&unchanged), "the recorded colour, not the default");
|
||||
}
|
||||
|
||||
/// #3087: an expression on Orientation arrives with the stored value, as on any property; it was
|
||||
/// dropped together with the value and reported as "not decoded".
|
||||
#[test]
|
||||
fn an_orientation_expression_is_imported_with_its_value() {
|
||||
let bytes = mutate(&mut |c| {
|
||||
if !c.is_list(b"otst") {
|
||||
return;
|
||||
}
|
||||
for child in c.children_mut() {
|
||||
if child.is_list(b"tdbs") {
|
||||
let Body::List { children, .. } = &mut child.body else { continue };
|
||||
for k in children.iter_mut() {
|
||||
if k.is(b"tdsb") {
|
||||
set_data(k, 0, &1u32.to_be_bytes());
|
||||
} else if k.is(b"tdb4") {
|
||||
set_data(k, 120, &[1]);
|
||||
}
|
||||
}
|
||||
children.push(Chunk::data(*b"Utf8", b"[0, time * 90, 0]".to_vec()));
|
||||
} else if child.is_list(b"otky") {
|
||||
for k in child.children_mut() {
|
||||
let degrees: Vec<u8> = [10.0f64, 20.0, 30.0].iter().flat_map(|d| d.to_be_bytes()).collect();
|
||||
set_data(k, 0, °rees);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let (p, r) = run(&bytes, true);
|
||||
assert_eq!(losses(&r.unwrap(), LossCategory::TransformProperties), Vec::<String>::new());
|
||||
let mut seen = 0;
|
||||
for item in p.items.values() {
|
||||
let ItemKind::Comp(c) = &item.kind else { continue };
|
||||
for layer in &c.layers {
|
||||
let Some(o) = layer.transform().and_then(|t| t.prop("orientation")).filter(|o| o.expr.is_some()) else { continue };
|
||||
assert_eq!(o.value, effectcraft_keyframe::Value::Vec3([10.0, 20.0, 30.0]), "{}", layer.name);
|
||||
assert_eq!(o.expr.as_ref().map(|e| (e.text.as_str(), e.enabled)), Some(("[0, time * 90, 0]", true)), "{}", layer.name);
|
||||
seen += 1;
|
||||
}
|
||||
}
|
||||
assert!(seen > 0);
|
||||
}
|
||||
|
||||
/// `SYNTHETIC` with Levels' Channel and Clip to Output Black / White popups' item names (`pdnm`)
|
||||
/// rewritten to the French labels a French-language After Effects stores there, keeping the same
|
||||
/// item order so the index each popup is set to is unaffected.
|
||||
|
||||
@@ -245,6 +245,8 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("Import Adobe After Effects Project", "Adobe After Effectsプロジェクトを読み込み"),
|
||||
("Some parts of this project cannot be imported exactly (left out or changed):", "このプロジェクトの一部は正確に読み込めません(省略または変更されます):"),
|
||||
("Import Anyway", "このまま読み込み"),
|
||||
("{}: {} left out or changed", "{}:{} 件が省略または変更"),
|
||||
("… and {} more", "…ほか {} 件"),
|
||||
("Casts Shadows", "シャドウを落とす"),
|
||||
("Change Renderer…", "レンダラーを変更…"),
|
||||
(
|
||||
@@ -1386,6 +1388,8 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
|
||||
("Import Adobe After Effects Project", "导入 Adobe After Effects 项目"),
|
||||
("Some parts of this project cannot be imported exactly (left out or changed):", "此项目的某些部分无法准确导入(将被省略或更改):"),
|
||||
("Import Anyway", "仍然导入"),
|
||||
("{}: {} left out or changed", "{}:{} 项被省略或更改"),
|
||||
("… and {} more", "…以及另外 {} 项"),
|
||||
("Casts Shadows", "投射阴影"),
|
||||
("Change Renderer…", "更改渲染器…"),
|
||||
("Changing a shortcut creates a custom preset; the default stays as it is.", "修改快捷键会创建自定义预设;默认预设保持不变。"),
|
||||
@@ -2505,6 +2509,8 @@ const SWEDISH: &[(&str, &str)] = &[
|
||||
"Vissa delar av projektet kan inte importeras exakt (utelämnas eller ändras):",
|
||||
),
|
||||
("Import Anyway", "Importera ändå"),
|
||||
("{}: {} left out or changed", "{}: {} utelämnas eller ändras"),
|
||||
("… and {} more", "… och {} till"),
|
||||
("Casts Shadows", "Kastar skuggor"),
|
||||
("Change Renderer…", "Byt renderare…"),
|
||||
(
|
||||
@@ -3778,6 +3784,8 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
|
||||
("Import Adobe After Effects Project", "匯入 Adobe After Effects 專案"),
|
||||
("Some parts of this project cannot be imported exactly (left out or changed):", "此專案的部分內容無法準確匯入(將被省略或變更):"),
|
||||
("Import Anyway", "仍要匯入"),
|
||||
("{}: {} left out or changed", "{}:{} 項被省略或變更"),
|
||||
("… and {} more", "…以及另外 {} 項"),
|
||||
("Casts Shadows", "投射陰影"),
|
||||
("Change Renderer…", "更改算圖引擎…"),
|
||||
("Changing a shortcut creates a custom preset; the default stays as it is.", "修改快速鍵會創建自定義預設集;預設預設集保持不變。"),
|
||||
@@ -4847,6 +4855,8 @@ const UKRAINIAN: &[(&str, &str)] = &[
|
||||
"Деякі частини цього проєкту неможливо імпортувати точно (їх пропущено або змінено):",
|
||||
),
|
||||
("Import Anyway", "Усе одно імпортувати"),
|
||||
("{}: {} left out or changed", "{}: {} пропущено або змінено"),
|
||||
("… and {} more", "… і ще {}"),
|
||||
("Casts Shadows", "Відкидає тіні"),
|
||||
("Change Renderer…", "Змінити рушій рендерингу…"),
|
||||
(
|
||||
@@ -5428,6 +5438,8 @@ const PORTUGUESE: &[(&str, &str)] = &[
|
||||
"Algumas partes deste projeto não podem ser importadas exatamente (omitidas ou alteradas):",
|
||||
),
|
||||
("Import Anyway", "Importar mesmo assim"),
|
||||
("{}: {} left out or changed", "{}: {} omitidos ou alterados"),
|
||||
("… and {} more", "… e mais {}"),
|
||||
("Casts Shadows", "Projeta sombras"),
|
||||
("Change Renderer…", "Alterar renderizador…"),
|
||||
(
|
||||
@@ -6201,6 +6213,8 @@ const POLISH: &[(&str, &str)] = &[
|
||||
"Niektórych części tego projektu nie można zaimportować dokładnie (zostaną pominięte lub zmienione):",
|
||||
),
|
||||
("Import Anyway", "Importuj mimo to"),
|
||||
("{}: {} left out or changed", "{}: {} pominięto lub zmieniono"),
|
||||
("… and {} more", "… i {} więcej"),
|
||||
("Casts Shadows", "Rzuca cienie"),
|
||||
("Change Renderer…", "Zmień renderer…"),
|
||||
(
|
||||
@@ -6957,6 +6971,8 @@ const CZECH: &[(&str, &str)] = &[
|
||||
"Některé části tohoto projektu nelze importovat přesně (budou vynechány nebo změněny):",
|
||||
),
|
||||
("Import Anyway", "Přesto importovat"),
|
||||
("{}: {} left out or changed", "{}: {} vynecháno nebo změněno"),
|
||||
("… and {} more", "… a {} dalších"),
|
||||
("Casts Shadows", "Vrhat stíny"),
|
||||
("Change Renderer…", "Změnit vykreslovací modul…"),
|
||||
(
|
||||
@@ -7532,6 +7548,8 @@ const SPANISH: &[(&str, &str)] = &[
|
||||
"Algunas partes de este proyecto no se pueden importar con exactitud (se omiten o cambian):",
|
||||
),
|
||||
("Import Anyway", "Importar de todos modos"),
|
||||
("{}: {} left out or changed", "{}: {} omitidos o cambiados"),
|
||||
("… and {} more", "… y {} más"),
|
||||
("Casts Shadows", "Proyecta sombras"),
|
||||
("Change Renderer…", "Cambiar motor de procesamiento…"),
|
||||
(
|
||||
@@ -8098,6 +8116,8 @@ const GEORGIAN: &[(&str, &str)] = &[
|
||||
"ამ პროექტის ზოგიერთი ნაწილის ზუსტად იმპორტი შეუძლებელია (გამოტოვებული ან შეცვლილი):",
|
||||
),
|
||||
("Import Anyway", "მაინც იმპორტი"),
|
||||
("{}: {} left out or changed", "{}: {} გამოტოვებული ან შეცვლილი"),
|
||||
("… and {} more", "… და კიდევ {}"),
|
||||
("Casts Shadows", "ჩრდილს აგდებს"),
|
||||
("Change Renderer…", "რენდერერის შეცვლა…"),
|
||||
("Changing a shortcut creates a custom preset; the default stays as it is.", "მალსახმობის შეცვლა ქმნის მორგებულ პრესეტს; ნაგულისხმევი უცვლელი რჩება."),
|
||||
@@ -9070,6 +9090,8 @@ const FRENCH: &[(&str, &str)] = &[
|
||||
"Certaines parties de ce projet ne peuvent pas être importées à l’identique (omises ou modifiées) :",
|
||||
),
|
||||
("Import Anyway", "Importer quand même"),
|
||||
("{}: {} left out or changed", "{} : {} omis ou modifiés"),
|
||||
("… and {} more", "… et {} de plus"),
|
||||
("Casts Shadows", "Projette des ombres"),
|
||||
("Change Renderer…", "Changer de moteur de rendu…"),
|
||||
(
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//!
|
||||
//! Automation ids: `dialog.aepLosses.continue`, `dialog.aepLosses.cancel`.
|
||||
|
||||
use crate::i18n::tr;
|
||||
use crate::i18n::{tr, tr_args};
|
||||
use egui::vec2;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -23,9 +23,6 @@ pub struct Pending {
|
||||
pub queue: Vec<String>,
|
||||
}
|
||||
|
||||
/// How many detail lines of a loss category the prompt shows.
|
||||
const DETAILS: usize = 3;
|
||||
|
||||
/// Whether the prompt is showing with a conversion waiting on it.
|
||||
pub fn asking(app: &EffectcraftApp) -> bool {
|
||||
app.dialog == Some(Dialog::AepLosses) && app.dialog_state.aep_losses.command.is_some()
|
||||
@@ -77,14 +74,24 @@ fn category(c: &str) -> String {
|
||||
chars.next().map(|f| f.to_uppercase().chain(chars).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The report's losses as lines: one per category with its count, and a few of its details.
|
||||
/// The report's losses as lines: one per category with how many occurrences are left out or
|
||||
/// changed, then every detail line the report keeps and "… and N more" for the occurrences none
|
||||
/// describes (#3365).
|
||||
pub fn lines(report: &Value) -> Vec<(String, Vec<String>)> {
|
||||
let Some(losses) = report["losses"].as_array() else { return vec![] };
|
||||
losses
|
||||
.iter()
|
||||
.map(|l| {
|
||||
let head = format!("{} ({})", category(l["category"].as_str().unwrap_or("other")), l["count"].as_u64().unwrap_or(0));
|
||||
let details = l["details"].as_array().map(|d| d.iter().filter_map(Value::as_str).take(DETAILS).map(str::to_string).collect()).unwrap_or_default();
|
||||
let count = l["count"].as_u64().unwrap_or(0);
|
||||
let head = tr_args("{}: {} left out or changed", &[&category(l["category"].as_str().unwrap_or("other")), &count]);
|
||||
let mut details: Vec<String> =
|
||||
l["details"].as_array().map(|d| d.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();
|
||||
// An export report has no `unlisted`: each of its lines is one occurrence.
|
||||
let listed = u64::try_from(details.len()).unwrap_or(u64::MAX);
|
||||
let unlisted = l["unlisted"].as_u64().unwrap_or_else(|| count.saturating_sub(listed));
|
||||
if unlisted > 0 {
|
||||
details.push(tr_args("… and {} more", &[&unlisted]));
|
||||
}
|
||||
(head, details)
|
||||
})
|
||||
.collect()
|
||||
@@ -179,11 +186,25 @@ mod tests {
|
||||
fn the_report_reads_as_categories_with_counts_and_details() {
|
||||
let report = json!({"losses": [{"category": "layer_styles", "count": 4, "details": ["a", "b", "c", "d"]}, {"category": "effects", "count": 1}]});
|
||||
let l = lines(&report);
|
||||
assert_eq!(l[0], ("Layer styles (4)".to_string(), vec!["a".to_string(), "b".into(), "c".into()]));
|
||||
assert_eq!(l[1], ("Effects (1)".to_string(), vec![]));
|
||||
assert_eq!(l[0], ("Layer styles: 4 left out or changed".to_string(), vec!["a".to_string(), "b".into(), "c".into(), "d".into()]));
|
||||
assert_eq!(l[1], ("Effects: 1 left out or changed".to_string(), vec!["… and 1 more".to_string()]));
|
||||
assert!(lines(&json!({})).is_empty());
|
||||
}
|
||||
|
||||
/// #3365: every detail line shows, then how many occurrences have none: the report's
|
||||
/// `unlisted` (an effect line can describe many instances), else the lines past the count.
|
||||
#[test]
|
||||
fn every_detail_shows_and_the_rest_is_counted() {
|
||||
let details: Vec<String> = (0..20).map(|i| format!("d{i}")).collect();
|
||||
let l = lines(&json!({"losses": [{"category": "masks", "count": 27, "details": details}]}));
|
||||
assert_eq!(l[0].1.len(), 21);
|
||||
assert_eq!(l[0].1[20], "… and 7 more");
|
||||
let l = lines(&json!({"losses": [{"category": "effects", "count": 50, "details": ["X, and 44 more like it"], "unlisted": 5}]}));
|
||||
assert_eq!(l[0].1, ["X, and 44 more like it", "… and 5 more"]);
|
||||
let l = lines(&json!({"losses": [{"category": "effects", "count": 45, "details": ["X, and 44 more like it"], "unlisted": 0}]}));
|
||||
assert_eq!(l[0].1, ["X, and 44 more like it"]);
|
||||
}
|
||||
|
||||
/// Projects dropped or picked together wait only behind a prompt that shows; with none
|
||||
/// showing they are tried at once (a missing file is an error naming it).
|
||||
#[test]
|
||||
|
||||
+10
-6
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user