mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-02 18:13:39 +00:00
Integration: merge main, resolve conflicts (#8658)
docs/file-format-parity.md: main's PSD row with the round-trip count 258/258. docs/scorecard.md: regenerated with `cargo xtask scorecard`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+42
-33
@@ -72,9 +72,33 @@ fn format_report(languages: &[Language], english_keys: &BTreeSet<Key>, translati
|
||||
let coverage = 100.0 * translated as f64 / english_keys.len() as f64;
|
||||
report.push_str(&format!("{:<12} {:>5}/{:<6} {:>8.2}%\n", language.code, translated, english_keys.len(), coverage));
|
||||
}
|
||||
report.push_str("\nMissing translations (shown in English until translated)\n");
|
||||
for language in languages.iter().filter(|language| language.code != "en") {
|
||||
let empty = BTreeSet::new();
|
||||
let translated = translations.get(&language.code).unwrap_or(&empty);
|
||||
let missing: Vec<&Key> = english_keys.difference(translated).collect();
|
||||
if missing.is_empty() {
|
||||
report.push_str(&format!("{}: none missing\n", language.code));
|
||||
continue;
|
||||
}
|
||||
report.push_str(&format!("{}: {} missing\n", language.code, missing.len()));
|
||||
for key in missing {
|
||||
let source = escape_line(&key.source);
|
||||
if key.context.is_empty() {
|
||||
report.push_str(&format!(" {source}\n"));
|
||||
} else {
|
||||
report.push_str(&format!(" [{}] {source}\n", escape_line(&key.context)));
|
||||
}
|
||||
}
|
||||
}
|
||||
report
|
||||
}
|
||||
|
||||
/// Keeps one key per log line: a source string with a newline or tab prints them escaped.
|
||||
fn escape_line(text: &str) -> String {
|
||||
text.replace('\\', "\\\\").replace('\n', "\\n").replace('\t', "\\t")
|
||||
}
|
||||
|
||||
fn registered_languages(source: &str) -> Result<Vec<Language>, String> {
|
||||
let registry = source
|
||||
.split("pub static LANGUAGES")
|
||||
@@ -494,15 +518,28 @@ mod tests {
|
||||
let languages = registered_languages(registry);
|
||||
assert!(languages.is_ok());
|
||||
let languages = languages.unwrap_or_default();
|
||||
let english_keys = BTreeSet::from([Key { context: String::new(), source: "Open".into() }, Key { context: String::new(), source: "Save".into() }]);
|
||||
let translations = BTreeMap::from([("fr".to_string(), BTreeSet::from([Key { context: String::new(), source: "Open".into() }]))]);
|
||||
let english_keys = BTreeSet::from([
|
||||
Key { context: String::new(), source: "Open".into() },
|
||||
Key { context: String::new(), source: "Save".into() },
|
||||
Key { context: "@plural".into(), source: "{n} layer|{n} layers".into() },
|
||||
]);
|
||||
let translations = BTreeMap::from([
|
||||
("fr".to_string(), BTreeSet::from([Key { context: String::new(), source: "Open".into() }])),
|
||||
("zh".to_string(), english_keys.clone()),
|
||||
]);
|
||||
assert_eq!(
|
||||
format_report(&languages, &english_keys, &translations),
|
||||
"UI translation coverage (translated source keys / source keys)\n\
|
||||
language translated coverage\n\
|
||||
en 2/2 100.00%\n\
|
||||
fr 1/2 50.00%\n\
|
||||
zh 0/2 0.00%\n"
|
||||
en 3/3 100.00%\n\
|
||||
fr 1/3 33.33%\n\
|
||||
zh 3/3 100.00%\n\
|
||||
\n\
|
||||
Missing translations (shown in English until translated)\n\
|
||||
fr: 2 missing\n\
|
||||
\x20 Save\n\
|
||||
\x20 [@plural] {n} layer|{n} layers\n\
|
||||
zh: none missing\n"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -563,34 +600,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_key_set_is_covered_by_complete_catalogs() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap_or(Path::new("."));
|
||||
let keys = source_keys(root).unwrap_or_default();
|
||||
for (code, path) in [
|
||||
("ja", "ja.tsv"),
|
||||
("zh-hant", "zh-hant.tsv"),
|
||||
("es", "es.tsv"),
|
||||
("ru", "ru.tsv"),
|
||||
("cs", "cs.tsv"),
|
||||
("de", "de.tsv"),
|
||||
("pt-br", "pt-br.tsv"),
|
||||
("it", "it.tsv"),
|
||||
("el", "el.tsv"),
|
||||
("uk", "uk.tsv"),
|
||||
("nl", "nl.tsv"),
|
||||
("hu", "hu.tsv"),
|
||||
("tr", "tr.tsv"),
|
||||
("vi", "vi.tsv"),
|
||||
("sr", "sr.tsv"),
|
||||
] {
|
||||
let catalog_path = root.join("crates/ui-egui/src/i18n").join(path);
|
||||
let text = fs::read_to_string(&catalog_path).unwrap_or_default();
|
||||
let translations = parse_catalog(&catalog_path, &text).unwrap_or_default();
|
||||
let missing: Vec<_> = keys.difference(&translations).collect();
|
||||
assert!(missing.is_empty(), "{code}: missing source keys: {missing:#?}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn plural_forms_follow_count_expressions_and_dynamic_forms_are_not_literal_keys() {
|
||||
let sources = vec![(
|
||||
|
||||
+103
-21
@@ -16,15 +16,19 @@ mod stats;
|
||||
mod version;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, ExitCode};
|
||||
use std::process::{Command, ExitCode, Stdio};
|
||||
|
||||
const USAGE: &str = "\
|
||||
usage: cargo xtask <command>
|
||||
|
||||
commands:
|
||||
layers enforce the crate dependency layering (docs/architecture.md §3)
|
||||
wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates
|
||||
ci fmt --check, clippy -D warnings, test, layers, wasm (stops at first failure)
|
||||
wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates (one call, plus
|
||||
one per optional web feature)
|
||||
test workspace tests (cargo nextest when installed, else cargo test), then doctests
|
||||
ci the CI gate: fmt --check, clippy -D warnings, layers, scorecard --check, tests
|
||||
(nextest --profile ci), panic_hunt, doctests and wasm, with --locked --features heif
|
||||
(stops at first failure), then the i18n-coverage report
|
||||
corpus [--all | --pngsuite | --psd | --psd-tools | --pixls | --atn | --photoshop | --gimp] [--local] [--update-manifest]
|
||||
show where test corpora live and their pins (xtask/src/corpus_pins.rs), or fetch
|
||||
them into corpus/ (pinned commits, sha256-verified; --all = every corpus but pixls,
|
||||
@@ -36,7 +40,8 @@ commands:
|
||||
--local takes corpus/photoshop and corpus/gimp from the photocraft-corpus authoring clone
|
||||
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
|
||||
parity Photoshop menu parity; rewrites docs/parity-checklist.md
|
||||
i18n-coverage report stable UI translation coverage for registered languages
|
||||
i18n-coverage report UI translation coverage and the missing strings per registered language
|
||||
(the last `ci` step; it never fails)
|
||||
perf [--quick] [--update-baseline] [--threshold PCT] [--bench NAME]... [--skip-build] [--reuse]
|
||||
run the release benches, merge them by scenario id into target/perf/results.json,
|
||||
check perf/budgets.toml and perf/baseline.json (non-zero on a broken budget or regression)
|
||||
@@ -54,6 +59,7 @@ fn main() -> ExitCode {
|
||||
let result = match args.first().map(String::as_str) {
|
||||
Some("layers") => cmd_layers(),
|
||||
Some("wasm") => cmd_wasm(),
|
||||
Some("test") => cmd_test().and_then(|()| cmd_doctest()),
|
||||
Some("ci") => cmd_ci(),
|
||||
Some("corpus") => corpus::cmd(&rest),
|
||||
Some("test-corpus") => corpus::test_cmd(&rest),
|
||||
@@ -157,15 +163,23 @@ fn wasm_set() -> Result<Vec<String>, String> {
|
||||
/// (package, feature).
|
||||
const WASM_FEATURES: &[(&str, &str)] = &[("photocraft-codecs", "heif")];
|
||||
|
||||
/// One `cargo check` for the whole set: each crate is checked once, and `--keep-going` still
|
||||
/// reports every failing crate. Features unify across the set, so each crate is checked with the
|
||||
/// union of the features the set enables. The optional features get a call each, so the crates
|
||||
/// are also checked without them.
|
||||
fn cmd_wasm() -> Result<(), String> {
|
||||
let set = wasm_set()?;
|
||||
let mut results = Vec::new();
|
||||
for pkg in &set {
|
||||
let mut c = cargo();
|
||||
c.args(["check", "--target", "wasm32-unknown-unknown", "-p", pkg]);
|
||||
let ok = run(c, &format!("cargo check --target wasm32-unknown-unknown -p {pkg}")).is_ok();
|
||||
results.push((pkg.clone(), ok));
|
||||
if set.is_empty() {
|
||||
return Err("no wasm-safe crates found in the workspace metadata".into());
|
||||
}
|
||||
let mut results = Vec::new();
|
||||
let mut c = cargo();
|
||||
c.args(["check", "--target", "wasm32-unknown-unknown", "--keep-going"]);
|
||||
for pkg in &set {
|
||||
c.args(["-p", pkg]);
|
||||
}
|
||||
let ok = run(c, &format!("cargo check --target wasm32-unknown-unknown --keep-going -p {}", set.join(" -p "))).is_ok();
|
||||
results.push((format!("{} crates", set.len()), ok));
|
||||
for (pkg, feature) in WASM_FEATURES {
|
||||
let mut c = cargo();
|
||||
c.args(["check", "--target", "wasm32-unknown-unknown", "-p", pkg, "--features", feature]);
|
||||
@@ -177,7 +191,62 @@ fn cmd_wasm() -> Result<(), String> {
|
||||
println!(" {:<6} {p}", if *ok { "ok" } else { "FAIL" });
|
||||
}
|
||||
let failed = results.iter().filter(|r| !r.1).count();
|
||||
if failed == 0 { Ok(()) } else { Err(format!("{failed} crate(s) failed the wasm check")) }
|
||||
if failed == 0 { Ok(()) } else { Err(format!("{failed} wasm check(s) failed")) }
|
||||
}
|
||||
|
||||
/// Test targets for the test build. Examples are left out so they aren't linked; clippy
|
||||
/// `--all-targets` still compiles them.
|
||||
const TEST_TARGETS: [&str; 4] = ["--workspace", "--lib", "--bins", "--tests"];
|
||||
|
||||
fn nextest_available() -> bool {
|
||||
cargo().args(["nextest", "--version"]).stdout(Stdio::null()).stderr(Stdio::null()).status().is_ok_and(|s| s.success())
|
||||
}
|
||||
|
||||
fn cmd_test() -> Result<(), String> {
|
||||
let mut c = cargo();
|
||||
let tool = if nextest_available() {
|
||||
c.args(["nextest", "run"]);
|
||||
"nextest run"
|
||||
} else {
|
||||
eprintln!("note: cargo-nextest is not installed, running cargo test (cargo install cargo-nextest --locked)");
|
||||
c.arg("test");
|
||||
"test"
|
||||
};
|
||||
c.args(TEST_TARGETS);
|
||||
run(c, &format!("cargo {tool} {}", TEST_TARGETS.join(" ")))
|
||||
}
|
||||
|
||||
/// What the CI gate adds to every build: `--locked`, and the optional photocraft-heif crate that
|
||||
/// official builds open HEIC photos with.
|
||||
const GATE_ARGS: [&str; 4] = ["--workspace", "--locked", "--features", "heif"];
|
||||
|
||||
/// The gate's tests. nextest's `ci` profile stops at the first failure and terminates hung tests
|
||||
/// (.config/nextest.toml). `panic_hunt` runs only the engine's `#[ignore]`d panic hunt (AGENTS.md
|
||||
/// Rule 9) with the same packages, features and targets as the tests, so nothing rebuilds.
|
||||
fn cmd_gate_tests(panic_hunt: bool) -> Result<(), String> {
|
||||
let mut c = cargo();
|
||||
if nextest_available() {
|
||||
c.args(["nextest", "run", "--profile", "ci"]).args(GATE_ARGS).args(&TEST_TARGETS[1..]);
|
||||
if panic_hunt {
|
||||
c.args(["--run-ignored", "only", "-E", "binary_id(photocraft-engine::panic_hunt)"]);
|
||||
}
|
||||
} else if panic_hunt {
|
||||
// `-- --ignored` across the workspace would run every ignored test, not only the hunt. The
|
||||
// engine has no `heif` feature of its own; it reaches the decoder through photocraft-io.
|
||||
c.args(["test", "-p", "photocraft-engine", "--locked", "--features", "photocraft-io/heif", "--test", "panic_hunt", "--", "--ignored"]);
|
||||
} else {
|
||||
eprintln!("note: cargo-nextest is not installed, running cargo test (cargo install cargo-nextest --locked)");
|
||||
c.arg("test").args(GATE_ARGS).args(&TEST_TARGETS[1..]);
|
||||
}
|
||||
let what = format!("cargo {}", c.get_args().map(|a| a.to_string_lossy()).collect::<Vec<_>>().join(" "));
|
||||
run(c, &what)
|
||||
}
|
||||
|
||||
/// nextest does not run doctests.
|
||||
fn cmd_doctest() -> Result<(), String> {
|
||||
let mut c = cargo();
|
||||
c.args(["test", "--workspace", "--doc"]);
|
||||
run(c, "cargo test --workspace --doc")
|
||||
}
|
||||
|
||||
fn cmd_parity() -> Result<(), String> {
|
||||
@@ -201,20 +270,33 @@ fn cmd_ci() -> Result<(), String> {
|
||||
"clippy",
|
||||
Box::new(|| {
|
||||
let mut c = cargo();
|
||||
c.args(["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]);
|
||||
run(c, "cargo clippy --workspace --all-targets -- -D warnings")
|
||||
}),
|
||||
),
|
||||
(
|
||||
"test",
|
||||
Box::new(|| {
|
||||
let mut c = cargo();
|
||||
c.args(["test", "--workspace"]);
|
||||
run(c, "cargo test --workspace")
|
||||
c.arg("clippy").args(GATE_ARGS).args(["--all-targets", "--keep-going", "--", "-D", "warnings"]);
|
||||
run(c, "cargo clippy --workspace --locked --features heif --all-targets --keep-going -- -D warnings")
|
||||
}),
|
||||
),
|
||||
("layers", Box::new(cmd_layers)),
|
||||
("scorecard", Box::new(|| scorecard::run(&root(), &["--check"]))),
|
||||
("test", Box::new(|| cmd_gate_tests(false))),
|
||||
("panic_hunt", Box::new(|| cmd_gate_tests(true))),
|
||||
(
|
||||
"doctest",
|
||||
Box::new(|| {
|
||||
let mut c = cargo();
|
||||
c.arg("test").args(GATE_ARGS).arg("--doc");
|
||||
run(c, "cargo test --workspace --locked --features heif --doc")
|
||||
}),
|
||||
),
|
||||
("wasm", Box::new(cmd_wasm)),
|
||||
(
|
||||
"i18n",
|
||||
Box::new(|| {
|
||||
// Lists missing translations for the log; a missing string shows in English, so it never fails CI.
|
||||
if let Err(e) = i18n_coverage::run(&root()) {
|
||||
eprintln!("warning: i18n coverage report failed: {e}");
|
||||
}
|
||||
Ok(())
|
||||
}),
|
||||
),
|
||||
];
|
||||
let mut done = Vec::new();
|
||||
for (name, f) in &steps {
|
||||
|
||||
Reference in New Issue
Block a user