Browse / github.com/storytold/photocraft / patch
Fix GPU timing validation, first-frame CJK fallback and GPU test locking
Fix GPU timing validation, first-frame CJK fallback and GPU test locking This fixes CJK fallback in builds without bundled craft fonts and makes GPU-dependent tests more reliable. - Install the existing system-font provider in native builds without craft fonts, and wait for Fontconfig scanning in the brush test harness. - Compare GPU timings against equivalent resident workloads and add coverage for aggregation across four batches. The runtime timer is unchanged. - Use the shared GPU lock in CPU-canvas tests that create a wgpu harness. - Update the contributor list. ### Validation - GPU crate: 41 unit and 108 integration tests passed. - UI suite without craft fo
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- mockydevs (imported from pull request #9800, open)
- Base
7ac7b8b106acccf6826d45ed5c9e481dfe341035- Size
- +235 −44 in 6 files
- Review
- pending
- Shared
- 2026-10-28
- Id
90384d7dfbcfc7cbb9c021a9
How it went
No other operator has reported using it yet.
Diff
raw · contributors/people.toml crates/gpu/tests/it/timing.rs crates/ui-egui/src/canvas/cpu_background_tests.rs crates/ui-egui/src/panels/brush_panel/tests.rs crates/ui-egui/src/text/cjk_fonts.rs crates/ui-egui/src/text/cjk_fonts/tests.rs
@@ -282,3 +282,6 @@ sync_github_name = false [people.jasallen] real_name = "Jason DeBoever" sync_github_name = true++[people.mockydevs]+real_name = "Don Omondi"@@ -17,7 +17,7 @@ use std::time::Instant; use photocraft_color::{BlendMode, Color, ColorMode, SampleType}; use photocraft_doc::{Document, Layer, Size}; use photocraft_geom::Rect;-use photocraft_gpu::{Compositor, DeviceHealth, GpuTiming, timing};+use photocraft_gpu::{Compositor, DeviceHealth, GpuTiming, Stats, timing}; /// Pixels in `r`, as the compositor counts them. fn area(r: Rect) -> u64 {@@ -92,8 +92,14 @@ fn document(size: Size) -> Document { /// Render `region` and return (submit ms, waited-for wall ms, the measurement). fn measure(device: &wgpu::Device, queue: &wgpu::Queue, comp: &mut Compositor, doc: &Document, region: Rect) -> (f64, f64, Option<GpuTiming>) {+ let (submit_ms, wall_ms, timing, _) = measure_with_stats(device, queue, comp, doc, region);+ (submit_ms, wall_ms, timing)+}++/// [`measure`], plus the refresh's own statistics (how often it submitted, what it uploaded).+fn measure_with_stats(device: &wgpu::Device, queue: &wgpu::Queue, comp: &mut Compositor, doc: &Document, region: Rect) -> (f64, f64, Option<GpuTiming>, Stats) { let t0 = Instant::now();- comp.render(device, queue, doc, region, |_, _| {}).expect("the document composites on the GPU");+ let stats = comp.render(device, queue, doc, region, |_, _| {}).expect("the document composites on the GPU"); let submit_ms = t0.elapsed().as_secs_f64() * 1000.0; let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }); let wall_ms = t0.elapsed().as_secs_f64() * 1000.0;@@ -106,9 +112,12 @@ fn measure(device: &wgpu::Device, queue: &wgpu::Queue, comp: &mut Compositor, do } let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }); }- (submit_ms, wall_ms, timing)+ (submit_ms, wall_ms, timing, stats) } +/// The reported GPU time of a GPU-bound 6 MP refresh is its execution, not its encode-and-submit:+/// several times the submit time, within the waited-for wall clock and most of it, and scaling+/// with the pixels refreshed (a quarter of the canvas costs well under the whole). #[test] fn gpu_time_tracks_execution_not_submission() { let _lock = crate::GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);@@ -120,19 +129,21 @@ fn gpu_time_tracks_execution_not_submission() { // Warm the residency and pipelines: the first refresh uploads every layer. let _ = measure(&device, &queue, &mut comp, &doc, full); - // Best of three, by the smallest encode. Everything below is a ratio against `submit_ms`,- // and CPU contention on a shared machine only ever inflates it — one run that happened to- // encode behind a busy scheduler (8.2 ms against a typical 1.0 ms) failed the comparison- // while the measurement itself was right. The GPU side is unaffected by that contention,- // so taking the least-disturbed encode tests the same property without the false failure.+ // Best of five, by the shortest waited-for refresh. Everything below compares the GPU span+ // with `submit_ms` and `wall_ms`, and contention on a shared machine only ever inflates those+ // two: one run that happened to encode behind a busy scheduler (8.2 ms against a typical+ // 1.0 ms) failed the comparison, and on an RX 5700 XT about one wait in five took ~11 ms+ // against a typical 1.3 ms while the GPU span stayed at 1.1 ms. The span is unaffected by+ // that contention, so taking the least-disturbed refresh (whose wall clock includes its+ // encode) tests the same property without the false failure. let mut best: Option<(f64, f64, Option<GpuTiming>)> = None;- for _ in 0..3 {+ for _ in 0..5 { let run = measure(&device, &queue, &mut comp, &doc, full);- if run.2.is_some() && best.as_ref().is_none_or(|b| run.0 < b.0) {+ if run.2.is_some() && best.as_ref().is_none_or(|b| run.1 < b.1) { best = Some(run); } }- let (submit_ms, wall_ms, timing) = best.expect("at least one of three refreshes reports a GPU time");+ let (submit_ms, wall_ms, timing) = best.expect("at least one of five refreshes reports a GPU time"); let t = timing.expect("a hardware device with timestamp queries must report a GPU time"); eprintln!("full {}x{}: submit {submit_ms:.1} ms, waited wall {wall_ms:.1} ms, gpu {:.1} ms", size.width, size.height, t.ms); @@ -177,40 +188,183 @@ fn an_unmeasured_compositor_reports_nothing() { assert_eq!(comp.take_gpu_timing(), None, "no measurement is reported rather than a wrong one"); } +/// A refresh that flushes several command buffers under a tight memory budget reports one+/// measurement with a span per command buffer. Its summed time stays within the waited-for wall+/// clock and reaches at least 60% of the same composite's time in a single command buffer, which+/// timing only the last command buffer would miss. #[test] fn a_refresh_that_submits_several_times_is_one_measurement() { let _lock = crate::GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);- let Some((device, queue, mut comp)) = gpu() else { return };+ let Some((device, queue, mut batched)) = gpu() else { return }; // A budget far below the document's layer pages makes the refresh evict as it goes, which is // what makes it submit in batches: the measurement must span all of them, not just the last.- comp.set_memory_budget(24 << 20);+ batched.set_memory_budget(24 << 20);+ // The same composite with every page resident, in one command buffer: the reference for the+ // GPU work the batched refresh's spans must add up to.+ let mut whole = Compositor::new(&device);+ assert!(whole.measure_gpu_time(&device, &queue), "the device has the features, so the timer must build");+ // Both draw every pass: a reused lower-stack composite would make either do less work.+ batched.set_composite_cache_budget(0);+ whole.set_composite_cache_budget(0); let size = Size::new(3000, 2000); let doc = document(size); let full = Rect::from_xywh(0, 0, size.width, size.height); - let _ = measure(&device, &queue, &mut comp, &doc, full);- let (submit_ms, wall_ms, timing) = measure(&device, &queue, &mut comp, &doc, full);- let t = timing.expect("a batched refresh is measured like any other");- eprintln!("batched: {} command buffers, gpu {:.1} ms, submit {submit_ms:.1} ms, waited wall {wall_ms:.1} ms", t.spans, t.ms);- assert_eq!(t.px, area(full));+ let _ = measure(&device, &queue, &mut whole, &doc, full);+ let _ = measure(&device, &queue, &mut batched, &doc, full);+ // Interleaved, best of six each: GPU clocks ramp and settle between refreshes, and the+ // fastest run of each is the one at full clock, so the two are compared like for like. The+ // order alternates by sample, so neither side always runs on clocks and caches the other has+ // just warmed; an even count gives each order three samples.+ let (mut one, mut sum) = (f64::INFINITY, None::<(GpuTiming, f64, f64)>);+ for sample in 0..6 {+ let resident_first = sample % 2 == 0;+ let mut resident = if resident_first { measure(&device, &queue, &mut whole, &doc, full).2 } else { None };+ let (submit_ms, wall_ms, timing, stats) = measure_with_stats(&device, &queue, &mut batched, &doc, full);+ if !resident_first {+ resident = measure(&device, &queue, &mut whole, &doc, full).2;+ }+ if let Some(r) = &resident {+ assert_eq!(r.spans, 1, "a resident refresh of this size submits once");+ one = one.min(r.ms);+ }+ let t = timing.expect("a batched refresh is measured like any other");+ assert_eq!(t.px, area(full));+ // Structure: every command buffer the refresh submitted is one span of the measurement.+ assert_eq!(t.spans as usize, stats.flushes + 1, "{} flushes but {} timed command buffers", stats.flushes, t.spans);+ // Upper bound: the spans run inside the refresh, so they can't exceed its waited-for+ // wall clock.+ assert!(t.ms <= wall_ms * 1.25, "summed gpu time {:.1} ms exceeds the whole waited-for refresh {wall_ms:.1} ms", t.ms);+ eprintln!(+ "sample {sample} ({} first): resident {}; batched {} command buffers, gpu {:.3} ms, submit {submit_ms:.1} ms, waited wall {wall_ms:.1} ms, {} MB uploaded",+ if resident_first { "resident" } else { "batched" },+ resident.as_ref().map_or("not reported".to_owned(), |r| format!("gpu {:.3} ms", r.ms)),+ t.spans,+ t.ms,+ stats.bytes_uploaded >> 20+ );+ if sum.as_ref().is_none_or(|(b, ..)| t.ms < b.ms) {+ sum = Some((t, submit_ms, wall_ms));+ }+ }+ let (t, submit_ms, wall_ms) = sum.expect("six batched refreshes were measured");+ assert!(one.is_finite(), "the resident refresh reports a GPU time");+ eprintln!("best: batched {:.2} ms in {} command buffers, resident {one:.2} ms in one", t.ms, t.spans); if t.spans < 2 { eprintln!("SKIP the sum: this refresh still fit one command buffer"); return; }- assert!(t.ms <= wall_ms * 1.25, "summed gpu time {:.1} ms exceeds the whole waited-for refresh {wall_ms:.1} ms", t.ms);- // What this bound excludes is summing only the last command buffer. A correct sum measures- // ~85 % of the waited-for GPU time here; the flush thresholds split on accumulated work, so- // no single buffer of the several carries much over half of it, and timing only the last- // would land near 40 %. 0.6 sits between the two with room on both sides for a machine- // whose split is less even than this one's.+ // Lower bound. The batched refresh draws the same passes over the same pixels as the+ // resident one, so its spans together carry at least that GPU work; timing only the last+ // command buffer would report a fraction of it. (`a_split_measurement_sums_every_command_buffer`+ // proves the sum itself with equal, known batches.)+ //+ // Not against the wall clock: a batched refresh evicts and re-uploads its pages, and+ // `Queue::write_texture` uploads run in wgpu's own command buffer outside the spans by design+ // (see `timing.rs`). On an integrated GPU the composite dominates the wait, but on a discrete+ // one (RX 5700 XT) the composite took ~2 ms of a ~25 ms wait that was PCIe uploads and fence+ // latency, so a `wall - submit` bound fails a correct sum there. assert!(- t.ms >= (wall_ms - submit_ms) * 0.6,- "summed gpu time {:.1} ms is far below the {:.1} ms the refresh waited: is only the last command buffer timed?",+ t.ms >= one * 0.6,+ "summed gpu time {:.2} ms over {} command buffers is well below the {one:.2} ms the same composite takes in one: is only the last command buffer timed? ({submit_ms:.1} ms submit, {wall_ms:.1} ms wall)", t.ms,- wall_ms - submit_ms+ t.spans ); } +/// The sum itself, with known work: the same GPU work recorded into one command buffer, or split+/// over several that are submitted one after the other (as a flushing refresh does), measures+/// the same. Timing only the first or the last of four equal batches would report a quarter.+#[test]+fn a_split_measurement_sums_every_command_buffer() {+ let _lock = crate::GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);+ let Some((device, queue, _)) = gpu() else { return };+ const BATCHES: usize = 4;+ // One batch: a 64 MiB buffer copied four times (well inside every adapter's default limits),+ // a few milliseconds on an integrated GPU and about one on a discrete one.+ const BYTES: u64 = 64 << 20;+ let buffer = |usage| device.create_buffer(&wgpu::BufferDescriptor { label: None, size: BYTES, usage, mapped_at_creation: false });+ let (src, dst) = (buffer(wgpu::BufferUsages::COPY_SRC), buffer(wgpu::BufferUsages::COPY_DST));+ let batch = |encoder: &mut wgpu::CommandEncoder| {+ for _ in 0..4 {+ encoder.copy_buffer_to_buffer(&src, 0, &dst, 0, BYTES);+ }+ };+ let mut timer = timing::GpuTimer::new(&device, &queue).expect("the device has timestamp queries");+ let new_encoder = || device.create_command_encoder(&Default::default());+ let land = |timer: &mut timing::GpuTimer| {+ let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None });+ (0..8)+ .find_map(|_| {+ let t = timer.take();+ if t.is_none() {+ let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None });+ }+ t+ })+ .expect("the measurement lands")+ };+ let one_buffer = |timer: &mut timing::GpuTimer| {+ let mut encoder = new_encoder();+ timer.start(&mut encoder, 1);+ for _ in 0..BATCHES {+ batch(&mut encoder);+ }+ timer.finish(&mut encoder);+ queue.submit([encoder.finish()]);+ timer.submitted();+ land(timer)+ };+ let split = |timer: &mut timing::GpuTimer| {+ let mut encoder = new_encoder();+ timer.start(&mut encoder, 1);+ for i in 0..BATCHES {+ batch(&mut encoder);+ if i + 1 < BATCHES {+ let mut next = new_encoder();+ timer.split(&mut encoder, &mut next);+ queue.submit([std::mem::replace(&mut encoder, next).finish()]);+ }+ }+ timer.finish(&mut encoder);+ queue.submit([encoder.finish()]);+ timer.submitted();+ land(timer)+ };++ let _ = one_buffer(&mut timer);+ let _ = split(&mut timer);+ // Interleaved, best of six each, alternating which runs first by sample: neither side always+ // runs on clocks and caches the other has just warmed, and each order gets three samples.+ let (mut one, mut sum) = (f64::INFINITY, f64::INFINITY);+ for sample in 0..6 {+ let one_first = sample % 2 == 0;+ let (one_ms, t) = if one_first {+ let one_ms = one_buffer(&mut timer).ms;+ (one_ms, split(&mut timer))+ } else {+ let t = split(&mut timer);+ (one_buffer(&mut timer).ms, t)+ };+ eprintln!(+ "sample {sample} ({} first): one command buffer {one_ms:.3} ms, split over {} {:.3} ms",+ if one_first { "one buffer" } else { "split" },+ t.spans,+ t.ms+ );+ assert_eq!(t.spans as usize, BATCHES, "every command buffer is one span");+ one = one.min(one_ms);+ sum = sum.min(t.ms);+ }+ eprintln!("{BATCHES} batches: one command buffer {one:.2} ms, split over {BATCHES} {sum:.2} ms");+ assert!(one > 0.0, "the copies took measurable GPU time");+ // Equal batches: losing one of four spans reports about 75% of the work even when the+ // span count is still four. Keep the lower bound above that failure, with 15% room for+ // clock variation between the interleaved single-buffer and split measurements.+ assert!(sum >= one * 0.85, "split over {BATCHES} command buffers measured {sum:.2} ms against {one:.2} ms in one: not every span is summed");+ assert!(sum <= one * 1.5, "split over {BATCHES} command buffers measured {sum:.2} ms against {one:.2} ms in one: spans counted twice?");+}+ #[test] fn refreshes_faster_than_the_readback_stay_valid() { let _lock = crate::GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);@@ -67,6 +67,9 @@ type Harness = egui_kittest::Harness<'static, PhotocraftApp>; /// The CPU-canvas fixture on the offscreen software adapter, or `None` when this machine has no /// graphics adapter. Only creating the renderer may be skipped: a failing fixture command or a /// panic in the first frames must fail the test, not pass it as "no adapter".+///+/// Callers hold [`photocraft_testkit::gpu_lock`] for the whole test, like every other GPU harness:+/// building wgpu devices on parallel test threads segfaults on some drivers (testkit `gpu.rs`). fn harness(size: [u32; 2], background: &str) -> Option<Harness> { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); // app.gpu remains None: compose on the CPU, use the harness's software window adapter.@@ -117,8 +120,12 @@ fn fixture_failures_are_not_skipped_as_a_missing_adapter() { let _ = harness([0, 0], "white"); } +/// Skipping the transparency checker behind an opaque CPU canvas renders the same pixels as+/// drawing it, across rotation, horizontal flip and pattern preview. Set+/// `PHOTOCRAFT_CPU_PIXEL_OUTPUT` to a directory to save both frames and a difference report. #[test] fn omitting_an_occluded_checker_preserves_cpu_canvas_pixels() {+ let _gpu = photocraft_testkit::gpu_lock(); let Some(mut h) = harness([320, 240], "white") else { eprintln!("skipping: no offscreen graphics adapter"); return;@@ -157,8 +164,12 @@ fn omitting_an_occluded_checker_preserves_cpu_canvas_pixels() { } } +/// With pattern preview on, the opaque CPU canvas still submits the same checker meshes as the+/// reference path for the document and each visible tiled copy, rotated or flipped; with it off,+/// the hidden checker is omitted. #[test] fn pattern_preview_keeps_the_legacy_checker_for_every_copy() {+ let _gpu = photocraft_testkit::gpu_lock(); let Some(mut h) = harness([320, 240], "white") else { eprintln!("skipping: no offscreen graphics adapter"); return;@@ -195,10 +206,15 @@ fn pattern_preview_keeps_the_legacy_checker_for_every_copy() { assert!(checker_meshes(&h).is_empty(), "ordinary opaque CPU canvases still omit the hidden checker"); } +/// Frame time of a 24 MP CPU canvas with and without the occluded checker, for an opaque and a+/// transparent document: 24 alternating-order samples per path, reported as p50/p95 JSON on+/// stdout (and in `PHOTOCRAFT_CPU_BENCH_OUTPUT`, with before/after frames, when set). Run in+/// release with `--ignored`; it also asserts both paths render the same pixels. #[test] #[ignore = "manual release benchmark: 24 MP CPU canvas on the offscreen software adapter"] fn cpu_canvas_background_benchmark() { use std::time::Instant;+ let _gpu = photocraft_testkit::gpu_lock(); let mut results = Vec::new(); let output = std::env::var_os("PHOTOCRAFT_CPU_BENCH_OUTPUT").map(std::path::PathBuf::from); for background in ["white", "transparent"] {@@ -1338,7 +1338,16 @@ fn app_harness() -> Harness<'static, PhotocraftApp> { app_harness_with_language(crate::i18n::Lang::EN) } +/// [`app_harness`] with its UI drawn in `lang`, under kittest's strict missing-glyph policy, so+/// every label of the Brush tool's options bar, settings window and preset picker must shape in+/// that language from the first frame. fn app_harness_with_language(lang: crate::i18n::Lang) -> Harness<'static, PhotocraftApp> {+ // Kittest's strict missing-glyph policy panics in the first frame that draws a CJK label. The+ // font provider serves it then, but on Linux it may wait for the background Fontconfig lookup+ // when no known font path has the script, so let that finish first (as the other strict+ // all-language tests do).+ #[cfg(target_os = "linux")]+ assert!(crate::cjk_fonts::wait_for_fontconfig_cjk_scan(), "Fontconfig CJK lookup did not finish within 10 seconds"); let mut app = app(); app.ui.tool = crate::state::Tool::Brush; let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).build_ui_state(@@ -30,12 +30,13 @@ //! Builds made with the optional craft-fonts input (`CRAFT_FONTS_DIR`, //! [`photocraft_text::craft_fonts`]) carry Japanese, Chinese, Korean and other faces. A //! per-character [`FontProvider`] serves them, and the system font a locale defers to, in the-//! first frame; the lazy loader remains the later fallback. The provider has no lifetime read-//! cap. It retains at most [`MAX_SYSTEM_CACHE_BYTES`] of unique font-file blobs, charging a-//! collection (TTC) once however many faces it selects; a face that cannot be retained is-//! returned transiently for the current request, so a later Fontconfig candidate is never-//! skipped merely because earlier characters read other fonts.-//! Without craft-fonts (and on the web, which never embeds them) nothing changes.+//! first frame; the lazy loader remains the later fallback. Without craft-fonts the same+//! provider serves the system fonts alone, so the first frame needs no embedded face either.+//! The provider has no lifetime read cap. It retains at most [`MAX_SYSTEM_CACHE_BYTES`] of+//! unique font-file blobs, charging a collection (TTC) once however many faces it selects; a+//! face that cannot be retained is returned transiently for the current request, so a later+//! Fontconfig candidate is never skipped merely because earlier characters read other fonts.+//! The web, which never embeds craft-fonts and has no system font files, installs no provider. use egui::epaint::text::{FontInsert, FontPriority, InsertFontFamily}; use egui::{FallbackRequest, FontData, FontFamily, FontId, FontProvider, Shape};@@ -81,10 +82,11 @@ pub struct Sources { /// provider and lazy loader both use this injection (tests can therefore pin craft-font /// selection without depending on the build input). pub embedded: fn(&'static str) -> Vec<&'static CraftFont>,- /// Install the per-character craft-font provider, so a headless first frame can shape CJK- /// and other scripts before the lazy loader gets a second pass (tests panic on a missing- /// glyph). It selects a face for the character's script instead of putting a broad face into- /// the family stack up front for every later character.+ /// Install the per-character provider (craft faces when the build has them, then system+ /// faces), so a headless first frame can shape CJK and other scripts before the lazy loader+ /// gets a second pass (tests panic on a missing glyph). It selects a face for the+ /// character's script instead of putting a broad face into the family stack up front for+ /// every later character. pub eager_craft_fonts: bool, } @@ -977,6 +979,10 @@ pub fn install(ctx: &egui::Context) { install_with(ctx, Sources::system()); } +/// [`install`] with injected [`Sources`]: (re)installs the lazy fallback loader plugin, resetting+/// one already present, and, when `sources.eager_craft_fonts` is set, the per-character provider+/// that serves craft faces (if the build has them) and system faces in the first frame. Tests use+/// it to pin locale, files and coverage without depending on the machine's fonts. pub fn install_with(ctx: &egui::Context, sources: Sources) { // egui keeps the first plugin of a type. set_fonts removes loaded fallbacks, so reset the // existing loader too; otherwise its tried/loaded sets prevent fonts being added again.@@ -991,9 +997,11 @@ pub fn install_with(ctx: &egui::Context, sources: Sources) { { ctx.add_plugin(CjkFontPlugin(fallback)); }- // The provider resolves a craft face for each missing character in the first frame, before the- // lazy loader's next-pass system fallback. Without it a headless frame panics on CJK text.- if sources.eager_craft_fonts && !craft_fonts::CRAFT_FONTS.is_empty() {+ // The provider resolves a face for each missing character in the first frame, before the lazy+ // loader's next-pass system fallback. Without it a headless frame panics on CJK text. It is+ // installed without craft-fonts too: it then serves the system faces alone, exactly the files+ // the lazy loader would read one frame later, so a strict first frame needs no embedded fonts.+ if sources.eager_craft_fonts { let provider = std::sync::Arc::new(CraftFontProvider::new(&sources, crate::theme::ui_font_scale(ctx))); let as_provider: std::sync::Arc<dyn FontProvider> = provider.clone(); ctx.data_mut(|d| d.insert_temp(craft_provider_id(), provider));@@ -1366,12 +1366,13 @@ fn pending_scan_recovers_through_real_layout_and_the_lazy_reload() { PENDING_READY.store(false, std::sync::atomic::Ordering::Relaxed); } +/// Under the panicking missing-glyph policy, the first frame after a finished scan draws a+/// Traditional Chinese label from the system face alone: the eager provider is installed and+/// serves it even with no embedded craft fonts. #[test] fn strict_policy_first_frame_after_the_scan_is_accepted() {- if craft_fonts::CRAFT_FONTS.is_empty() {- eprintln!("skipping: the eager provider is only installed with craft-fonts");- return;- }+ // No craft-font skip: the sources embed nothing, and the provider must serve the system face+ // alone, with or without craft-fonts in the build. let ctx = egui::Context::default(); ctx.set_missing_glyph_policy(egui::MissingGlyphPolicy::Panic); let sources = Sources {