ifc_lite_wasm/zero_copy/symbolic_truncation.rs
1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
5//! The truncation surface of [`SymbolicRepresentationCollection`].
6//!
7//! Also home to the reasoning behind `is_empty`'s truncation guard: a per-item
8//! bound can fire having emitted nothing, so a truncated collection can hold no
9//! primitives at all. A consumer that skips an "empty" collection would then
10//! discard the only signal the browser gets that its drawing is clipped, since
11//! geometry is client-side only. `SymbolicData::is_empty` in
12//! `ifc-lite-processing` already refuses to call a truncated result empty; the
13//! test below pins the two together rather than trusting two definitions to
14//! stay in step.
15//!
16//! Split out of `symbolic.rs` rather than raising that file's ratchet budget:
17//! the budget exists so a god-file cannot grow one accessor at a time, and
18//! "the new field needed a getter" is exactly the increment it is meant to
19//! refuse. These read one field and belong together (#2938).
20
21use super::symbolic::SymbolicRepresentationCollection;
22use wasm_bindgen::prelude::*;
23
24#[wasm_bindgen]
25impl SymbolicRepresentationCollection {
26 /// Primitive count at which extraction stopped, else `undefined`.
27 #[wasm_bindgen(getter, js_name = truncatedAt)]
28 pub fn truncated_at(&self) -> Option<usize> {
29 self.truncated.as_ref().map(|t| t.emitted)
30 }
31
32 /// Which bound stopped extraction, else `undefined`. One of
33 /// `element-count`, `output-bytes`, `item-depth`, `item-revisits`,
34 /// `item-cycle` — the same kebab-case strings the JSON path emits, so a
35 /// consumer reading either surface reads one vocabulary.
36 #[wasm_bindgen(getter, js_name = truncatedReason)]
37 pub fn truncated_reason(&self) -> Option<String> {
38 self.truncated
39 .as_ref()
40 .map(|t| t.reason.as_wire_str().to_string())
41 }
42
43 /// The bound's numeric value, when the reason has one, else `undefined`.
44 /// Absent for the per-item reasons, whose bound is per item and not
45 /// comparable with `truncatedAt`.
46 #[wasm_bindgen(getter, js_name = truncatedLimit)]
47 pub fn truncated_limit(&self) -> Option<usize> {
48 self.truncated.as_ref().and_then(|t| t.limit)
49 }
50}
51
52#[cfg(test)]
53mod tests {
54 use super::SymbolicRepresentationCollection;
55 use ifc_lite_processing::{
56 SymbolicData, SymbolicTruncation, SymbolicTruncationReason,
57 };
58
59 fn truncated_but_empty() -> SymbolicData {
60 // A per-item bound can fire having emitted nothing at all: the walk
61 // refuses one representation item and the file contributes no
62 // primitives. That is the shape this guard exists for.
63 SymbolicData {
64 truncated: Some(SymbolicTruncation {
65 reason: SymbolicTruncationReason::ItemRevisits,
66 emitted: 0,
67 limit: None,
68 }),
69 ..SymbolicData::default()
70 }
71 }
72
73 /// `is_empty` decides whether a consumer keeps the collection at all, so
74 /// the two implementations disagreeing means the browser drops a
75 /// diagnostic the server keeps. Assert PARITY, not each side separately:
76 /// two independently-correct-looking definitions is exactly how they
77 /// drifted in the first place.
78 #[test]
79 fn is_empty_agrees_with_the_processing_side_for_a_truncated_but_empty_result() {
80 let data = truncated_but_empty();
81 let expected = data.is_empty();
82 let collection = SymbolicRepresentationCollection::from_data(data);
83
84 assert!(
85 !expected,
86 "SymbolicData::is_empty must not call a truncated result empty"
87 );
88 assert_eq!(
89 collection.is_empty(),
90 expected,
91 "the WASM collection must agree with SymbolicData::is_empty, or a \
92 consumer that skips an empty collection silently discards the \
93 truncation diagnostic (geometry is client-side only, so this is \
94 the browser's only signal the drawing is clipped)"
95 );
96 // The diagnostic the guard protects is actually reachable.
97 assert_eq!(collection.truncated_reason().as_deref(), Some("item-revisits"));
98 }
99
100 #[test]
101 fn a_genuinely_complete_and_empty_result_is_still_empty() {
102 // The other direction, so "always false" is not a passing fix.
103 let data = SymbolicData::default();
104 assert!(data.is_empty());
105 let collection = SymbolicRepresentationCollection::from_data(data);
106 assert!(collection.is_empty());
107 assert_eq!(collection.truncated_reason(), None);
108 }
109}