Files
photocraft/crates/engine/src/analysis_cmds/tests.rs
T
Dylan M. Taylor 6649b78526 Use chrono instead of hand-rolled UTC conversion (#8234)
* Use chrono for UTC/calendar conversion instead of hand-rolled copies

Replaces the civil_from_days copies behind the app's log timestamp, the
Measurement Log's date/time, and the perf report date with chrono as a
workspace dependency (reusing the version already in the tree). Output formats
are unchanged and pinned by the existing tests. AGENTS.md now says time goes
through chrono.

Follow-up to storytold/cadcraft#1785

* Never panic on a clock outside chrono's range; keep 5-digit years unprefixed

chrono's From<SystemTime> unwraps internally, so a clock past year 262143
panicked. logging::timestamp and analysis_cmds::now_iso now read the duration
since the epoch and go through the fallible DateTime::from_timestamp, reading
an out-of-range or pre-epoch clock as the epoch as the old saturating code
did. All three ISO helpers (timestamp, now_iso, perf::iso_utc) write the year
with {:04} rather than %Y, which prefixes a '+' past year 9999.

Tests cover the far-future and year-10000 cases for timestamp and iso_utc.

* Don't build an unrepresentable SystemTime in the far-future test

UNIX_EPOCH + Duration::from_secs(u64::MAX / 2) overflows Windows'
SystemTime and panicked before timestamp() ran, failing the Windows
test job. Build it with checked_add and only assert where it exists.

* time: preserve old out-of-range date conversion (no epoch fallback)

`DateTime::from_timestamp(...).unwrap_or_default()` silently reset any
instant past chrono's year range to 1970-01-01, changing output the old
hand-rolled Howard-Hinnant code converted exactly (e.g. 10_000_000_000_000 s
was 318857-05-20 17:46:40, not the epoch). Restore the old conversion domain
with a cycle-based helper that routes the calendar through chrono (the
calendar repeats every 400 years = 146_097 days, so month/day/time come from
chrono and only the year is carried in i128, keeping every i64 exact).

- apps/photocraft/src/logging.rs: `timestamp` via `utc_parts`; keep the
  pre-1970 epoch clamp; u64 secs past i64::MAX saturate, not epoch.
- crates/engine/src/analysis_cmds.rs: extract pure `iso_from_unix` (cycle
  based) from `now_iso` so the conversion is tested directly.
- xtask/src/perf.rs: `iso_utc` cycle-based, accepts the full u64 range the
  old code did.

Pinned old outputs in tests:
  logging u64::MAX/2 s -> 292277026596-12-04T15:30:07.000Z (was epoch),
          10_000_000_000_000 s -> 318857-05-20T17:46:40.000Z
  iso_from_unix 10_000_000_000_000 -> 318857-05-20T17:46:40Z,
          -10_000_000_000_000 -> -314918-08-13T06:13:20Z, i64 extremes
  iso_utc u64::MAX -> 584554051223-11-09T07:00:15Z (was epoch),
          10_000_000_000_000 -> 318857-05-20T17:46:40Z

* Don't build a year-318857 SystemTime unchecked in the logging test

Windows' SystemTime only reaches about year 30828, so
UNIX_EPOCH + Duration::from_secs(10_000_000_000_000) panicked before
timestamp() ran and failed the Windows test job. Build it with
checked_add and assert where the platform can hold it. The year-10000
case fits everywhere and stays unconditional.

* Name the 400-year range extension as the one exception to the time rule

The rule said never to hand-roll calendar math, but the range fix
(shifting by whole 400-year Gregorian cycles so dates past chrono's year
range keep their old output) is cycle arithmetic, so the rule and the code
disagreed. The rule now names that exception: the library still computes
month, day and time, and only whole cycles are carried in the year.

* Say the library computes month and day; time-of-day is plain arithmetic

Narrows the range-extension wording: the date library computes the
calendar fields, and splitting seconds into hours, minutes and seconds
is ordinary arithmetic, which is what the code does.
2026-10-22 13:54:10 -04:00

555 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::*;
use photocraft_doc::{ColorMode, LayerContent, LayerId};
fn session(w: u32, h: u32, depth: u32) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": w, "height": h, "depth": depth, "background": "white"})).unwrap();
s
}
fn d(s: &Session) -> Arc<Document> {
s.active().unwrap().doc.clone()
}
#[test]
fn failed_compound_restores_layer_target_history_and_journal() {
let mut s = session(80, 60, 8);
let background = s.active().unwrap().active_layer.unwrap();
s.execute("layer.new.layer", json!({"name": "Selected"})).unwrap();
let selected = s.active().unwrap().active_layer.unwrap();
{
let st = s.active_mut().unwrap();
st.selected_layers = vec![background, selected];
st.layer_anchor = Some(background);
st.coalesce = Some("previous-gesture".into());
}
let st = s.active().unwrap();
let prior_doc = st.doc.clone();
let prior_history = st.history.past_len();
let prior_channel_view = st.channel_view.clone();
let prior_mask_targeted = s.tools.mask_targeted;
let prior_journal = s.journal.len();
let error = compound(&mut s, "Place Scale Marker", |s| -> Result<()> {
// Mirrors a scale-bar creation followed by a failed type.create: the
// inner command selects a new layer, which rollback must remove.
s.execute("layer.new.layer", json!({"name": "Temporary Scale Bar"}))?;
s.active_mut().unwrap().channel_view.target = crate::channel_cmds::ChannelTarget::LayerMask;
s.sync_tools_mask_target();
Err(bad("test", "synthetic second step failed"))
})
.unwrap_err();
assert!(error.to_string().contains("synthetic second step failed"));
let st = s.active().unwrap();
assert!(Arc::ptr_eq(&prior_doc, &st.doc), "the original document is restored");
assert_eq!(st.history.past_len(), prior_history, "no undo step remains");
assert_eq!(s.journal.len(), prior_journal, "no rolled-back command is journaled");
assert_eq!(st.active_layer, Some(selected));
assert_eq!(st.selected_layers, vec![background, selected]);
assert_eq!(st.layer_anchor, Some(background));
assert_eq!(st.channel_view, prior_channel_view);
assert_eq!(s.tools.mask_targeted, prior_mask_targeted, "tool mask target follows the restored channel");
assert_eq!(st.coalesce.as_deref(), Some("previous-gesture"));
assert!(st.doc.layer(selected).is_some());
// Subsequent ordinary commands target the original layer, not the
// nonexistent scale bar.
s.execute("layer.setProps", json!({"layer": selected.0, "visible": false})).unwrap();
assert!(!s.active().unwrap().doc.layer(selected).unwrap().visible);
}
#[test]
fn failed_compound_preserves_a_clean_document() {
let mut s = session(80, 60, 8);
assert!(!s.active().unwrap().is_dirty());
let prior_doc = d(&s);
let error = compound(&mut s, "Place Scale Marker", |s| -> Result<()> {
s.execute("layer.new.layer", json!({"name": "Temporary Scale Bar"}))?;
Err(bad("test", "synthetic second step failed"))
})
.unwrap_err();
assert!(error.to_string().contains("synthetic second step failed"));
assert!(Arc::ptr_eq(&prior_doc, &d(&s)));
assert!(!s.active().unwrap().is_dirty(), "a rolled-back edit does not need saving");
}
#[test]
fn record_measurements_refuses_an_area_too_large_to_measure() {
// #933: with no selection the whole document is measured, and `width * height` overflowed
// u32 on a legal 65536×65536 document, panicking inside `run`.
let mut s = Session::new();
s.execute("file.new", json!({"width": 65536, "height": 65536, "background": "transparent"})).unwrap();
let err = s.execute("image.analysis.recordMeasurements", json!({"source": "selection"})).unwrap_err();
assert!(err.to_string().contains("too large to measure"), "{err}");
assert!(s.analysis.log.is_empty());
// A failed measurement does not use up a label: the next one is still "Measurement 1".
s.execute("file.new", json!({"width": 20, "height": 20, "background": "white"})).unwrap();
let r = s.execute("image.analysis.recordMeasurements", json!({"source": "selection"})).unwrap();
assert_eq!(r["rows"][0]["values"]["label"], "Measurement 1");
assert_eq!(r["rows"][0]["values"]["area"], 400.0);
}
#[test]
fn measurement_scale_set_query_default_and_undo() {
let mut s = session(50, 40, 8);
let r = s.execute("image.analysis.setMeasurementScale", json!({})).unwrap();
assert_eq!(r["default"], true);
let r = s.execute("image.analysis.setMeasurementScale", json!({"pixelLength": 100, "logicalLength": 2.5, "units": "cm"})).unwrap();
assert_eq!(r["factor"], 0.025);
assert_eq!(r["text"], "100 pixels = 2.5000 cm");
assert_eq!(d(&s).measurement.scale.units, "cm");
assert!(s.execute("image.analysis.setMeasurementScale", json!({"pixelLength": 0})).is_err());
assert!(s.execute("image.analysis.setMeasurementScale", json!({"units": " "})).is_err());
assert!(s.execute("image.analysis.setMeasurementScale", json!({"preset": "bogus"})).is_err());
s.execute("image.analysis.setMeasurementScale", json!({"preset": "default"})).unwrap();
assert!(d(&s).measurement.scale.is_default());
assert!(s.undo());
assert_eq!(d(&s).measurement.scale.units, "cm");
}
#[test]
fn features_area_perimeter_circularity() {
// A 10×10 square and a 1-pixel diagonal line of 5 pixels (one 8-connected feature).
let (w, h) = (30usize, 20usize);
let mut mask = vec![false; w * h];
for y in 2..12 {
for x in 2..12 {
mask[y * w + x] = true;
}
}
for i in 0..5 {
mask[(5 + i) * w + 20 + i] = true;
}
let gray: Vec<f32> = (0..w * h).map(|i| if i % 2 == 0 { 0.0 } else { 1.0 }).collect();
let f = measure_features(&mask, &gray, w, h, (100, 200), 255.0);
assert_eq!(f.len(), 2);
assert_eq!(f[0].area, 100.0);
assert_eq!(f[0].bounds, Rect::new(102, 202, 112, 212));
// Straight edges exact, corners cut by √½: 4·9 + 4·√½.
assert!((f[0].perimeter - (36.0 + 4.0 * std::f64::consts::FRAC_1_SQRT_2)).abs() < 1e-9, "{}", f[0].perimeter);
assert!(f[0].circularity() > 0.75 && f[0].circularity() < 0.9);
assert_eq!(f[0].gray_min, 0.0);
assert_eq!(f[0].gray_max, 255.0);
assert!((f[0].gray_mean - 127.5).abs() < 3.0);
assert_eq!(f[0].histogram.iter().sum::<u64>(), 100);
assert_eq!(f[1].area, 5.0);
// A disc's circularity approaches 1.
let (w, h) = (101usize, 101usize);
let mask: Vec<bool> = (0..w * h).map(|i| ((i % w) as f64 - 50.0).hypot((i / w) as f64 - 50.0) <= 40.0).collect();
let f = measure_features(&mask, &vec![0.5; w * h], w, h, (0, 0), 1.0);
assert_eq!(f.len(), 1);
// Digital discs read ≈0.89 (the 8-direction contour is ~5 % long), as in ImageJ.
assert!(f[0].circularity() > 0.85, "{}", f[0].circularity());
assert!((f[0].perimeter / (2.0 * std::f64::consts::PI * 40.0) - 1.0).abs() < 0.08, "{}", f[0].perimeter);
assert!((f[0].gray_median - 0.5).abs() < 1e-4);
}
#[test]
fn record_selection_rows_respect_scale_and_data_points() {
for depth in [8, 16, 32] {
let mut s = session(64, 48, depth);
s.execute("select.all", json!({})).unwrap_or_default();
s.execute("select.deselect", json!({})).unwrap_or_default();
// Two separate rectangles.
s.execute("select.rect", json!({"x": 4, "y": 4, "width": 10, "height": 10})).unwrap();
s.execute("select.rect", json!({"x": 30, "y": 20, "width": 20, "height": 5, "mode": "add"})).unwrap();
s.execute("image.analysis.setMeasurementScale", json!({"pixelLength": 2, "logicalLength": 1, "units": "mm"})).unwrap();
let r = s.execute("image.analysis.recordMeasurements", json!({"dateTime": "2026-10-01T00:00:00Z"})).unwrap();
assert_eq!(r["source"], "selection");
let rows = r["rows"].as_array().unwrap();
assert_eq!(rows.len(), 3, "summary + 2 features");
let sum = &rows[0]["values"];
assert_eq!(sum["count"], 2);
assert_eq!(sum["area"], (100.0 + 100.0) * 0.25);
assert_eq!(sum["scaleUnits"], "mm");
assert_eq!(sum["source"], "Selection");
// White background: gray max reads as white in the depth's range.
let white = match depth {
8 => 255.0,
16 => 32768.0,
_ => 1.0,
};
assert!((sum["grayMean"].as_f64().unwrap() - white).abs() < white * 0.01, "{depth}: {sum}");
assert_eq!(rows[1]["values"]["label"], "Feature 1");
assert_eq!(rows[2]["values"]["width"], 10.0);
assert_eq!(rows[2]["values"]["height"], 2.5);
}
// Data points filter the columns.
let mut s = session(20, 20, 8);
s.execute("image.analysis.selectDataPoints", json!({"selection": ["label", "area"]})).unwrap();
let r = s.execute("image.analysis.recordMeasurements", json!({"source": "selection"})).unwrap();
let v = r["rows"][0]["values"].as_object().unwrap();
assert_eq!(v.keys().collect::<Vec<_>>(), ["area", "label"]);
assert_eq!(v["area"], 400.0, "no selection: the whole image");
let r = s.execute("image.analysis.selectDataPoints", json!({"selection": {"perimeter": true, "label": false}})).unwrap();
assert_eq!(r["dataPoints"]["selection"], json!(["area", "perimeter"]));
assert!(s.execute("image.analysis.selectDataPoints", json!({"ruler": ["area"]})).is_err());
s.execute("image.analysis.selectDataPoints", json!({"reset": true})).unwrap();
assert_eq!(s.analysis.data_points, DataPoints::default());
}
#[test]
fn ruler_protractor_and_record() {
let mut s = session(100, 100, 8);
let r = s.execute("image.analysis.rulerTool", json!({"start": [10, 50], "end": [40, 10]})).unwrap();
let i = &r["info"];
assert_eq!(i["l1"], 50.0);
assert_eq!(i["w"], 30.0);
assert_eq!(i["h"], -40.0);
assert!((i["angle"].as_f64().unwrap() - 53.1301).abs() < 1e-3);
// The ruler doesn't add history and doesn't dirty the document.
assert_eq!(s.active().unwrap().history.past_len(), 0);
assert!(!s.active().unwrap().is_dirty());
// Protractor: a second arm straight down from the vertex.
let r = s.execute("image.analysis.rulerTool", json!({"protractor": [10, 90]})).unwrap();
assert!((r["info"]["angle"].as_f64().unwrap() - (180.0 - 36.8699)).abs() < 1e-3, "{r}");
assert_eq!(r["info"]["l2"], 40.0);
s.execute("image.analysis.setMeasurementScale", json!({"pixelLength": 10, "logicalLength": 1, "units": "cm"})).unwrap();
let r = s.execute("image.analysis.recordMeasurements", json!({})).unwrap();
assert_eq!(r["source"], "ruler");
assert_eq!(r["rows"][0]["values"]["length"], 5.0);
assert!(s.execute("image.analysis.rulerTool", json!({"start": [1, 1]})).is_err());
s.execute("image.analysis.rulerTool", json!({"clear": true})).unwrap();
assert!(d(&s).measurement.ruler.is_none());
assert!(!s.is_enabled("image.analysis.straightenLayer"));
}
#[test]
fn straighten_layer_and_image() {
// A layer: rotated about the canvas centre, canvas unchanged.
let mut s = session(80, 60, 8);
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("select.rect", json!({"x": 10, "y": 28, "width": 60, "height": 4})).unwrap();
s.execute("edit.fill", json!({"color": "#000000"})).unwrap_or_default();
s.execute("select.deselect", json!({})).unwrap();
s.execute("image.analysis.rulerTool", json!({"start": [10, 30], "end": [70, 25]})).unwrap();
let before = s.active().unwrap().history.past_len();
let r = s.execute("image.analysis.straightenLayer", json!({})).unwrap();
assert_eq!(r["cropped"], false);
assert!((r["angle"].as_f64().unwrap() - 4.7636).abs() < 1e-3, "{r}");
assert_eq!(s.active().unwrap().history.past_len(), before + 1, "one history step");
assert_eq!(d(&s).size.width, 80);
assert!(d(&s).measurement.ruler.is_none());
// The Background: rotate the canvas and crop to the image.
let mut s = session(80, 60, 16);
s.execute("image.analysis.rulerTool", json!({"start": [0, 0], "end": [40, 40]})).unwrap();
let r = s.execute("image.analysis.straightenLayer", json!({})).unwrap();
assert_eq!(r["cropped"], true);
assert_eq!(r["angle"].as_f64().unwrap().abs(), 45.0);
let nd = d(&s);
assert!(nd.size.width < 80 && nd.size.height < 60);
assert!(s.undo());
assert_eq!(d(&s).size.width, 80);
}
#[test]
fn inscribed_rect() {
let (w, h) = inscribed(100.0, 50.0, 0.0);
assert!((w - 100.0).abs() < 1e-9 && (h - 50.0).abs() < 1e-9);
let (w, h) = inscribed(100.0, 100.0, std::f64::consts::FRAC_PI_4);
assert!((w - 70.710678).abs() < 1e-4 && (h - 70.710678).abs() < 1e-4);
}
#[test]
fn count_groups_markers_and_undo() {
let mut s = session(100, 100, 8);
let r = s.execute("image.analysis.countTool", json!({})).unwrap();
assert_eq!(r["total"], 0);
let r = s.execute("count.add", json!({"x": 10, "y": 10})).unwrap();
assert_eq!(r["number"], 1);
assert_eq!(r["groups"][0]["name"], "Count Group 1");
s.execute("count.add", json!({"x": 20, "y": 20})).unwrap();
s.execute("count.newGroup", json!({"name": "Cells", "color": "#00ff00"})).unwrap();
let r = s.execute("count.add", json!({"x": 50, "y": 50})).unwrap();
assert_eq!(r["activeGroup"], 1);
assert_eq!(r["groups"][1]["count"], 1);
assert_eq!(r["total"], 3);
// Remove by position, move by index.
s.execute("count.remove", json!({"x": 21, "y": 19})).unwrap();
assert_eq!(d(&s).measurement.count_groups[0].points, vec![[10.0, 10.0]]);
s.execute("count.move", json!({"group": 0, "index": 0, "to": [12, 13]})).unwrap();
assert_eq!(d(&s).measurement.count_groups[0].points[0], [12.0, 13.0]);
assert!(s.execute("count.remove", json!({"x": 90, "y": 90})).is_err());
s.execute("count.setGroup", json!({"group": 0, "markerSize": 50, "labelSize": 20, "visible": false, "name": "Spots"})).unwrap();
let g = &d(&s).measurement.count_groups[0];
assert_eq!((g.marker_size, g.label_size, g.visible, g.name.as_str()), (10, 20, false, "Spots"));
// Record counts.
let r = s.execute("image.analysis.recordMeasurements", json!({"source": "count"})).unwrap();
assert_eq!(r["rows"][0]["values"]["count"], 2);
s.execute("count.clear", json!({})).unwrap();
assert_eq!(d(&s).measurement.count_total(), 1, "clears the active group only");
s.execute("count.clear", json!({"group": "all"})).unwrap();
assert_eq!(d(&s).measurement.count_total(), 0);
assert!(s.undo());
assert_eq!(d(&s).measurement.count_total(), 1);
s.execute("count.deleteGroup", json!({"group": 1})).unwrap();
assert_eq!(d(&s).measurement.count_groups.len(), 1);
assert_eq!(d(&s).measurement.active_count_group, 0);
s.execute("count.setGroup", json!({"group": 0})).unwrap();
assert!(s.execute("count.setGroup", json!({"group": 7})).is_err());
assert!(s.execute("count.add", json!({"x": 1})).is_err());
}
#[test]
fn deleting_an_earlier_count_group_keeps_the_active_group() {
let mut s = session(100, 100, 8);
s.execute("count.add", json!({"x": 10, "y": 10})).unwrap();
for _ in 0..3 {
s.execute("count.newGroup", json!({})).unwrap();
}
s.execute("count.setGroup", json!({"group": 2})).unwrap();
let r = s.execute("count.deleteGroup", json!({"group": 0})).unwrap();
assert_eq!(r["activeGroup"], 1);
assert_eq!(r["groups"][1]["name"], "Count Group 3");
// A later group leaves the active index alone.
let r = s.execute("count.deleteGroup", json!({"group": 2})).unwrap();
assert_eq!(r["activeGroup"], 1);
assert_eq!(r["groups"][1]["name"], "Count Group 3");
// Deleting the active last group selects the new last group.
let r = s.execute("count.deleteGroup", json!({})).unwrap();
assert_eq!(r["activeGroup"], 0);
assert_eq!(r["groups"][0]["name"], "Count Group 2");
}
#[test]
fn log_delete_and_csv_export() {
let mut s = session(10, 10, 8);
assert!(!s.is_enabled("measurementLog.delete"));
s.execute("image.analysis.recordMeasurements", json!({"dateTime": "t"})).unwrap();
s.execute("image.analysis.rulerTool", json!({"start": [0, 0], "end": [3, 4]})).unwrap();
s.execute("image.analysis.recordMeasurements", json!({"source": "ruler", "dateTime": "t"})).unwrap();
let r = s.execute("measurementLog.list", json!({})).unwrap();
assert_eq!(r["rows"].as_array().unwrap().len(), 2);
let r = s.execute("measurementLog.export", json!({})).unwrap();
let csv = r["csv"].as_str().unwrap();
let mut lines = csv.lines();
let header = lines.next().unwrap();
assert!(header.starts_with("Label,Date and Time,Document,Source,Scale"));
assert!(header.contains("Length") && header.contains("Gray Value (Mean)"));
assert!(lines.next().unwrap().starts_with("Measurement 1,t,"));
assert!(lines.next().unwrap().contains(",5.0,"), "{csv}");
#[cfg(not(target_arch = "wasm32"))]
{
let path = std::env::temp_dir().join(format!("pc-log-{}.csv", std::process::id()));
let r = s.execute("measurementLog.export", json!({"path": path.to_str().unwrap(), "rows": [2]})).unwrap();
assert_eq!(r["rows"], 1);
let text = std::fs::read_to_string(&path).unwrap();
assert_eq!(text.lines().count(), 2);
let _ = std::fs::remove_file(path);
}
s.execute("measurementLog.delete", json!({"rows": [1]})).unwrap();
assert_eq!(s.analysis.log.len(), 1);
assert!(s.execute("measurementLog.delete", json!({})).is_err());
s.execute("measurementLog.delete", json!({"all": true})).unwrap();
assert!(s.analysis.log.is_empty());
assert_eq!(csv_field(&json!("a,\"b\"")), "\"a,\"\"b\"\"\"");
}
#[test]
fn place_scale_marker_builds_one_group_in_one_step() {
let mut s = session(400, 300, 8);
s.execute("image.analysis.setMeasurementScale", json!({"pixelLength": 50, "logicalLength": 1, "units": "cm"})).unwrap();
let before = s.active().unwrap().history.past_len();
let r = s.execute("image.analysis.placeScaleMarker", json!({"color": "black"})).unwrap();
assert_eq!(s.active().unwrap().history.past_len(), before + 1);
// Nice length near width/5 = 80 px = 1.6 cm → 1 cm = 50 px.
assert_eq!(r["label"], "1 cm");
assert_eq!(r["pixels"], 50.0);
let doc = d(&s);
let g = doc.layers.last().unwrap();
assert_eq!(g.name, "Measurement Scale Marker");
let kids = g.children().unwrap();
assert_eq!(kids.len(), 2, "bar + text");
let bar = kids.iter().find(|l| l.name == "Scale Bar").unwrap();
let b = bar.surface().unwrap().content_bounds();
assert_eq!(b.width(), 50);
assert!(b.y1 < 300);
// Without text, white, on top.
let r = s.execute("image.analysis.placeScaleMarker", json!({"length": 2, "displayText": false, "color": "white", "textPosition": "top"})).unwrap();
assert_eq!(r["pixels"], 100.0);
assert!(r["text"].is_null());
assert!(s.execute("image.analysis.placeScaleMarker", json!({"length": 100})).is_err(), "wider than the image");
assert!(s.execute("image.analysis.placeScaleMarker", json!({"color": "red"})).is_err());
assert!(s.undo());
assert!(s.undo());
assert!(d(&s).layers.iter().all(|l| l.name != "Measurement Scale Marker"));
}
#[test]
fn place_scale_marker_lays_out_its_label_in_points_at_high_resolution() {
// `fontSize` is in points and `type.create` renders it at resolution/72 px per point, but the
// layout treated it as pixels: at 300 ppi a 12 pt label (50 px) overlapped the bar.
for res in [72.0, 300.0] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 1200, "height": 900, "resolution": res, "background": "white"})).unwrap();
let r = s.execute("image.analysis.placeScaleMarker", json!({"fontSize": 12, "textPosition": "bottom"})).unwrap();
let bar_bottom = r["barRect"][3].as_f64().unwrap();
let doc = d(&s);
let Some(LayerContent::Text(t)) = doc.layer(LayerId(r["text"].as_u64().unwrap())).map(|l| &l.content) else { panic!("a type layer: {r}") };
assert_eq!(t.size_pt, 12.0, "{res} ppi: type.create still gets points");
let layout = photocraft_text::shared().lock().unwrap().layout(t, doc.resolution_dpi);
let b = layout.bounds().expect("the label has a line");
let ty = t.transform.m[5];
let (top, bottom) = (f64::from(b[1]) + ty, f64::from(b[3]) + ty);
assert!(top >= bar_bottom - 0.5, "{res} ppi: label top {top} overlaps the bar ending at {bar_bottom}: {r}");
assert!(bottom <= 900.5, "{res} ppi: label bottom {bottom} stays on the canvas: {r}");
}
}
#[test]
fn place_scale_marker_on_a_short_document_errors_without_panicking() {
// A document under 8 px tall inverted the bar-height clamp and panicked (#932). Call `run`
// directly too: dispatch would catch the panic and hide it behind a generic error.
for (w, h, p) in [
(100, 4, json!({})),
(100, 1, json!({})),
(100, 7, json!({"displayText": false, "textPosition": "top"})),
(300_000, 3, json!({"fontSize": 1000})),
(2, 7, json!({"length": 1})),
(7, 7, json!({"length": 7, "fontSize": 1})),
] {
for h in [h, 1, 2, 3, 5, 6] {
let mut s = session(w, h, 8);
let before = s.active().unwrap().history.past_len();
let e = place_scale_marker(&mut s, &p).unwrap_err().to_string();
assert!(e.contains("tall"), "{w}x{h}: {e}");
let e = s.execute("image.analysis.placeScaleMarker", p.clone()).unwrap_err().to_string();
assert!(!e.contains("internal error"), "{w}x{h}: {e}");
assert_eq!(s.active().unwrap().history.past_len(), before, "{w}x{h}: nothing added");
assert!(d(&s).layers.iter().all(|l| l.name != "Measurement Scale Marker"));
}
}
// Control: 8 px is the shortest that fits, and tall, narrow or roomy documents still get a
// marker that lies on the canvas.
for (w, h, p) in [(100, 8, json!({})), (100, 100, json!({})), (20, 4000, json!({})), (2, 8, json!({"length": 2}))] {
let mut s = session(w, h, 8);
let r = place_scale_marker(&mut s, &p).unwrap();
let b: Vec<i64> = r["barRect"].as_array().unwrap().iter().map(|v| v.as_i64().unwrap()).collect();
assert!(0 <= b[0] && b[0] < b[2] && b[2] <= i64::from(w), "{w}x{h}: bar on the canvas: {r}");
assert!(0 <= b[1] && b[1] < b[3] && b[3] <= i64::from(h), "{w}x{h}: bar on the canvas: {r}");
assert_eq!(s.active().unwrap().history.past_len(), 1);
let doc = d(&s);
let bar = doc.layers.last().unwrap().children().unwrap().iter().find(|l| l.name == "Scale Bar").unwrap();
assert_eq!(f64::from(bar.surface().unwrap().content_bounds().width()), r["pixels"].as_f64().unwrap(), "{w}x{h}");
}
}
#[test]
fn analysis_info_and_cmyk_document() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 30, "height": 20, "mode": "cmyk", "background": "white"})).unwrap();
assert_eq!(d(&s).mode, ColorMode::Cmyk);
s.execute("count.add", json!({"x": 3, "y": 4})).unwrap();
let r = s.execute("image.analysis.recordMeasurements", json!({"source": "selection"})).unwrap();
assert_eq!(r["rows"][0]["values"]["area"], 600.0);
let r = s.execute("image.analysis.info", json!({})).unwrap();
assert_eq!(r["count"]["total"], 1);
assert_eq!(r["logRows"], 1);
}
#[test]
fn now_iso_shape() {
let t = now_iso();
assert_eq!(t.len(), 20, "{t}");
assert!(t.ends_with('Z') && &t[4..5] == "-");
}
#[test]
fn count_set_group_takes_sizes_sent_as_whole_floats() {
// The options bar sends `{"markerSize": 8.0, "labelSize": 20.0}`; `as_u64` read neither, so
// the fields did nothing.
let mut s = session(100, 100, 8);
s.execute("count.add", json!({"x": 10, "y": 10})).unwrap();
s.execute("count.setGroup", json!({"markerSize": 8.0, "labelSize": 20.0})).unwrap();
let g = &d(&s).measurement.count_groups[0];
assert_eq!((g.marker_size, g.label_size), (8, 20));
// Out-of-range whole numbers clamp, also huge ones that `as u32` would have wrapped.
s.execute("count.setGroup", json!({"markerSize": 4_294_967_297u64, "labelSize": 1e300})).unwrap();
let g = &d(&s).measurement.count_groups[0];
assert_eq!((g.marker_size, g.label_size), (10, 72));
}
#[test]
fn count_set_group_rejects_sizes_that_are_not_whole_numbers() {
let mut s = session(100, 100, 8);
s.execute("count.add", json!({"x": 10, "y": 10})).unwrap();
s.execute("count.setGroup", json!({"markerSize": 3, "labelSize": 12})).unwrap();
let past = s.active().unwrap().history.past_len();
for p in [
json!({"markerSize": 8.5}),
json!({"labelSize": 12.25}),
json!({"markerSize": -2}),
json!({"labelSize": -9.0}),
json!({"markerSize": "8"}),
json!({"labelSize": true}),
] {
let err = s.execute("count.setGroup", p.clone()).unwrap_err();
assert!(matches!(err, EngineError::BadParams { .. }), "{p}: {err}");
}
let g = &d(&s).measurement.count_groups[0];
assert_eq!((g.marker_size, g.label_size), (3, 12), "a rejected call changes nothing");
assert_eq!(s.active().unwrap().history.past_len(), past);
}
#[test]
fn count_add_rejects_unknown_groups_without_changing_the_document() {
for groups in [0, 1, 3] {
for group in [(groups as u64).max(1), u64::MAX] {
let mut s = session(10, 10, 8);
for _ in 0..groups {
s.execute("count.newGroup", json!({})).unwrap();
}
let before = d(&s);
let history = s.active().unwrap().history.past_len();
let journal = s.journal.len();
let error = s.execute("count.add", json!({"x": 2, "y": 3, "group": group})).unwrap_err();
assert!(error.to_string().contains("no such group"), "{error}");
assert!(Arc::ptr_eq(&before, &d(&s)), "an invalid target must not create a group or add a marker");
assert_eq!(s.active().unwrap().history.past_len(), history);
assert_eq!(s.journal.len(), journal);
}
}
}
#[test]
fn count_add_keeps_first_group_creation_and_targets_the_requested_group() {
let mut s = session(10, 10, 8);
// Group zero can be requested before the first marker creates it.
let r = s.execute("count.add", json!({"x": 2, "y": 3, "group": 0})).unwrap();
assert_eq!(r["groups"][0]["points"], json!([[2.0, 3.0]]));
s.execute("count.newGroup", json!({})).unwrap();
let before = d(&s).measurement.clone();
let history = s.active().unwrap().history.past_len();
let r = s.execute("count.add", json!({"x": 4, "y": 5, "group": 0})).unwrap();
assert_eq!(r["activeGroup"], 1);
assert_eq!(r["groups"][0]["points"], json!([[2.0, 3.0], [4.0, 5.0]]));
assert_eq!(r["groups"][1]["count"], 0);
assert_eq!(s.active().unwrap().history.past_len(), history + 1);
let after = d(&s).measurement.clone();
assert!(s.undo());
assert_eq!(d(&s).measurement, before);
assert!(s.redo());
assert_eq!(d(&s).measurement, after);
// Omitting the group still uses the active group.
let r = s.execute("count.add", json!({"x": 6, "y": 7})).unwrap();
assert_eq!(r["groups"][1]["points"], json!([[6.0, 7.0]]));
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn iso_from_unix_matches_the_old_cycle_conversion_including_beyond_chrono() {
use super::iso_from_unix;
assert_eq!(iso_from_unix(0), "1970-01-01T00:00:00Z");
assert_eq!(iso_from_unix(951_782_400), "2000-02-29T00:00:00Z");
assert_eq!(iso_from_unix(1_791_446_357), "2026-10-08T07:59:17Z");
// Years past 9999 keep the plain zero-padded year (no `+`).
assert_eq!(iso_from_unix(253_402_300_800), "10000-01-01T00:00:00Z");
// Beyond chrono's year range the old hand-rolled code still converted; pin that output
// (never the epoch) in both directions and at the i64 extremes.
assert_eq!(iso_from_unix(10_000_000_000_000), "318857-05-20T17:46:40Z");
assert_eq!(iso_from_unix(-10_000_000_000_000), "-314918-08-13T06:13:20Z");
assert_eq!(iso_from_unix(i64::MAX), "292277026596-12-04T15:30:07Z");
assert_eq!(iso_from_unix(i64::MIN), "-292277022657-01-27T08:29:52Z");
}