mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-25 11:26:46 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef24afc10c | ||
|
|
6528bb564b | ||
|
|
d0281360c2 | ||
|
|
de5aecfe8b | ||
|
|
546b5a1cf3 | ||
|
|
2ab09850bb | ||
|
|
4ab8c384b3 |
@@ -21,41 +21,3 @@ jobs:
|
||||
nix:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
|
||||
|
||||
git-sources:
|
||||
name: Git dependencies come from storytold
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Git dependencies come from storytold
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
|
||||
# Source replacement, patches and path overrides in cargo config never show up
|
||||
# in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
|
||||
# Cargo still reads the legacy .cargo/config.
|
||||
python3 - <<'PY'
|
||||
import os, sys, tomllib
|
||||
for path in (".cargo/config", ".cargo/config.toml"):
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
config = tomllib.load(open(path, "rb"))
|
||||
replaced = [name for name, source in config.get("source", {}).items()
|
||||
if isinstance(source, dict) and "replace-with" in source]
|
||||
if replaced or "patch" in config or "paths" in config:
|
||||
print(f"{path} replaces, patches or overrides a source")
|
||||
sys.exit(1)
|
||||
PY
|
||||
# Fails when Cargo.lock is out of date with the manifests.
|
||||
cargo metadata --locked --format-version 1 > /dev/null
|
||||
python3 - <<'PY'
|
||||
import sys, tomllib
|
||||
lock = tomllib.load(open("Cargo.lock", "rb"))
|
||||
bad = sorted({p["source"] for p in lock.get("package", [])
|
||||
if p.get("source", "").startswith("git+")
|
||||
and not p["source"].startswith("git+https://github.com/storytold/")})
|
||||
if bad:
|
||||
print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
|
||||
sys.exit(1)
|
||||
PY
|
||||
|
||||
@@ -11,57 +11,14 @@ on:
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
git-sources:
|
||||
name: Git dependencies come from storytold
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Git dependencies come from storytold
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
|
||||
# Source replacement, patches and path overrides in cargo config never show up
|
||||
# in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
|
||||
# Cargo still reads the legacy .cargo/config.
|
||||
python3 - <<'PY'
|
||||
import os, sys, tomllib
|
||||
for path in (".cargo/config", ".cargo/config.toml"):
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
config = tomllib.load(open(path, "rb"))
|
||||
replaced = [name for name, source in config.get("source", {}).items()
|
||||
if isinstance(source, dict) and "replace-with" in source]
|
||||
if replaced or "patch" in config or "paths" in config:
|
||||
print(f"{path} replaces, patches or overrides a source")
|
||||
sys.exit(1)
|
||||
PY
|
||||
# Fails when Cargo.lock is out of date with the manifests.
|
||||
cargo metadata --locked --format-version 1 > /dev/null
|
||||
python3 - <<'PY'
|
||||
import sys, tomllib
|
||||
lock = tomllib.load(open("Cargo.lock", "rb"))
|
||||
bad = sorted({p["source"] for p in lock.get("package", [])
|
||||
if p.get("source", "").startswith("git+")
|
||||
and not p["source"].startswith("git+https://github.com/storytold/")})
|
||||
if bad:
|
||||
print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
|
||||
sys.exit(1)
|
||||
PY
|
||||
|
||||
release:
|
||||
needs: git-sources
|
||||
permissions:
|
||||
# The reusable release workflow tags and uploads the draft release.
|
||||
contents: write
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
|
||||
@@ -11,8 +11,7 @@ permissions:
|
||||
|
||||
concurrency:
|
||||
group: sonarcloud-${{ github.ref }}
|
||||
# A push to main is the run that publishes coverage; superseded runs there would never finish.
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
sonarcloud:
|
||||
|
||||
@@ -18,7 +18,8 @@ pub use craft_docpath::Refused;
|
||||
/// on other platforms.
|
||||
pub fn resolve(stored: &str, document: Option<&Path>) -> Result<PathBuf, Refused> {
|
||||
let document = document.filter(|d| !d.as_os_str().is_empty()).map(|d| std::path::absolute(d).unwrap_or_else(|_| d.to_path_buf()));
|
||||
craft_docpath::resolve_absolute(stored.trim(), document.as_deref())
|
||||
let path = craft_docpath::resolve(stored.trim(), document.as_deref())?;
|
||||
if path.is_absolute() { Ok(path) } else { Err(Refused::Invalid) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! OFFSET Erase/Layer/Multiple/Undo and the MOVE/COPY Displacement option at the command line.
|
||||
|
||||
use cadcraft_engine::Input;
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::EntityKind;
|
||||
use cadcraft_engine::geom::Vec2;
|
||||
use serde_json::json;
|
||||
|
||||
/// Every line as ((ax, ay), (bx, by), layer), sorted by the first y.
|
||||
@@ -62,6 +64,27 @@ fn offset_multiple_and_undo() {
|
||||
assert_eq!(lines(&s), vec![l((0.0, 0.0), (10.0, 0.0), "0"), l((0.0, 2.0), (10.0, 2.0), "0"), l((0.0, 4.0), (10.0, 4.0), "0")]);
|
||||
}
|
||||
|
||||
// Regression test for #618: OFFSET's distance prompt took only a typed number; clicking two points
|
||||
// (the distance between them) was rejected, and the second click had no distance guide.
|
||||
#[test]
|
||||
fn offset_distance_from_two_clicked_points() {
|
||||
let mut s = one_line();
|
||||
s.start("offset").unwrap();
|
||||
s.input(Input::Point(Vec2::new(20.0, 20.0))).unwrap();
|
||||
let p = s.current_prompt().unwrap();
|
||||
assert!(p.message.contains("second point"), "{}", s.prompt_text());
|
||||
assert!(p.base.is_some_and(|b| b.near(Vec2::new(20.0, 20.0), 1e-9)), "the second click is measured from the first");
|
||||
// The guide from the first click to the cursor is the previewed distance.
|
||||
let guide = s.preview(Vec2::new(20.0, 22.5));
|
||||
assert!(matches!(guide.first().map(|e| &e.kind), Some(EntityKind::Line(g)) if g.a.xy().near(Vec2::new(20.0, 20.0), 1e-9)), "{guide:?}");
|
||||
s.input(Input::Point(Vec2::new(20.0, 22.5))).unwrap();
|
||||
assert!(s.current_prompt().unwrap().message.contains("Select object"), "{}", s.prompt_text());
|
||||
assert_eq!(s.doc().unwrap().header.f64("OFFSETDIST", -1.0), 2.5);
|
||||
s.script("5,0 5,1\n\n").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert_eq!(lines(&s), vec![l((0.0, 0.0), (10.0, 0.0), "0"), l((0.0, 2.5), (10.0, 2.5), "0")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_json_erase_and_layer() {
|
||||
let mut s = one_line();
|
||||
|
||||
@@ -349,6 +349,22 @@ fn hostile_dxf_does_not_panic() {
|
||||
}
|
||||
}
|
||||
|
||||
// Regression test for #1336: the LAYER table of a DXF had no record cap, so a hostile file could
|
||||
// grow the layer list without bound (its VIEW/UCS/VPORT peers stop at one million records).
|
||||
#[test]
|
||||
fn dxf_layer_table_stops_at_the_record_cap() {
|
||||
use std::fmt::Write as _;
|
||||
let records = 1_000_100;
|
||||
let mut text = String::from("0\nSECTION\n2\nTABLES\n0\nTABLE\n2\nLAYER\n");
|
||||
for i in 0..records {
|
||||
let _ = write!(text, "0\nLAYER\n2\nL{i}\n");
|
||||
}
|
||||
text.push_str("0\nENDTAB\n0\nENDSEC\n0\nEOF\n");
|
||||
let d = read(text.as_bytes(), "layers.dxf").unwrap();
|
||||
assert!(d.layers.len() <= 1_000_000, "{} layers read from {records} records", d.layers.len());
|
||||
assert!(d.layers.len() > 900_000, "the records below the cap are kept: {}", d.layers.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_and_png_export() {
|
||||
let d = sample();
|
||||
|
||||
@@ -297,6 +297,31 @@ fn truetype_text_is_filled_or_outlined() {
|
||||
assert!(l.prims.len() >= 3, "O has two contours, H one");
|
||||
}
|
||||
|
||||
// Regression test for #651: filled TrueType text was triangulated at full outline detail at every
|
||||
// zoom, so a page of small text on screen produced millions of triangles and froze the canvas.
|
||||
#[test]
|
||||
fn small_filled_text_on_screen_is_simplified_but_plots_keep_full_detail() {
|
||||
let Some(font) = system_font() else { return };
|
||||
let d = text_doc(font, "OOOO");
|
||||
let tris = |l: &DisplayList| l.prims.iter().filter(|p| p.kind == Kind::Tris).map(|p| l.points(p).len() / 3).sum::<usize>();
|
||||
let plot = build_plot(&d, &Space::Model, &Options::default());
|
||||
let full = tris(&plot);
|
||||
assert!(full > 100, "full-detail O glyphs are many triangles: {full}");
|
||||
// One pixel is a tenth of the cap height: the outlines are simplified, the shapes stay.
|
||||
let reduced = build(&d, &Space::Model, &screen(0.1, None));
|
||||
assert!(tris(&reduced) < full / 2, "{} triangles at 10 px per cap height, {full} in a plot", tris(&reduced));
|
||||
assert!(tris(&reduced) > 0 && reduced.bounds.width() > plot.bounds.width() * 0.9, "{:?} vs {:?}", reduced.bounds, plot.bounds);
|
||||
// An em under two pixels is drawn as the ink's box: two triangles a glyph, same extent.
|
||||
let tiny = build(&d, &Space::Model, &screen(1.0, None));
|
||||
assert_eq!(tris(&tiny), 2 * 4, "one box per glyph");
|
||||
assert!(
|
||||
tiny.bounds.width() > plot.bounds.width() * 0.9 && tiny.bounds.height() > plot.bounds.height() * 0.9,
|
||||
"{:?} vs {:?}",
|
||||
tiny.bounds,
|
||||
plot.bounds
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_font_falls_back_to_stroke() {
|
||||
let d = text_doc("NoSuchFontAnywhere.ttf", "OH");
|
||||
|
||||
+893
-202
File diff suppressed because it is too large
Load Diff
@@ -391,4 +391,20 @@ mod tests {
|
||||
assert_eq!(parse_hex(bad), None, "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialog_labels_have_ukrainian_translations() {
|
||||
let sources = [include_str!("dialog.rs")];
|
||||
let mut missing: Vec<String> = dialog::TABS.iter().filter(|(_, l)| crate::i18n::tr("uk", l) == *l).map(|(_, l)| l.to_string()).collect();
|
||||
for source in sources {
|
||||
for rest in source.split("crate::tl!(").skip(1) {
|
||||
let Some(text) = rest.strip_prefix('"').and_then(|r| r.split('"').next()) else { continue };
|
||||
// File format names read the same in every language.
|
||||
if crate::i18n::tr("uk", text) == text && !["DXF", "DWG"].contains(&text) {
|
||||
missing.push(text.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(missing.is_empty(), "missing translations: {missing:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,6 +169,37 @@ fn a_selected_block_reference_offers_the_block_editor() {
|
||||
assert_eq!(app.session.state().unwrap().block_editor(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_menu_label_has_a_ukrainian_translation() {
|
||||
let mut app = app();
|
||||
app.cmdline("LINE 0,0 10,0");
|
||||
app.cmdline("");
|
||||
app.run("pline", json!({ "vertices": [[0, 0], [10, 0]] })).unwrap();
|
||||
select_all(&mut app);
|
||||
let mut all = flatten(&context_menu::entries(&app));
|
||||
let line = app.session.doc().unwrap().model.handles()[0];
|
||||
app.session.set_selection(app.session.doc().unwrap().model.handles().into_iter().filter(|h| *h != line).collect());
|
||||
all.extend(flatten(&context_menu::entries(&app)));
|
||||
all.extend(flatten(&context_menu::snap_entries(&app)));
|
||||
type_line(&mut app, "LINE");
|
||||
type_line(&mut app, "0,0");
|
||||
all.extend(flatten(&context_menu::command_entries(&app).unwrap()));
|
||||
let mut missing = Vec::new();
|
||||
for (label, id, _) in &all {
|
||||
let segments: Vec<&str> = label.split('/').collect();
|
||||
// The item itself is user text when it types a value, or a command name (Recent Commands).
|
||||
let user_text = (id.starts_with(context_menu::TYPE) && !id.contains('\'')) || segments.first() == Some(&"Recent Commands");
|
||||
let n = segments.len() - usize::from(user_text);
|
||||
for s in &segments[..n] {
|
||||
// Point filters (.X) and "Repeat LINE" (a template) aren't catalog keys.
|
||||
if !s.starts_with('.') && !s.starts_with("Repeat ") && cadcraft_ui_egui::i18n::tr("uk", s) == *s {
|
||||
missing.push(s.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(missing.is_empty(), "untranslated menu labels: {missing:?}");
|
||||
}
|
||||
|
||||
/// The text painted in one frame at `time` (seconds).
|
||||
fn painted(app: &mut CadApp, ctx: &egui::Context, events: Vec<egui::Event>, time: f64) -> String {
|
||||
fn collect(shape: &egui::Shape, text: &mut String) {
|
||||
|
||||
@@ -16,6 +16,7 @@ mod lisp_files;
|
||||
mod measure_distance_snapping;
|
||||
mod menubar_hover;
|
||||
mod mspace_viewport_view;
|
||||
mod offset_clicks;
|
||||
mod options_dialog;
|
||||
mod parameters_rename;
|
||||
mod pickdrag;
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
//! OFFSET on the canvas: the distance can be clicked, and picking the source is not moved by the
|
||||
//! grid snap (#618).
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::{Common, Drawing, EntityKind, Line, Space};
|
||||
use cadcraft_engine::geom::Vec2;
|
||||
use cadcraft_ui_egui::{CadApp, Services};
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
fn harness() -> Harness<'static, CadApp> {
|
||||
let mut d = Drawing::new_imperial();
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec2::new(0.0, 2.0).to3(0.0), b: Vec2::new(10.0, 2.0).to3(0.0) })).unwrap();
|
||||
let mut session = Session::empty();
|
||||
session.open_drawing(d, "Offset clicks", None);
|
||||
session.execute("view.set", &json!({"center":[5,5],"height":20})).unwrap();
|
||||
session.settings.osmode = 0;
|
||||
session.settings.snapmode = true;
|
||||
session.execute("setvar", &json!({"name":"SNAPUNIT","value":[5,5]})).unwrap();
|
||||
session.settings.orthomode = false;
|
||||
let mut app = CadApp::new(session, Services::default());
|
||||
app.canvas.zoom_pending = false;
|
||||
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(
|
||||
|ui, app: &mut CadApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(5);
|
||||
h
|
||||
}
|
||||
|
||||
fn click(h: &mut Harness<'_, CadApp>, world: Vec2) {
|
||||
let pos: Pos2 = h.state().canvas.xf.unwrap().to_screen(world);
|
||||
h.event(Event::PointerMoved(pos));
|
||||
h.run_steps(2);
|
||||
for pressed in [true, false] {
|
||||
h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
}
|
||||
}
|
||||
|
||||
// Regression test for #618: OFFSET's distance could not be clicked, and with grid snap on a click
|
||||
// meant to pick the source object was moved to the nearest grid point, missing the object.
|
||||
#[test]
|
||||
fn offset_takes_a_clicked_distance_and_picks_its_source_under_grid_snap() {
|
||||
let mut h = harness();
|
||||
h.state_mut().start("offset");
|
||||
h.run_steps(2);
|
||||
// Two clicks, snapped to the grid points (5, 5) and (10, 5): a distance of 5.
|
||||
click(&mut h, Vec2::new(5.1, 4.9));
|
||||
assert!(h.state().session.prompt_text().contains("second point"), "{}", h.state().session.prompt_text());
|
||||
click(&mut h, Vec2::new(9.9, 5.1));
|
||||
assert_eq!(h.state().session.doc().unwrap().header.f64("OFFSETDIST", -1.0), 5.0);
|
||||
assert!(h.state().session.prompt_text().contains("Select object"), "{}", h.state().session.prompt_text());
|
||||
// The source line is at y = 2, two units from the grid point (5, 0) the click would snap to.
|
||||
click(&mut h, Vec2::new(7.0, 2.04));
|
||||
let prompt = h.state().session.prompt_text();
|
||||
assert!(prompt.contains("side to offset"), "the line was not picked: {prompt}");
|
||||
// A side click above the line (snapped to (5, 5)) makes the copy 5 units up, at y = 7.
|
||||
click(&mut h, Vec2::new(7.0, 7.0));
|
||||
let lines: Vec<(f64, f64)> = h
|
||||
.state()
|
||||
.session
|
||||
.doc()
|
||||
.unwrap()
|
||||
.model
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::Line(line) => Some((line.a.y, line.b.y)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(lines.len(), 2, "OFFSET added no line: {lines:?}");
|
||||
assert!(lines.iter().any(|&(a, b)| (a - 7.0).abs() < 1e-9 && (b - 7.0).abs() < 1e-9), "no copy at y = 7: {lines:?}");
|
||||
// OFFSET asks for the next object; Enter ends it.
|
||||
assert!(h.state().session.prompt_text().contains("Select object to offset"), "{}", h.state().session.prompt_text());
|
||||
h.key_press(egui::Key::Enter);
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().session.prompt_text(), "Command:", "Enter ends OFFSET");
|
||||
}
|
||||
@@ -136,7 +136,7 @@ fn layer_and_properties_palette_menus() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_id_is_a_command() {
|
||||
fn every_label_has_a_ukrainian_translation_and_every_id_a_command() {
|
||||
let mut app = app();
|
||||
app.cmdline("LINE 0,0 10,0");
|
||||
app.cmdline("");
|
||||
@@ -146,7 +146,16 @@ fn every_id_is_a_command() {
|
||||
all.extend(flatten(&sm::layout_tab_entries(&app, "Layout1")));
|
||||
all.extend(flatten(&sm::layer_entries(&app, "0")));
|
||||
all.extend(flatten(&sm::properties_entries(&app)));
|
||||
let mut missing = Vec::new();
|
||||
for (label, id, _) in &all {
|
||||
let segments: Vec<&str> = label.split('/').collect();
|
||||
// Command names (Recent Commands) and line counts aren't translated.
|
||||
let n = segments.len() - usize::from(segments.len() > 1);
|
||||
for s in &segments[..n] {
|
||||
if cadcraft_ui_egui::i18n::tr("uk", s) == *s {
|
||||
missing.push(s.to_string());
|
||||
}
|
||||
}
|
||||
let command = id.strip_prefix("run:").and_then(|r| r.split_once(':')).map_or(id.as_str(), |(c, _)| c);
|
||||
let special = ["drawing:", "layout:", "cmdline:"].iter().any(|p| id.starts_with(p));
|
||||
assert!(
|
||||
@@ -154,4 +163,5 @@ fn every_id_is_a_command() {
|
||||
"{label}: {id} isn't a command"
|
||||
);
|
||||
}
|
||||
assert!(missing.is_empty(), "untranslated menu labels: {missing:?}");
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@ selection. Resetting palettes keeps the language choice.
|
||||
The catalogs cover command and menu labels, tool groups and tooltips, the Start page,
|
||||
file and model-space tabs, status controls, object snaps, drafting prompts, property labels,
|
||||
dialogs, and the About window's controls. The command reference can be searched by English
|
||||
command ID, English label, or translated label. A missing entry falls back to English and
|
||||
never fails a build; `cargo xtask i18n` (a step of `cargo xtask ci`) lists each language's.
|
||||
command ID, English label, or translated label. Ukrainian has enforced coverage;
|
||||
missing entries in the other catalogs are reported by tests and fall back to English.
|
||||
|
||||
Czech has 1,202 catalog rows, covering the reference catalog and every marked UI
|
||||
literal, including Options. Four `menu` entries distinguish menu headings from object/command
|
||||
@@ -139,14 +139,14 @@ The implementation follows the display-time catalog pattern used by
|
||||
- Windows retain explicit IDs based on their English source, and tool groups retain their
|
||||
source names as keys, so switching languages preserves their state.
|
||||
- Add a translation attribution row in `ATTRIBUTION.md`, update the language lists, and add
|
||||
the language's locale tags and plural rule. The rendering, font-glyph and live-switch tests
|
||||
run for every catalog; no test checks one language's wording, and adding a command needs
|
||||
only its English label.
|
||||
locale, plural, font-glyph and live-switch tests. Add the catalog to `REPORTED` in
|
||||
`i18n/tests.rs`: only Ukrainian, the reference translation, is in `REQUIRED`, so adding a
|
||||
command needs a row in one language, not in all of them.
|
||||
- Catalogs are embedded and parsed once. Missing or malformed rows cannot crash the app;
|
||||
tests reject invalid rows, duplicate keys, changed placeholders, and mismatched ellipses.
|
||||
- `cargo test -p cadcraft-ui-egui i18n` checks the catalogs, locale resolution, preferences,
|
||||
integer plurals, bundled glyphs and actual egui renders with language switching.
|
||||
`cargo xtask i18n` prints the strings each language shows in English. `cargo xtask assets` checks asset attribution;
|
||||
- `cargo test -p cadcraft-ui-egui i18n` checks menu/command/tool/snap coverage, every marked
|
||||
UI literal, locale resolution, preferences, integer plurals, bundled glyphs and actual
|
||||
egui renders with language switching. `cargo xtask assets` checks asset attribution;
|
||||
`cargo xtask ci` runs the workspace quality gates.
|
||||
|
||||
Translations are original work under `MIT OR Apache-2.0`, written from the meaning of
|
||||
|
||||
+1
-16
@@ -21,8 +21,7 @@ commands:
|
||||
layers enforce the crate dependency layering (plan/architecture.md §3)
|
||||
wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates
|
||||
test cargo nextest run (cargo test without nextest) plus the doctests
|
||||
i18n print the strings each interface language shows in English (never fails)
|
||||
ci fmt --check, clippy -D warnings, test, i18n, assets, layers, wasm (stops at first failure)
|
||||
ci fmt --check, clippy -D warnings, test, assets, layers, wasm (stops at first failure)
|
||||
corpus [--download]
|
||||
show where test corpora live; --download fetches PngSuite into corpus/pngsuite
|
||||
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
|
||||
@@ -39,7 +38,6 @@ fn main() -> ExitCode {
|
||||
Some("layers") => cmd_layers(),
|
||||
Some("wasm") => cmd_wasm(),
|
||||
Some("test") => cmd_test(),
|
||||
Some("i18n") => cmd_i18n(),
|
||||
Some("ci") => cmd_ci(),
|
||||
Some("corpus") => cmd_corpus(rest.contains(&"--download")),
|
||||
Some("stats") => stats::run(&root(), rest.contains(&"--exact")),
|
||||
@@ -169,18 +167,6 @@ fn cmd_test() -> Result<(), String> {
|
||||
run(c, "cargo test --workspace --doc")
|
||||
}
|
||||
|
||||
/// The translation gap report (`cadcraft-ui-egui`'s ignored `i18n::tests::translation_gaps`).
|
||||
/// A missing translation shows in English and never fails a build, so neither does this step.
|
||||
fn cmd_i18n() -> Result<(), String> {
|
||||
let mut c = cargo();
|
||||
// `--workspace` builds the same test binaries as the test step; `-p` alone would rebuild them.
|
||||
c.args(["test", "--workspace", "--lib", "-q", "--", "--ignored", "--exact", "i18n::tests::translation_gaps", "--nocapture"]);
|
||||
if let Err(e) = run(c, "cargo test --workspace --lib -q -- --ignored --exact i18n::tests::translation_gaps --nocapture") {
|
||||
eprintln!("note: the translation gap report did not run: {e}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_ci() -> Result<(), String> {
|
||||
type Step = (&'static str, Box<dyn Fn() -> Result<(), String>>);
|
||||
let steps: Vec<Step> = vec![
|
||||
@@ -201,7 +187,6 @@ fn cmd_ci() -> Result<(), String> {
|
||||
}),
|
||||
),
|
||||
("test", Box::new(cmd_test)),
|
||||
("i18n", Box::new(cmd_i18n)),
|
||||
("assets", Box::new(|| assets::run(&root()))),
|
||||
("layers", Box::new(cmd_layers)),
|
||||
("wasm", Box::new(cmd_wasm)),
|
||||
|
||||
Reference in New Issue
Block a user