1
//! Post-layout developer warnings for misused raw text nodes.
2
//!
3
//! Browsers wrap a raw text run in an ANONYMOUS BLOCK whenever it needs one;
4
//! azul does not. A bare `NodeType::Text` therefore has no box of its own —
5
//! no rect, no clip, no layout constraints — and every box-model CSS
6
//! property, every id or class a stylesheet could reach it by, and every
7
//! callback, `tab_index` or `dataset` attached to one is silently inert.
8
//! That silence has shipped real bugs (text escaping its widget, click
9
//! targets that never fire), which is why the raw constructor is named
10
//! `create_p_with_text` and why this pass
11
//! exists: after layout, every text node in a shape azul cannot honor is
12
//! reported to the developer, once per unique finding.
13
//!
14
//! The checks are structural (DOM + computed display), deliberately not
15
//! geometric: they fire deterministically on the first layout of a DOM,
16
//! before any symptom is visible on screen.
17
//!
18
//! This runs after EVERY layout pass, in release, so it is built to cost
19
//! nothing on a clean DOM: the scan allocates only once a finding exists,
20
//! message text (two allocations for the snippet alone) is rendered only for
21
//! a warning that is actually printed, and the per-parent questions ("is
22
//! there a block-level child?", "how many items?") are answered once per
23
//! parent instead of once per text child.
24

            
25
use std::collections::BTreeSet;
26
use std::hash::{Hash, Hasher};
27
use std::mem::discriminant;
28
use std::sync::Mutex;
29

            
30
use azul_core::{
31
    dom::{AttributeType, NodeType},
32
    id::NodeId,
33
    styled_dom::{NodeHierarchyItem, StyledDom},
34
};
35
use azul_css::props::layout::LayoutDisplay;
36

            
37
use crate::solver3::getters::{get_display_property, MultiValue};
38

            
39
/// Distinct findings kept alive process-wide. A DOM that is wrong in a
40
/// hundred places has one bug, not a hundred; past this the developer is
41
/// told the tap was closed rather than having the log drowned.
42
const MAX_DISTINCT_WARNINGS: usize = 32;
43

            
44
/// Findings already reported, keyed by [`dedup_key`], process-wide. Layouts
45
/// re-run constantly (every DOM refresh); a warning that repeats 60 times a
46
/// second is a warning nobody reads.
47
struct Emitted {
48
    keys: BTreeSet<u64>,
49
    capped: bool,
50
}
51

            
52
static EMITTED: Mutex<Emitted> = Mutex::new(Emitted {
53
    keys: BTreeSet::new(),
54
    capped: false,
55
});
56

            
57
/// The suppression tag for this lint, honored from the `AZ_SUPPRESS`
58
/// environment variable (comma-separated list; the common misspelling
59
/// `AZ_SUPRESS` is accepted too). Every emitted warning names it.
60
pub const SUPPRESS_TAG: &str = "bare_text";
61

            
62
/// `AZ_SUPPRESS=bare_text` (checked once): the developer has read the
63
/// warnings and wants them off — e.g. a codebase that deliberately renders
64
/// raw text and accepts the differences from browser behavior.
65
4547
fn is_suppressed() -> bool {
66
    static SUPPRESSED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
67
4547
    *SUPPRESSED.get_or_init(|| {
68
20
        let v = std::env::var("AZ_SUPPRESS")
69
20
            .or_else(|_| std::env::var("AZ_SUPRESS"))
70
20
            .unwrap_or_default();
71
20
        v.split(',').any(|t| t.trim().eq_ignore_ascii_case(SUPPRESS_TAG))
72
20
    })
73
4547
}
74

            
75
/// State on a text node that a text node cannot carry, as a bitset so the
76
/// scan can record a finding without allocating.
77
const INERT_CSS: u8 = 1 << 0;
78
const INERT_IDS_CLASSES: u8 = 1 << 1;
79
const INERT_CALLBACKS: u8 = 1 << 2;
80
const INERT_TAB_INDEX: u8 = 1 << 3;
81
const INERT_DATASET: u8 = 1 << 4;
82
const INERT_CHILDREN: u8 = 1 << 5;
83

            
84
/// One finding, kept as a tag until a message is known to be needed.
85
#[derive(Clone, Copy)]
86
enum Finding {
87
    /// W1 — box-less node carrying state (bitset of the `INERT_*` flags).
88
    Inert(u8),
89
    /// W2 — the text run is the root: nothing owns its box.
90
    NoParent,
91
    /// W2 — text nested directly inside text.
92
    TextParent,
93
    /// W2 — one of several items in a flex/grid container.
94
    FlexItem {
95
        display: LayoutDisplay,
96
        child_count: usize,
97
    },
98
    /// W2 — mixed inline/block content: no line box of its own.
99
    BlockSibling,
100
}
101

            
102
impl Finding {
103
    /// Orders findings on the same node the way the checks are written
104
    /// (state first, placement second) — the scan visits a text node and its
105
    /// parent in different iterations, so the sort needs the tie-break.
106
69550
    const fn rank(self) -> u8 {
107
69550
        match self {
108
345
            Self::Inert(_) => 0,
109
            Self::NoParent => 1,
110
15
            Self::TextParent => 2,
111
29899
            Self::FlexItem { .. } => 3,
112
39291
            Self::BlockSibling => 4,
113
        }
114
69550
    }
115
}
116

            
117
652593
const fn is_text(node_type: &NodeType) -> bool {
118
652593
    matches!(node_type, NodeType::Text(_))
119
652593
}
120

            
121
82844
fn has_ids_or_classes(data: &azul_core::dom::NodeData) -> bool {
122
82844
    data.attributes()
123
82844
        .as_ref()
124
82844
        .iter()
125
82844
        .any(|a| matches!(a, AttributeType::Id(_) | AttributeType::Class(_)))
126
82844
}
127

            
128
70294
fn display_of(styled_dom: &StyledDom, node_id: NodeId) -> LayoutDisplay {
129
70294
    match get_display_property(styled_dom, Some(node_id)) {
130
70294
        MultiValue::Exact(d) => d,
131
        _ => LayoutDisplay::Block,
132
    }
133
70294
}
134

            
135
69616
const fn is_flex_or_grid(display: LayoutDisplay) -> bool {
136
59076
    matches!(
137
69616
        display,
138
        LayoutDisplay::Flex
139
            | LayoutDisplay::InlineFlex
140
            | LayoutDisplay::Grid
141
            | LayoutDisplay::InlineGrid
142
    )
143
69616
}
144

            
145
678
const fn is_block_level(display: LayoutDisplay) -> bool {
146
651
    !matches!(
147
678
        display,
148
        LayoutDisplay::Inline
149
            | LayoutDisplay::InlineBlock
150
            | LayoutDisplay::InlineFlex
151
            | LayoutDisplay::InlineGrid
152
            | LayoutDisplay::InlineTable
153
            | LayoutDisplay::None
154
    )
155
678
}
156

            
157
/// Walk `styled_dom` and tag every text node used in a shape azul cannot
158
/// honor. Allocation-free while nothing is wrong.
159
4602
fn collect_findings(styled_dom: &StyledDom) -> Vec<(usize, Finding)> {
160
4602
    let node_data = styled_dom.node_data.as_container();
161
4602
    let hierarchy = styled_dom.node_hierarchy.as_container();
162
4602
    let mut found: Vec<(usize, Finding)> = Vec::new();
163

            
164
233564
    for idx in 0..node_data.len() {
165
233564
        let node_id = NodeId::new(idx);
166
233564
        let data = &node_data[node_id];
167
233564
        let Some(h) = hierarchy.get(node_id) else {
168
            continue;
169
        };
170

            
171
233564
        if is_text(data.get_node_type()) {
172
            // W1 — state on a box-less node: every one of these is inert on
173
            // a text node, because only the wrapping block box carries a
174
            // rect. An id or a class counts: it is how a stylesheet reaches
175
            // the node, and every rule it selects computes onto a node that
176
            // never gets one.
177
82844
            let mut inert = 0u8;
178
82844
            if !data.get_style().rules.as_ref().is_empty() {
179
11
                inert |= INERT_CSS;
180
82833
            }
181
82844
            if has_ids_or_classes(data) {
182
122
                inert |= INERT_IDS_CLASSES;
183
82722
            }
184
82844
            if !data.get_callbacks().as_ref().is_empty() {
185
                inert |= INERT_CALLBACKS;
186
82844
            }
187
82844
            if data.get_tab_index().is_some() {
188
1
                inert |= INERT_TAB_INDEX;
189
82843
            }
190
82844
            if data.get_dataset().is_some() {
191
                inert |= INERT_DATASET;
192
82844
            }
193
82844
            if h.first_child_id(node_id).is_some() {
194
3
                inert |= INERT_CHILDREN;
195
82841
            }
196
82844
            if inert != 0 {
197
123
                found.push((idx, Finding::Inert(inert)));
198
82721
            }
199

            
200
            // W2 — no containing block the text can live in. Browsers would
201
            // generate an anonymous block here; azul does not. The remaining
202
            // W2 shapes depend only on the PARENT, so they are decided once
203
            // in the parent's own iteration below.
204
82844
            match h.parent_id() {
205
                None => found.push((idx, Finding::NoParent)),
206
82844
                Some(parent_id) if is_text(node_data[parent_id].get_node_type()) => {
207
3
                    found.push((idx, Finding::TextParent));
208
3
                }
209
82841
                Some(_) => {}
210
            }
211
82844
            continue;
212
150720
        }
213

            
214
        // Parent-major placement checks. Answering "does this parent have a
215
        // block-level child / how many items does it have?" once per parent
216
        // keeps the pass linear; asking it per text child made a wide parent
217
        // with several text children quadratic.
218
150720
        let mut child_count = 0usize;
219
150720
        let mut has_text_child = false;
220
150720
        let mut child = h.first_child_id(node_id);
221
379678
        while let Some(cc) = child {
222
228958
            child_count += 1;
223
228958
            has_text_child |= is_text(node_data[cc].get_node_type());
224
228958
            child = hierarchy.get(cc).and_then(NodeHierarchyItem::next_sibling_id);
225
228958
        }
226
150720
        if !has_text_child {
227
            // The overwhelmingly common case: no display lookup, no second
228
            // sweep, nothing allocated.
229
81104
            continue;
230
69616
        }
231

            
232
69616
        let parent_display = display_of(styled_dom, node_id);
233
69616
        let finding = if is_flex_or_grid(parent_display) {
234
            // A text leaf as the SOLE child of a flex/grid box is the
235
            // sanctioned wrapper pattern (the parent is the box that carries
236
            // the styling — badge, the converted labels). The hazard is text
237
            // as ONE OF SEVERAL items: it competes in item layout with no box
238
            // of its own.
239
10540
            if child_count <= 1 {
240
88
                continue;
241
10452
            }
242
10452
            Finding::FlexItem {
243
10452
                display: parent_display,
244
10452
                child_count,
245
10452
            }
246
        } else {
247
            // Mixed inline + block content under one parent: the text has no
248
            // dedicated line box of its own next to block siblings.
249
59076
            let mut has_block_child = false;
250
59076
            let mut sibling = h.first_child_id(node_id);
251
118695
            while let Some(sib) = sibling {
252
60270
                if !is_text(node_data[sib].get_node_type())
253
678
                    && is_block_level(display_of(styled_dom, sib))
254
                {
255
651
                    has_block_child = true;
256
651
                    break;
257
59619
                }
258
59619
                sibling = hierarchy.get(sib).and_then(NodeHierarchyItem::next_sibling_id);
259
            }
260
59076
            if !has_block_child {
261
58425
                continue;
262
651
            }
263
651
            Finding::BlockSibling
264
        };
265

            
266
11103
        let mut child = h.first_child_id(node_id);
267
58060
        while let Some(cc) = child {
268
46957
            if is_text(node_data[cc].get_node_type()) {
269
23824
                found.push((cc.index(), finding));
270
23825
            }
271
46957
            child = hierarchy.get(cc).and_then(NodeHierarchyItem::next_sibling_id);
272
        }
273
    }
274

            
275
    // Node order, so a finding reads in the order the DOM was written.
276
45675
    found.sort_by_key(|(idx, finding)| (*idx, finding.rank()));
277
4602
    found
278
4602
}
279

            
280
/// Walk `styled_dom` and return one message per text node that is used in a
281
/// shape azul cannot honor. Pure — the caller decides how to report.
282
#[must_use]
283
55
pub fn collect_text_placement_warnings(styled_dom: &StyledDom) -> Vec<String> {
284
55
    let found = collect_findings(styled_dom);
285
55
    if found.is_empty() {
286
49
        return Vec::new();
287
6
    }
288
6
    let node_data = styled_dom.node_data.as_container();
289
6
    found
290
6
        .into_iter()
291
8
        .map(|(idx, finding)| render(&node_data[NodeId::new(idx)], idx, finding))
292
6
        .collect()
293
55
}
294

            
295
/// Build the developer-facing text. Only called for a finding that is about
296
/// to be printed — the snippet alone costs two allocations.
297
173
fn render(data: &azul_core::dom::NodeData, idx: usize, finding: Finding) -> String {
298
173
    let snippet = match data.get_node_type() {
299
173
        NodeType::Text(t) => snippet_of(t.as_str()),
300
        _ => String::new(),
301
    };
302
173
    match finding {
303
41
        Finding::Inert(flags) => {
304
41
            let mut inert = Vec::new();
305
41
            if flags & INERT_CSS != 0 {
306
10
                inert.push("css properties");
307
31
            }
308
41
            if flags & INERT_IDS_CLASSES != 0 {
309
40
                inert.push("ids/classes");
310
40
            }
311
41
            if flags & INERT_CALLBACKS != 0 {
312
                inert.push("callbacks");
313
41
            }
314
41
            if flags & INERT_TAB_INDEX != 0 {
315
1
                inert.push("a tab_index");
316
40
            }
317
41
            if flags & INERT_DATASET != 0 {
318
                inert.push("a dataset");
319
41
            }
320
41
            if flags & INERT_CHILDREN != 0 {
321
2
                inert.push("element children");
322
40
            }
323
41
            format!(
324
41
                "text node {idx} ({snippet}) carries {} — INERT: a text node has no box. \
325
41
                 Move them onto a block wrapper (create_p_with_text / create_div_with_text) \
326
41
                 instead of the raw text node.",
327
41
                inert.join(" + "),
328
            )
329
        }
330
        Finding::NoParent => format!(
331
            "text node {idx} ({snippet}) has no parent — a raw text run needs a \
332
             block-level container (p / div / ...) to own its box.",
333
        ),
334
1
        Finding::TextParent => format!(
335
1
            "text node {idx} ({snippet}) is the child of another text node — \
336
1
             wrap both in a block-level container (p / div / ...).",
337
        ),
338
        Finding::FlexItem {
339
57
            display,
340
57
            child_count,
341
57
        } => format!(
342
57
            "text node {idx} ({snippet}) is one of {child_count} items in a \
343
57
             {display:?} container — a raw text run competes in flex/grid \
344
57
             layout with no box of its own (browsers auto-wrap it in an anonymous \
345
57
             block; azul does not). Wrap it: create_p_with_text / \
346
57
             create_div_with_text.",
347
        ),
348
74
        Finding::BlockSibling => format!(
349
74
            "text node {idx} ({snippet}) sits NEXT TO block-level siblings — browsers \
350
74
             would wrap it in an anonymous block, azul does not, so it has no line box \
351
74
             of its own. Wrap it: create_p_with_text / create_div_with_text.",
352
        ),
353
    }
354
173
}
355

            
356
/// Identify the PROBLEM, not the node.
357
///
358
/// Node indices shift on every structural edit — i.e. on every keystroke in
359
/// a live document — so an index-derived key re-hashes each frame: the same
360
/// finding re-prints forever AND the dedup set grows without bound. The kind
361
/// of finding plus the styling identity of the text node and its parent is
362
/// stable across edits, and it is what the developer actually fixes: one
363
/// construction site.
364
23942
fn dedup_key(styled_dom: &StyledDom, node_id: NodeId, finding: Finding) -> u64 {
365
23942
    let node_data = styled_dom.node_data.as_container();
366
23942
    let hierarchy = styled_dom.node_hierarchy.as_container();
367
23942
    let mut hasher = std::collections::hash_map::DefaultHasher::new();
368

            
369
23942
    finding.rank().hash(&mut hasher);
370
23942
    match finding {
371
121
        Finding::Inert(flags) => flags.hash(&mut hasher),
372
        // NOT the child_count: adding an item to the container is not a new
373
        // bug.
374
10663
        Finding::FlexItem { display, .. } => display.hash(&mut hasher),
375
13158
        _ => {}
376
    }
377

            
378
23942
    hash_identity(&node_data[node_id], &mut hasher);
379
23942
    match hierarchy.get(node_id).and_then(NodeHierarchyItem::parent_id) {
380
        None => 0u8.hash(&mut hasher),
381
23942
        Some(parent_id) => {
382
23942
            1u8.hash(&mut hasher);
383
23942
            hash_identity(&node_data[parent_id], &mut hasher);
384
23942
        }
385
    }
386
23942
    hasher.finish()
387
23942
}
388

            
389
/// The part of a node that survives a DOM rebuild: what it is and how it is
390
/// selected. Deliberately not its text, which may be per-frame data.
391
47884
fn hash_identity(data: &azul_core::dom::NodeData, hasher: &mut impl Hasher) {
392
47884
    discriminant(data.get_node_type()).hash(hasher);
393
47884
    for attr in data.attributes().as_ref() {
394
13758
        match attr {
395
537
            AttributeType::Id(s) => {
396
537
                0u8.hash(hasher);
397
537
                s.as_str().hash(hasher);
398
537
            }
399
13221
            AttributeType::Class(s) => {
400
13221
                1u8.hash(hasher);
401
13221
                s.as_str().hash(hasher);
402
13221
            }
403
            _ => {}
404
        }
405
    }
406
47884
}
407

            
408
/// Report every finding from [`collect_text_placement_warnings`] to stderr,
409
/// once per unique finding per process. Call after layout.
410
4547
pub fn warn_text_without_block_container(styled_dom: &StyledDom) {
411
4547
    if is_suppressed() {
412
        return;
413
4547
    }
414
4547
    let found = collect_findings(styled_dom);
415
4547
    if found.is_empty() {
416
2653
        return;
417
1894
    }
418
1894
    let Ok(mut emitted) = EMITTED.lock() else {
419
        return;
420
    };
421
1894
    if emitted.capped {
422
        return;
423
1894
    }
424
1894
    let node_data = styled_dom.node_data.as_container();
425
25836
    for (idx, finding) in found {
426
23942
        let node_id = NodeId::new(idx);
427
23942
        if !emitted.keys.insert(dedup_key(styled_dom, node_id, finding)) {
428
23777
            continue;
429
165
        }
430
165
        if emitted.keys.len() > MAX_DISTINCT_WARNINGS {
431
            emitted.capped = true;
432
            eprintln!(
433
                "[azul][text-without-block] {MAX_DISTINCT_WARNINGS} distinct findings \
434
                 reported — further warnings suppressed \
435
                 (suppress the whole lint with AZ_SUPPRESS={SUPPRESS_TAG})"
436
            );
437
            return;
438
165
        }
439
165
        eprintln!(
440
165
            "[azul][text-without-block] WARNING: {} \
441
165
             (suppress with AZ_SUPPRESS={SUPPRESS_TAG})",
442
165
            render(&node_data[node_id], idx, finding),
443
        );
444
    }
445
4547
}
446

            
447
173
fn snippet_of(text: &str) -> String {
448
173
    let mut s: String = text.chars().take(24).collect();
449
173
    if text.chars().count() > 24 {
450
31
        s.push_str("...");
451
142
    }
452
173
    format!("{s:?}")
453
173
}
454

            
455
#[cfg(test)]
456
mod autotest_generated {
457
    use azul_core::dom::{Dom, IdOrClass, IdOrClassVec, TabIndex};
458
    use azul_core::styled_dom::StyledDom;
459
    use azul_css::css::Css;
460

            
461
    use super::collect_text_placement_warnings;
462

            
463
    fn styled(mut dom: Dom, css: &str) -> StyledDom {
464
        let css = if css.is_empty() {
465
            Css::empty()
466
        } else {
467
            Css::from_string(css.into())
468
        };
469
        StyledDom::create(&mut dom, css)
470
    }
471

            
472
    fn raw_text(s: &str) -> Dom {
473
        Dom::create_text_do_not_use_without_block_level_wrapper(s)
474
    }
475

            
476
    #[test]
477
    fn a_correctly_wrapped_text_produces_no_warning() {
478
        let sd = styled(
479
            Dom::create_body().with_child(Dom::create_p_with_text("hello")),
480
            "",
481
        );
482
        assert_eq!(collect_text_placement_warnings(&sd), Vec::<String>::new());
483
    }
484

            
485
    #[test]
486
    fn text_inside_an_inline_span_inside_a_block_is_fine() {
487
        let sd = styled(
488
            Dom::create_body()
489
                .with_child(Dom::create_p().with_child(Dom::create_span_with_text("hi"))),
490
            "",
491
        );
492
        assert_eq!(collect_text_placement_warnings(&sd), Vec::<String>::new());
493
    }
494

            
495
    #[test]
496
    fn state_on_a_text_node_is_reported_as_inert() {
497
        let text = raw_text("styled").with_tab_index(TabIndex::Auto);
498
        let sd = styled(Dom::create_body().with_child(Dom::create_p().with_child(text)), "");
499
        let w = collect_text_placement_warnings(&sd);
500
        assert_eq!(w.len(), 1, "{w:?}");
501
        assert!(w[0].contains("INERT"), "{w:?}");
502
        assert!(w[0].contains("tab_index"), "{w:?}");
503
    }
504

            
505
    #[test]
506
    fn ids_and_classes_on_a_text_node_are_reported_as_inert() {
507
        // The shipped menu_renderer defect: the checkmark's three classes
508
        // ended up on the text node instead of the icon <div> that boxes it.
509
        // The text is that div's only child — the shape every placement check
510
        // calls sanctioned — so the classes are the only thing left to report.
511
        let icon = raw_text("✓").with_ids_and_classes(IdOrClassVec::from_vec(vec![
512
            IdOrClass::Class("menu-item-icon".into()),
513
            IdOrClass::Class("menu-item-checkbox".into()),
514
            IdOrClass::Class("menu-item-checkbox-checked".into()),
515
        ]));
516
        let sd = styled(
517
            Dom::create_body().with_child(Dom::create_div().with_child(icon)),
518
            "",
519
        );
520
        let w = collect_text_placement_warnings(&sd);
521
        assert_eq!(w.len(), 1, "{w:?}");
522
        assert!(w[0].contains("INERT"), "{w:?}");
523
        assert!(w[0].contains("ids/classes"), "{w:?}");
524
    }
525

            
526
    #[test]
527
    fn a_sole_text_leaf_in_a_flex_wrapper_is_the_sanctioned_pattern() {
528
        // badge / the converted labels: the flex box IS the wrapper.
529
        let sd = styled(
530
            Dom::create_body().with_child(Dom::create_div().with_child(raw_text("flexed"))),
531
            "div { display: flex; }",
532
        );
533
        assert_eq!(collect_text_placement_warnings(&sd), Vec::<String>::new());
534
    }
535

            
536
    #[test]
537
    fn text_competing_with_other_flex_items_is_reported() {
538
        // The tree_view/radio_group shape: a raw label beside element items.
539
        let sd = styled(
540
            Dom::create_body().with_child(
541
                Dom::create_div()
542
                    .with_child(Dom::create_div())
543
                    .with_child(raw_text("flexed")),
544
            ),
545
            "body > div { display: flex; }",
546
        );
547
        let w = collect_text_placement_warnings(&sd);
548
        assert_eq!(w.len(), 1, "{w:?}");
549
        assert!(w[0].contains("competes in flex/grid"), "{w:?}");
550
    }
551

            
552
    #[test]
553
    fn text_next_to_a_block_sibling_is_reported() {
554
        // The audited frame.rs shape: a title wedged between two divs.
555
        let sd = styled(
556
            Dom::create_body().with_child(
557
                Dom::create_div()
558
                    .with_child(Dom::create_div())
559
                    .with_child(raw_text("title"))
560
                    .with_child(Dom::create_div()),
561
            ),
562
            "",
563
        );
564
        let w = collect_text_placement_warnings(&sd);
565
        assert_eq!(w.len(), 1, "{w:?}");
566
        assert!(w[0].contains("block-level siblings"), "{w:?}");
567
    }
568

            
569
    #[test]
570
    fn several_text_children_of_one_parent_are_all_reported() {
571
        // The per-parent memo must not swallow the sibling text runs it was
572
        // computed for.
573
        let sd = styled(
574
            Dom::create_body().with_child(
575
                Dom::create_div()
576
                    .with_child(raw_text("a"))
577
                    .with_child(Dom::create_div())
578
                    .with_child(raw_text("b")),
579
            ),
580
            "",
581
        );
582
        let w = collect_text_placement_warnings(&sd);
583
        assert_eq!(w.len(), 2, "{w:?}");
584
        assert!(w[0].contains("\"a\""), "{w:?}");
585
        assert!(w[1].contains("\"b\""), "{w:?}");
586
    }
587

            
588
    #[test]
589
    fn findings_are_reported_in_node_order() {
590
        // The two text runs are found via DIFFERENT parents (one nested, one
591
        // directly under body), so the scan reaches them out of order and the
592
        // sort has to put them back.
593
        let sd = styled(
594
            Dom::create_body()
595
                .with_child(
596
                    Dom::create_div()
597
                        .with_child(Dom::create_div())
598
                        .with_child(raw_text("deep")),
599
                )
600
                .with_child(raw_text("shallow"))
601
                .with_child(Dom::create_div()),
602
            "",
603
        );
604
        let w = collect_text_placement_warnings(&sd);
605
        assert_eq!(w.len(), 2, "{w:?}");
606
        let indices: Vec<usize> = w
607
            .iter()
608
            .map(|m| {
609
                m.split_whitespace()
610
                    .nth(2)
611
                    .and_then(|n| n.parse().ok())
612
                    .unwrap_or_else(|| panic!("no node index in {m:?}"))
613
            })
614
            .collect();
615
        assert!(indices[0] < indices[1], "{indices:?} / {w:?}");
616
    }
617

            
618
    #[test]
619
    fn every_widget_dom_is_warning_free() {
620
        // The runtime twin of the widgets' label-convention test: none of the
621
        // shipped widgets may trip the developer warning.
622
        for (name, dom) in crate::widgets::all_widget_doms_for_lint() {
623
            let sd = styled(Dom::create_body().with_child(dom), "");
624
            let w = collect_text_placement_warnings(&sd);
625
            assert_eq!(w, Vec::<String>::new(), "widget {name} trips the text lint: {w:?}");
626
        }
627
    }
628
}