Skip to main content

viva_genapi/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2//! GenApi node system: typed feature access backed by register IO.
3
4mod bitops;
5mod conversions;
6mod error;
7mod io;
8mod nodemap;
9mod nodes;
10pub mod swissknife;
11
12pub use error::GenApiError;
13pub use io::{NullIo, RegisterIo};
14pub use nodemap::NodeMap;
15pub use nodes::{
16    BooleanNode, CategoryNode, CommandNode, EnumNode, FloatNode, IntegerNode, Node, NodeMeta,
17    RegisterNode, Representation, SkNode, Visibility,
18};
19pub use swissknife::{AstNode, EvalMode, Value};
20pub use viva_genapi_xml::{AccessMode, SkOutput, SkippedNode};
21
22#[cfg(test)]
23mod tests {
24    use std::cell::RefCell;
25    use std::collections::HashMap;
26
27    use crate::conversions::{bytes_to_i64, i64_to_bytes};
28    use crate::{AccessMode, GenApiError, NodeMap, RegisterIo, Visibility};
29    use viva_genapi_xml::{ByteOrder, Sign};
30
31    const FIXTURE: &str = r#"
32        <RegisterDescription SchemaMajorVersion="1" SchemaMinorVersion="2" SchemaSubMinorVersion="3">
33            <Integer Name="Width">
34                <Address>0x100</Address>
35                <Length>4</Length>
36                <AccessMode>RW</AccessMode>
37                <Min>16</Min>
38                <Max>4096</Max>
39                <Inc>2</Inc>
40            </Integer>
41            <Float Name="ExposureTime">
42                <Address>0x200</Address>
43                <Length>4</Length>
44                <AccessMode>RW</AccessMode>
45                <Min>10.0</Min>
46                <Max>100000.0</Max>
47                <Scale>1/1000</Scale>
48            </Float>
49            <Enumeration Name="GainSelector">
50                <Address>0x300</Address>
51                <Length>2</Length>
52                <AccessMode>RW</AccessMode>
53                <EnumEntry Name="All" Value="0" />
54                <EnumEntry Name="Red" Value="1" />
55                <EnumEntry Name="Blue" Value="2" />
56            </Enumeration>
57            <Integer Name="Gain">
58                <Length>2</Length>
59                <AccessMode>RW</AccessMode>
60                <Min>0</Min>
61                <Max>48</Max>
62                <pSelected>GainSelector</pSelected>
63                <Selected>All</Selected>
64                <Address>0x310</Address>
65                <Selected>Red</Selected>
66                <Address>0x314</Address>
67                <Selected>Blue</Selected>
68            </Integer>
69            <Boolean Name="GammaEnable">
70                <Address>0x400</Address>
71                <Length>1</Length>
72                <AccessMode>RW</AccessMode>
73            </Boolean>
74            <Command Name="AcquisitionStart">
75                <Address>0x500</Address>
76                <Length>4</Length>
77            </Command>
78        </RegisterDescription>
79    "#;
80
81    const INDIRECT_FIXTURE: &str = r#"
82        <RegisterDescription SchemaMajorVersion="1" SchemaMinorVersion="0" SchemaSubMinorVersion="0">
83            <Integer Name="RegAddr">
84                <Address>0x2000</Address>
85                <Length>4</Length>
86                <AccessMode>RW</AccessMode>
87                <Min>0</Min>
88                <Max>65535</Max>
89            </Integer>
90            <Integer Name="Gain">
91                <pAddress>RegAddr</pAddress>
92                <Length>4</Length>
93                <AccessMode>RW</AccessMode>
94                <Min>0</Min>
95                <Max>255</Max>
96            </Integer>
97        </RegisterDescription>
98    "#;
99
100    const ENUM_PVALUE_FIXTURE: &str = r#"
101        <RegisterDescription SchemaMajorVersion="1" SchemaMinorVersion="0" SchemaSubMinorVersion="0">
102            <Enumeration Name="Mode">
103                <Address>0x4000</Address>
104                <Length>4</Length>
105                <AccessMode>RW</AccessMode>
106                <EnumEntry Name="Fixed10">
107                    <Value>10</Value>
108                </EnumEntry>
109                <EnumEntry Name="DynFromReg">
110                    <pValue>RegModeVal</pValue>
111                </EnumEntry>
112            </Enumeration>
113            <Integer Name="RegModeVal">
114                <Address>0x4100</Address>
115                <Length>4</Length>
116                <AccessMode>RW</AccessMode>
117                <Min>0</Min>
118                <Max>65535</Max>
119            </Integer>
120        </RegisterDescription>
121    "#;
122
123    const BITFIELD_FIXTURE: &str = r#"
124        <RegisterDescription SchemaMajorVersion="1" SchemaMinorVersion="0" SchemaSubMinorVersion="0">
125            <Integer Name="LeByte">
126                <Address>0x5000</Address>
127                <Length>4</Length>
128                <AccessMode>RW</AccessMode>
129                <Min>0</Min>
130                <Max>65535</Max>
131                <Mask>0x0000FF00</Mask>
132            </Integer>
133            <!-- The top three bits of a big-endian 16-bit word. Indices count
134                 down from the MSB here, so the field is 0..=2 and `<LSB>` is the
135                 larger of the two. This used to read `<Lsb>13</Lsb><Msb>15</Msb>`,
136                 an orientation that appears nowhere in the vendor corpus and
137                 encoded the issue-#120 defect rather than catching it. -->
138            <Integer Name="BeBits">
139                <Address>0x5004</Address>
140                <Length>2</Length>
141                <AccessMode>RW</AccessMode>
142                <Min>0</Min>
143                <Max>15</Max>
144                <LSB>2</LSB>
145                <MSB>0</MSB>
146                <Endianess>BigEndian</Endianess>
147            </Integer>
148            <Boolean Name="PackedFlag">
149                <Address>0x5006</Address>
150                <Length>4</Length>
151                <AccessMode>RW</AccessMode>
152                <Bit>13</Bit>
153            </Boolean>
154        </RegisterDescription>
155    "#;
156
157    const SWISSKNIFE_FIXTURE: &str = r#"
158        <RegisterDescription SchemaMajorVersion="1" SchemaMinorVersion="0" SchemaSubMinorVersion="0">
159            <Integer Name="GainRaw">
160                <Address>0x3000</Address>
161                <Length>4</Length>
162                <AccessMode>RW</AccessMode>
163                <Min>0</Min>
164                <Max>1000</Max>
165            </Integer>
166            <Float Name="Offset">
167                <Address>0x3008</Address>
168                <Length>4</Length>
169                <AccessMode>RW</AccessMode>
170                <Min>-100.0</Min>
171                <Max>100.0</Max>
172                <Scale>1</Scale>
173            </Float>
174            <Integer Name="B">
175                <Address>0x3010</Address>
176                <Length>4</Length>
177                <AccessMode>RW</AccessMode>
178                <Min>-1000</Min>
179                <Max>1000</Max>
180            </Integer>
181            <SwissKnife Name="ComputedGain">
182                <Formula>(GainRaw * 0.5) + Offset</Formula>
183                <pVariable Name="GainRaw">GainRaw</pVariable>
184                <pVariable Name="Offset">Offset</pVariable>
185            </SwissKnife>
186            <IntSwissKnife Name="DivideInt">
187                <Formula>GainRaw / 3</Formula>
188                <pVariable Name="GainRaw">GainRaw</pVariable>
189            </IntSwissKnife>
190            <IntSwissKnife Name="Unary">
191                <Formula>-GainRaw + 10</Formula>
192                <pVariable Name="GainRaw">GainRaw</pVariable>
193            </IntSwissKnife>
194            <SwissKnife Name="DivideByZero">
195                <Formula>GainRaw / B</Formula>
196                <pVariable Name="GainRaw">GainRaw</pVariable>
197                <pVariable Name="B">B</pVariable>
198            </SwissKnife>
199        </RegisterDescription>
200    "#;
201
202    #[derive(Default)]
203    struct MockIo {
204        regs: RefCell<HashMap<u64, Vec<u8>>>,
205        reads: RefCell<HashMap<u64, usize>>,
206    }
207
208    impl MockIo {
209        fn with_registers(entries: &[(u64, Vec<u8>)]) -> Self {
210            let mut regs = HashMap::new();
211            for (addr, data) in entries {
212                regs.insert(*addr, data.clone());
213            }
214            MockIo {
215                regs: RefCell::new(regs),
216                reads: RefCell::new(HashMap::new()),
217            }
218        }
219
220        fn read_count(&self, addr: u64) -> usize {
221            *self.reads.borrow().get(&addr).unwrap_or(&0)
222        }
223    }
224
225    impl RegisterIo for MockIo {
226        fn read(&self, addr: u64, len: usize) -> Result<Vec<u8>, GenApiError> {
227            let mut reads = self.reads.borrow_mut();
228            *reads.entry(addr).or_default() += 1;
229            let regs = self.regs.borrow();
230            let data = regs
231                .get(&addr)
232                .ok_or_else(|| GenApiError::Io(format!("read miss at 0x{addr:08X}")))?;
233            if data.len() != len {
234                return Err(GenApiError::Io(format!(
235                    "length mismatch at 0x{addr:08X}: expected {len}, have {}",
236                    data.len()
237                )));
238            }
239            Ok(data.clone())
240        }
241
242        fn write(&self, addr: u64, data: &[u8]) -> Result<(), GenApiError> {
243            self.regs.borrow_mut().insert(addr, data.to_vec());
244            Ok(())
245        }
246    }
247
248    fn build_nodemap() -> NodeMap {
249        let model = viva_genapi_xml::parse(FIXTURE).expect("parse fixture");
250        NodeMap::try_from_xml(model).expect("build nodemap")
251    }
252
253    /// A node type we do not implement must reach the nodemap's skip list.
254    ///
255    /// Two separate holes used to swallow this. The XML parser dropped an
256    /// unlisted tag at `skip_element` without recording it, and the nodemap
257    /// then discarded whatever the XML layer *had* recorded — so a consumer
258    /// holding a nodemap could not tell a feature we cannot read from one the
259    /// camera does not have.
260    #[test]
261    fn an_unsupported_node_type_is_visible_in_the_nodemap() {
262        // `<ConfRom>` is a real GenICam node type we do not implement.
263        // This test used `<Register>` until GA-09 landed support for it.
264        const WITH_UNKNOWN: &str = r#"
265            <RegisterDescription SchemaMajorVersion="1" SchemaMinorVersion="1" SchemaSubMinorVersion="0">
266                <Integer Name="Width">
267                    <Address>0x100</Address>
268                    <Length>4</Length>
269                    <AccessMode>RW</AccessMode>
270                </Integer>
271                <ConfRom Name="DeviceConfRom">
272                    <Address>0x2000</Address>
273                    <Length>512</Length>
274                </ConfRom>
275            </RegisterDescription>
276        "#;
277        let model = viva_genapi_xml::parse(WITH_UNKNOWN).expect("parse");
278        assert_eq!(model.skipped.len(), 1, "XML layer records the unknown tag");
279
280        let nodemap = NodeMap::try_from_xml(model).expect("build nodemap");
281        let io = MockIo::with_registers(&[(0x100, vec![0, 0, 0, 7])]);
282        assert_eq!(nodemap.get_integer("Width", &io).expect("read Width"), 7);
283
284        let skipped = nodemap.skipped();
285        assert_eq!(skipped.len(), 1, "and the nodemap carries it forward");
286        assert_eq!(skipped[0].tag, "ConfRom");
287        assert_eq!(skipped[0].name.as_deref(), Some("DeviceConfRom"));
288    }
289
290    /// `<Register>` fixture covering the three shapes GA-09's first cut cares
291    /// about: a readable/writable block on the device port, one on a chunk
292    /// port, and one whose length is resolved at runtime.
293    const REGISTER_FIXTURE: &str = r#"
294        <RegisterDescription SchemaMajorVersion="1" SchemaMinorVersion="1" SchemaSubMinorVersion="0">
295            <Register Name="FileAccessBuffer">
296                <Address>0x2000</Address>
297                <Length>8</Length>
298                <AccessMode>RW</AccessMode>
299            </Register>
300            <Register Name="DeviceSerialBlock">
301                <Address>0x3000</Address>
302                <Length>4</Length>
303                <AccessMode>RO</AccessMode>
304                <pPort>Device</pPort>
305            </Register>
306            <Register Name="ChunkMeasurementResults">
307                <Address>0x0</Address>
308                <Length>4</Length>
309                <AccessMode>RO</AccessMode>
310                <pPort>Chunk4007</pPort>
311            </Register>
312            <Register Name="DynamicBlock">
313                <Address>0x4000</Address>
314                <pLength>BlockLength</pLength>
315            </Register>
316            <StringReg Name="DeviceVendorName">
317                <Address>0x5000</Address>
318                <Length>4</Length>
319                <AccessMode>RO</AccessMode>
320            </StringReg>
321        </RegisterDescription>
322    "#;
323
324    fn build_register_nodemap() -> NodeMap {
325        let model = viva_genapi_xml::parse(REGISTER_FIXTURE).expect("parse register fixture");
326        NodeMap::try_from_xml(model).expect("build nodemap")
327    }
328
329    #[test]
330    fn register_reads_and_writes_raw_bytes() {
331        let nodemap = build_register_nodemap();
332        let payload = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04];
333        let io = MockIo::with_registers(&[(0x2000, payload.clone())]);
334
335        assert_eq!(
336            nodemap
337                .get_register("FileAccessBuffer", &io)
338                .expect("read register"),
339            payload
340        );
341
342        let replacement = vec![9u8; 8];
343        nodemap
344            .set_register("FileAccessBuffer", &replacement, &io)
345            .expect("write register");
346        assert_eq!(
347            nodemap
348                .get_register("FileAccessBuffer", &io)
349                .expect("re-read register"),
350            replacement
351        );
352    }
353
354    /// A short write must be refused, not padded.
355    ///
356    /// `set_string` zero-pads to the declared length, which is right for a
357    /// string. Doing the same to a file-transfer buffer would silently zero
358    /// the rest of the block — data loss dressed as a convenience.
359    #[test]
360    fn register_write_of_the_wrong_length_is_refused() {
361        let nodemap = build_register_nodemap();
362        let io = MockIo::with_registers(&[(0x2000, vec![0u8; 8])]);
363
364        let err = nodemap
365            .set_register("FileAccessBuffer", &[1, 2, 3], &io)
366            .expect_err("a 3-byte write into an 8-byte register must fail");
367        assert!(
368            matches!(err, GenApiError::Range(_)),
369            "expected a range error, got {err:?}"
370        );
371        assert_eq!(
372            io.read(0x2000, 8).expect("register untouched"),
373            vec![0u8; 8],
374            "the refused write must not have reached the device"
375        );
376    }
377
378    /// A chunk-port register is listed but cannot be read.
379    ///
380    /// Its address is relative to a port we do not route. Reading it through
381    /// the device port would not error — it would return whatever lives at
382    /// that address, which for a scanCONTROL's `0x0` is the GVCP bootstrap
383    /// area. Silent wrong answers are what ADR-0018 exists to refuse (GA-12).
384    #[test]
385    fn register_on_a_non_device_port_is_listed_but_not_readable() {
386        let nodemap = build_register_nodemap();
387        let io = MockIo::with_registers(&[(0x0, vec![1, 2, 3, 4])]);
388
389        assert!(
390            nodemap.node_names().any(|n| n == "ChunkMeasurementResults"),
391            "the node must still be visible for introspection"
392        );
393
394        let err = nodemap
395            .get_register("ChunkMeasurementResults", &io)
396            .expect_err("a chunk-port register must not be read through the device port");
397        let text = err.to_string();
398        assert!(
399            text.contains("Chunk4007") && text.contains("GA-12"),
400            "the error must name the port and the reason: {text}"
401        );
402
403        // An explicit <pPort>Device</pPort> is the device port and reads fine.
404        let io = MockIo::with_registers(&[(0x3000, vec![5, 6, 7, 8])]);
405        assert_eq!(
406            nodemap
407                .get_register("DeviceSerialBlock", &io)
408                .expect("explicit Device port reads"),
409            vec![5, 6, 7, 8]
410        );
411    }
412
413    /// `register_address` (#92) keeps working, and now covers `<Register>`.
414    #[test]
415    fn register_address_resolves_for_a_register_node() {
416        let nodemap = build_register_nodemap();
417        let io = MockIo::default();
418        assert_eq!(
419            nodemap
420                .register_address("FileAccessBuffer", &io)
421                .expect("resolve address"),
422            (0x2000, 8)
423        );
424    }
425
426    #[test]
427    fn register_accessors_reject_the_wrong_node_type() {
428        let nodemap = build_register_nodemap();
429        let io = MockIo::with_registers(&[(0x5000, b"AVT\0".to_vec())]);
430
431        assert!(matches!(
432            nodemap.get_register("DeviceVendorName", &io),
433            Err(GenApiError::Type(_))
434        ));
435        assert!(matches!(
436            nodemap.get_register("NoSuchNode", &io),
437            Err(GenApiError::NodeNotFound(_))
438        ));
439    }
440
441    /// The deferred half of GA-09 must name itself in the skip reason.
442    ///
443    /// The corpus allowlist matches on this substring to tell a known gap from
444    /// a regression, so the wording is load-bearing, not cosmetic.
445    #[test]
446    fn a_register_with_p_length_is_skipped_and_says_why() {
447        let nodemap = build_register_nodemap();
448        let skipped = nodemap.skipped();
449
450        let entry = skipped
451            .iter()
452            .find(|s| s.name.as_deref() == Some("DynamicBlock"))
453            .expect("a <pLength> register must be recorded, not silently dropped");
454        assert_eq!(entry.tag, "Register");
455        assert!(
456            entry.error.contains("<pLength>"),
457            "the skip reason must name <pLength>: {}",
458            entry.error
459        );
460
461        // The other four nodes still built.
462        assert_eq!(nodemap.node_names().count(), 4);
463    }
464
465    /// Structural elements are not features and must not be reported as lost.
466    #[test]
467    fn elements_without_a_name_are_not_reported_as_skipped() {
468        const WITH_PORT: &str = r#"
469            <RegisterDescription SchemaMajorVersion="1" SchemaMinorVersion="1" SchemaSubMinorVersion="0">
470                <Port Name="Device"/>
471                <Group Comment="Standard">
472                    <Integer Name="Width">
473                        <Address>0x100</Address>
474                        <Length>4</Length>
475                        <AccessMode>RW</AccessMode>
476                    </Integer>
477                </Group>
478            </RegisterDescription>
479        "#;
480        let model = viva_genapi_xml::parse(WITH_PORT).expect("parse");
481        assert!(model.skipped.is_empty(), "{:?}", model.skipped);
482        assert_eq!(model.nodes.len(), 1);
483    }
484
485    fn build_indirect_nodemap() -> NodeMap {
486        let model = viva_genapi_xml::parse(INDIRECT_FIXTURE).expect("parse indirect fixture");
487        NodeMap::try_from_xml(model).expect("build nodemap")
488    }
489
490    fn build_enum_pvalue_nodemap() -> NodeMap {
491        let model = viva_genapi_xml::parse(ENUM_PVALUE_FIXTURE).expect("parse enum pvalue fixture");
492        NodeMap::try_from_xml(model).expect("build nodemap")
493    }
494
495    fn build_bitfield_nodemap() -> NodeMap {
496        let model = viva_genapi_xml::parse(BITFIELD_FIXTURE).expect("parse bitfield fixture");
497        NodeMap::try_from_xml(model).expect("build nodemap")
498    }
499
500    fn build_swissknife_nodemap() -> NodeMap {
501        let model = viva_genapi_xml::parse(SWISSKNIFE_FIXTURE).expect("parse swissknife fixture");
502        NodeMap::try_from_xml(model).expect("build nodemap")
503    }
504
505    #[test]
506    fn integer_roundtrip_and_cache() {
507        let mut nodemap = build_nodemap();
508        let io = MockIo::with_registers(&[(0x100, vec![0, 0, 4, 0])]);
509        let width = nodemap.get_integer("Width", &io).expect("read width");
510        assert_eq!(width, 1024);
511        assert_eq!(io.read_count(0x100), 1);
512        let width_again = nodemap.get_integer("Width", &io).expect("cached width");
513        assert_eq!(width_again, 1024);
514        assert_eq!(io.read_count(0x100), 1, "cached value should be reused");
515        nodemap
516            .set_integer("Width", 1030, &io)
517            .expect("write width");
518        let width = nodemap
519            .get_integer("Width", &io)
520            .expect("read updated width");
521        assert_eq!(width, 1030);
522        assert_eq!(io.read_count(0x100), 1, "write should update cache");
523    }
524
525    const IEEE754_FIXTURE: &str = r#"
526        <RegisterDescription SchemaMajorVersion="1" SchemaMinorVersion="0" SchemaSubMinorVersion="0">
527            <FloatReg Name="FrameRate">
528                <Address>0x100</Address>
529                <Length>4</Length>
530                <AccessMode>RW</AccessMode>
531                <Min>0.0</Min>
532                <Max>1000.0</Max>
533                <Endianess>BigEndian</Endianess>
534            </FloatReg>
535            <Float Name="ExposureUs">
536                <Address>0x110</Address>
537                <Length>8</Length>
538                <AccessMode>RW</AccessMode>
539                <Min>0.0</Min>
540                <Max>1000000.0</Max>
541                <Endianess>BigEndian</Endianess>
542            </Float>
543        </RegisterDescription>
544    "#;
545
546    fn build_ieee754_nodemap() -> NodeMap {
547        NodeMap::try_from_xml(viva_genapi_xml::parse(IEEE754_FIXTURE).expect("parse ieee754"))
548            .expect("build nodemap")
549    }
550
551    #[test]
552    fn float_ieee754_f32_roundtrip() {
553        let mut nodemap = build_ieee754_nodemap();
554        let io = MockIo::with_registers(&[(0x100, 30.0f32.to_be_bytes().to_vec())]);
555        let v = nodemap.get_float("FrameRate", &io).expect("read rate");
556        assert!((v - 30.0).abs() < 1e-3, "got {v}");
557
558        nodemap
559            .set_float("FrameRate", 42.5, &io)
560            .expect("write rate");
561        let raw = io.read(0x100, 4).expect("read back");
562        assert_eq!(raw, 42.5f32.to_be_bytes());
563    }
564
565    #[test]
566    fn float_ieee754_f64_heuristic_roundtrip() {
567        let mut nodemap = build_ieee754_nodemap();
568        let io = MockIo::with_registers(&[(0x110, 6000.0f64.to_be_bytes().to_vec())]);
569        let v = nodemap.get_float("ExposureUs", &io).expect("read exposure");
570        assert!((v - 6000.0).abs() < 1e-9, "got {v}");
571
572        nodemap
573            .set_float("ExposureUs", 5000.0, &io)
574            .expect("write exposure");
575        let raw = io.read(0x110, 8).expect("read back");
576        assert_eq!(raw, 5000.0f64.to_be_bytes());
577    }
578
579    #[test]
580    fn float_scaled_integer_preserved() {
581        // The classic fixture's ExposureTime uses <Scale>1/1000</Scale>,
582        // so it must stay on the scaled-integer path even after the heuristic.
583        let nodemap = build_nodemap();
584        let raw = 50_000i64;
585        let io = MockIo::with_registers(&[(
586            0x200,
587            i64_to_bytes("ExposureTime", raw, 4, Sign::Signed, ByteOrder::Big).unwrap(),
588        )]);
589        let exposure = nodemap
590            .get_float("ExposureTime", &io)
591            .expect("read exposure");
592        assert!((exposure - 50.0).abs() < 1e-6);
593    }
594
595    const PREDICATE_FIXTURE: &str = r#"
596        <RegisterDescription SchemaMajorVersion="1" SchemaMinorVersion="0" SchemaSubMinorVersion="0">
597            <IntReg Name="CtrlReg">
598                <Address>0x400</Address>
599                <Length>4</Length>
600                <AccessMode>RW</AccessMode>
601                <Sign>Unsigned</Sign>
602                <Endianess>BigEndian</Endianess>
603            </IntReg>
604            <IntSwissKnife Name="GateImplemented">
605                <Formula>CTRL &amp; 1</Formula>
606                <pVariable Name="CTRL">CtrlReg</pVariable>
607                <Output>Integer</Output>
608            </IntSwissKnife>
609            <IntSwissKnife Name="GateLocked">
610                <Formula>(CTRL &amp; 2) / 2</Formula>
611                <pVariable Name="CTRL">CtrlReg</pVariable>
612                <Output>Integer</Output>
613            </IntSwissKnife>
614            <IntSwissKnife Name="Entry8Implemented">
615                <Formula>(CTRL &amp; 4) / 4</Formula>
616                <pVariable Name="CTRL">CtrlReg</pVariable>
617                <Output>Integer</Output>
618            </IntSwissKnife>
619            <Integer Name="Gated">
620                <Address>0x410</Address>
621                <Length>4</Length>
622                <AccessMode>RW</AccessMode>
623                <Min>0</Min>
624                <Max>255</Max>
625                <Sign>Unsigned</Sign>
626                <Endianess>BigEndian</Endianess>
627                <pIsImplemented>GateImplemented</pIsImplemented>
628                <pIsLocked>GateLocked</pIsLocked>
629            </Integer>
630            <Enumeration Name="PixelFormat">
631                <EnumEntry Name="Mono8"><Value>1</Value></EnumEntry>
632                <EnumEntry Name="Mono16">
633                    <Value>2</Value>
634                    <pIsImplemented>Entry8Implemented</pIsImplemented>
635                </EnumEntry>
636                <pValue>PixelFormatReg</pValue>
637            </Enumeration>
638            <IntReg Name="PixelFormatReg">
639                <Address>0x420</Address>
640                <Length>4</Length>
641                <AccessMode>RW</AccessMode>
642                <Sign>Unsigned</Sign>
643                <Endianess>BigEndian</Endianess>
644            </IntReg>
645        </RegisterDescription>
646    "#;
647
648    /// The FLIR gating shape from issue #120: three `<MaskedIntReg>` predicates
649    /// sharing one big-endian word, plus a `<Float>` that delegates through a
650    /// `<Converter>` — reproduced from `FLIR_BFS_PGE_31S4C_C.xml`.
651    const BIG_ENDIAN_PREDICATE_FIXTURE: &str = r#"
652        <RegisterDescription SchemaMajorVersion="1" SchemaMinorVersion="1" SchemaSubMinorVersion="1">
653            <Float Name="ExposureTime">
654                <pIsImplemented>ExposureTime_Imp</pIsImplemented>
655                <pIsAvailable>ExposureTime_Avl</pIsAvailable>
656                <pIsLocked>ExposureTime_Lck</pIsLocked>
657                <pValue>ExposureTime_FloatVal</pValue>
658            </Float>
659            <Converter Name="ExposureTime_FloatVal">
660                <FormulaTo>FROM</FormulaTo>
661                <FormulaFrom>TO</FormulaFrom>
662                <pValue>ExposureTime_Val</pValue>
663                <Slope>Increasing</Slope>
664            </Converter>
665            <IntReg Name="ExposureTime_Val">
666                <Address>0x000C1004</Address><Length>4</Length>
667                <AccessMode>RW</AccessMode><Sign>Unsigned</Sign>
668                <Endianess>BigEndian</Endianess>
669            </IntReg>
670            <MaskedIntReg Name="ExposureTime_Imp">
671                <Address>0x000C1000</Address><Length>4</Length>
672                <AccessMode>RO</AccessMode><Bit>0</Bit>
673                <Sign>Unsigned</Sign><Endianess>BigEndian</Endianess>
674            </MaskedIntReg>
675            <MaskedIntReg Name="ExposureTime_Avl">
676                <Address>0x000C1000</Address><Length>4</Length>
677                <AccessMode>RO</AccessMode><Bit>1</Bit>
678                <Sign>Unsigned</Sign><Endianess>BigEndian</Endianess>
679            </MaskedIntReg>
680            <MaskedIntReg Name="ExposureTime_Lck">
681                <Address>0x000C1000</Address><Length>4</Length>
682                <AccessMode>RO</AccessMode><Bit>3</Bit>
683                <Sign>Unsigned</Sign><Endianess>BigEndian</Endianess>
684            </MaskedIntReg>
685        </RegisterDescription>
686    "#;
687
688    fn big_endian_predicate_io(status: u32) -> MockIo {
689        MockIo::with_registers(&[
690            (0x000C_1000, status.to_be_bytes().to_vec()),
691            (0x000C_1004, 10_000u32.to_be_bytes().to_vec()),
692        ])
693    }
694
695    /// Regression for issue #120, stated against a register whose layout the
696    /// GigE Vision standard fixes rather than against our own reading of it.
697    ///
698    /// `AVT_Manta_G125B.xml` declares bootstrap `GevSCPSPacketSize` at `0xD04`
699    /// as `<LSB>31</LSB><MSB>16</MSB>` big-endian. The standard puts the packet
700    /// size in the *low* 16 bits, and the #120 reporter's own diagnostic bundle
701    /// shows that register holding `0x40000578` — 1400 bytes, with bit 0 (the
702    /// MSB) set for "fire test packet".
703    ///
704    /// So 1400 is not a number we chose: any implementation that disagrees is
705    /// wrong. Before the fix this produced 1 073 742 200 — the whole word —
706    /// because `<LSB>`/`<MSB>` were matched only in their mixed-case spelling
707    /// and the bit range was dropped entirely.
708    #[test]
709    fn big_endian_lsb_msb_range_decodes_the_gige_packet_size_register() {
710        const XML: &str = r#"
711            <RegisterDescription SchemaMajorVersion="1" SchemaMinorVersion="1" SchemaSubMinorVersion="1">
712                <MaskedIntReg Name="RegSCPSPacketSize">
713                    <Address>0xD04</Address><Length>4</Length>
714                    <AccessMode>RW</AccessMode>
715                    <LSB>31</LSB><MSB>16</MSB>
716                    <Endianess>BigEndian</Endianess>
717                </MaskedIntReg>
718            </RegisterDescription>
719        "#;
720        let nodemap =
721            NodeMap::try_from_xml(viva_genapi_xml::parse(XML).expect("parse")).expect("nodemap");
722        let io = MockIo::with_registers(&[(0x0D04, 0x4000_0578u32.to_be_bytes().to_vec())]);
723
724        assert_eq!(
725            nodemap.get_integer("RegSCPSPacketSize", &io).expect("read"),
726            1400
727        );
728    }
729
730    /// `<StructEntry>` and `<MaskedIntReg>` are two spellings of the same
731    /// physical bit and must decode identically.
732    ///
733    /// They are parsed by different functions — `parsers::struct_reg` and
734    /// `parsers::numeric` — and until #120 those two disagreed, with the
735    /// `<StructEntry>` path correct. Every predicate in `viva-fake-gige` used
736    /// `<StructEntry>` or `<IntSwissKnife>`, so the whole suite passed on the
737    /// path that worked while real cameras used the one that did not.
738    #[test]
739    fn struct_entry_and_masked_int_reg_agree_on_a_big_endian_bit() {
740        const XML: &str = r#"
741            <RegisterDescription SchemaMajorVersion="1" SchemaMinorVersion="1" SchemaSubMinorVersion="1">
742                <MaskedIntReg Name="ViaMaskedIntReg">
743                    <Address>0x500</Address><Length>4</Length>
744                    <AccessMode>RO</AccessMode><Bit>1</Bit>
745                    <Sign>Unsigned</Sign><Endianess>BigEndian</Endianess>
746                </MaskedIntReg>
747                <StructReg Comment="same word">
748                    <Address>0x500</Address><Length>4</Length>
749                    <AccessMode>RO</AccessMode><Sign>Unsigned</Sign>
750                    <Endianess>BigEndian</Endianess>
751                    <StructEntry Name="ViaStructEntry"><Bit>1</Bit></StructEntry>
752                </StructReg>
753            </RegisterDescription>
754        "#;
755        let nodemap =
756            NodeMap::try_from_xml(viva_genapi_xml::parse(XML).expect("parse")).expect("nodemap");
757
758        for word in [0x4000_0000u32, 0x0000_0002, 0xFFFF_FFFF, 0x0000_0000] {
759            let io = MockIo::with_registers(&[(0x500, word.to_be_bytes().to_vec())]);
760            let masked = nodemap.get_integer("ViaMaskedIntReg", &io).expect("masked");
761            let entry = nodemap.get_integer("ViaStructEntry", &io).expect("entry");
762            assert_eq!(masked, entry, "0x{word:08X} decoded differently");
763        }
764    }
765
766    /// The end-to-end shape of issue #120: a write refused locally as
767    /// unavailable because the gating bits were read off the wrong end.
768    ///
769    /// `0xC000_0000` is implemented (bit 0 from the MSB) and available (bit 1),
770    /// with the lock bit (bit 3) clear — the state a FLIR camera is in with
771    /// `ExposureAuto=Off`. Read LSB-first, as before the fix, the same word says
772    /// not implemented, and every setter refuses before reaching the wire.
773    #[test]
774    fn big_endian_gating_bits_permit_the_write_they_describe() {
775        let mut nodemap = NodeMap::try_from_xml(
776            viva_genapi_xml::parse(BIG_ENDIAN_PREDICATE_FIXTURE).expect("parse"),
777        )
778        .expect("nodemap");
779        let io = big_endian_predicate_io(0xC000_0000);
780
781        assert!(nodemap.is_implemented("ExposureTime", &io).expect("imp"));
782        assert!(nodemap.is_available("ExposureTime", &io).expect("avl"));
783        assert_eq!(
784            nodemap
785                .effective_access_mode("ExposureTime", &io)
786                .expect("access"),
787            AccessMode::RW
788        );
789
790        nodemap
791            .set_float("ExposureTime", 12_000.0, &io)
792            .expect("write must reach the wire");
793        assert_eq!(
794            io.regs.borrow().get(&0x000C_1004).cloned(),
795            Some(12_000u32.to_be_bytes().to_vec())
796        );
797    }
798
799    /// The lock bit is bit 3 counted from the MSB — `0x1000_0000`, not `0x8`.
800    ///
801    /// This is the guard added for issue #45. It could never have fired on that
802    /// reporter's camera, because it was reading bit 3 from the wrong end and
803    /// always saw zero; #120 is what exposed that.
804    #[test]
805    fn big_endian_lock_bit_is_counted_from_the_msb() {
806        let mut nodemap = NodeMap::try_from_xml(
807            viva_genapi_xml::parse(BIG_ENDIAN_PREDICATE_FIXTURE).expect("parse"),
808        )
809        .expect("nodemap");
810        let io = big_endian_predicate_io(0xD000_0000);
811
812        let err = nodemap
813            .set_float("ExposureTime", 12_000.0, &io)
814            .expect_err("locked");
815        assert!(
816            matches!(err, GenApiError::Locked { ref locked_by, .. } if locked_by == "ExposureTime_Lck"),
817            "expected Locked by ExposureTime_Lck, got {err:?}"
818        );
819    }
820
821    /// Clearing the availability bit alone must still refuse — and must say
822    /// "unavailable", not "locked". This is the message the #120 reporter saw,
823    /// reproduced from the state that genuinely warrants it.
824    #[test]
825    fn big_endian_availability_bit_refuses_the_write() {
826        let mut nodemap = NodeMap::try_from_xml(
827            viva_genapi_xml::parse(BIG_ENDIAN_PREDICATE_FIXTURE).expect("parse"),
828        )
829        .expect("nodemap");
830        let io = big_endian_predicate_io(0x8000_0000);
831
832        let err = nodemap
833            .set_float("ExposureTime", 12_000.0, &io)
834            .expect_err("unavailable");
835        assert!(
836            matches!(err, GenApiError::Unavailable(ref n) if n == "ExposureTime"),
837            "expected Unavailable, got {err:?}"
838        );
839    }
840
841    fn build_predicate_nodemap() -> NodeMap {
842        NodeMap::try_from_xml(
843            viva_genapi_xml::parse(PREDICATE_FIXTURE).expect("parse predicate fixture"),
844        )
845        .expect("build nodemap")
846    }
847
848    fn predicate_io(ctrl: u32) -> MockIo {
849        MockIo::with_registers(&[
850            (0x400, ctrl.to_be_bytes().to_vec()),
851            (0x410, 0u32.to_be_bytes().to_vec()),
852            (0x420, 1u32.to_be_bytes().to_vec()),
853        ])
854    }
855
856    #[test]
857    fn predicate_is_implemented_defaults_true() {
858        let nodemap = build_predicate_nodemap();
859        let io = predicate_io(0);
860        // CtrlReg itself has no pIsImplemented → always implemented.
861        assert!(nodemap.is_implemented("CtrlReg", &io).unwrap());
862    }
863
864    #[test]
865    fn predicate_is_implemented_follows_gate() {
866        let nm0 = build_predicate_nodemap();
867        let io0 = predicate_io(0);
868        assert!(!nm0.is_implemented("Gated", &io0).unwrap());
869        let nm1 = build_predicate_nodemap();
870        let io1 = predicate_io(1);
871        assert!(nm1.is_implemented("Gated", &io1).unwrap());
872    }
873
874    #[test]
875    fn predicate_is_available_chains_implemented() {
876        let nm0 = build_predicate_nodemap();
877        let io0 = predicate_io(0);
878        assert!(!nm0.is_available("Gated", &io0).unwrap());
879        let nm1 = build_predicate_nodemap();
880        let io1 = predicate_io(1);
881        assert!(nm1.is_available("Gated", &io1).unwrap());
882    }
883
884    #[test]
885    fn predicate_effective_access_mode_locked_downgrade() {
886        let nodemap = build_predicate_nodemap();
887        // bit 0 set (implemented), bit 1 set (locked) → RW → RO
888        let io = predicate_io(0b11);
889        let mode = nodemap.effective_access_mode("Gated", &io).unwrap();
890        assert_eq!(mode, AccessMode::RO);
891    }
892
893    #[test]
894    fn write_to_a_locked_node_is_refused_locally() {
895        // The #45 shape: the node's static AccessMode is RW and the whole
896        // restriction lives in pIsLocked. Before GA-06 this write went to the
897        // wire and the device answered ACCESS_DENIED.
898        let mut nodemap = build_predicate_nodemap();
899        let io = predicate_io(0b11); // implemented, locked
900        let err = nodemap
901            .set_integer("Gated", 7, &io)
902            .expect_err("a locked node must not be written");
903        match err {
904            GenApiError::Locked { name, locked_by } => {
905                assert_eq!(name, "Gated");
906                // Naming the locking feature is the actionable part.
907                assert_eq!(locked_by, "GateLocked");
908            }
909            other => panic!("expected Locked, got {other:?}"),
910        }
911    }
912
913    #[test]
914    fn write_to_an_unlocked_node_still_succeeds() {
915        let mut nodemap = build_predicate_nodemap();
916        let io = predicate_io(0b01); // implemented, unlocked
917        nodemap
918            .set_integer("Gated", 7, &io)
919            .expect("an unlocked RW node must still be writable");
920    }
921
922    #[test]
923    fn write_to_an_unimplemented_node_reports_unavailable_not_locked() {
924        // The two conditions must stay distinguishable: `effective_access_mode`
925        // collapses both into RO, which is why the setters do not use it.
926        let mut nodemap = build_predicate_nodemap();
927        let io = predicate_io(0b00); // not implemented
928        let err = nodemap
929            .set_integer("Gated", 7, &io)
930            .expect_err("an unavailable node must not be written");
931        assert!(
932            matches!(err, GenApiError::Unavailable(ref n) if n == "Gated"),
933            "expected Unavailable, got {err:?}"
934        );
935    }
936
937    #[test]
938    fn predicate_effective_access_mode_rw_when_unlocked() {
939        let nodemap = build_predicate_nodemap();
940        // implemented, unlocked → base RW
941        let io = predicate_io(0b01);
942        let mode = nodemap.effective_access_mode("Gated", &io).unwrap();
943        assert_eq!(mode, AccessMode::RW);
944    }
945
946    #[test]
947    fn predicate_effective_access_mode_na_for_unavailable() {
948        let nodemap = build_predicate_nodemap();
949        // not implemented → effective access reported as RO (we don't model NA).
950        let io = predicate_io(0);
951        let mode = nodemap.effective_access_mode("Gated", &io).unwrap();
952        assert_eq!(mode, AccessMode::RO);
953    }
954
955    #[test]
956    fn predicate_available_enum_entries_filters() {
957        let nodemap = build_predicate_nodemap();
958        // bit 2 clear → Mono16 gated out; Mono8 has no predicate so it stays.
959        let io = predicate_io(0);
960        let entries = nodemap
961            .available_enum_entries("PixelFormat", &io)
962            .expect("enum entries");
963        assert_eq!(entries, vec!["Mono8".to_string()]);
964    }
965
966    #[test]
967    fn predicate_available_enum_entries_full_when_allowed() {
968        let nodemap = build_predicate_nodemap();
969        // bit 2 set → Mono16 available.
970        let io = predicate_io(0b100);
971        let mut entries = nodemap
972            .available_enum_entries("PixelFormat", &io)
973            .expect("enum entries");
974        entries.sort();
975        assert_eq!(entries, vec!["Mono16".to_string(), "Mono8".to_string()]);
976    }
977
978    #[test]
979    fn predicate_available_enum_entries_fallback_to_static() {
980        // CtrlReg itself isn't an enum; use an enum without entry predicates.
981        let xml = r#"
982            <RegisterDescription SchemaMajorVersion="1" SchemaMinorVersion="0" SchemaSubMinorVersion="0">
983                <Enumeration Name="Mode">
984                    <EnumEntry Name="A"><Value>0</Value></EnumEntry>
985                    <EnumEntry Name="B"><Value>1</Value></EnumEntry>
986                    <pValue>ModeReg</pValue>
987                </Enumeration>
988                <IntReg Name="ModeReg">
989                    <Address>0x500</Address>
990                    <Length>4</Length>
991                    <AccessMode>RW</AccessMode>
992                    <Sign>Unsigned</Sign>
993                    <Endianess>BigEndian</Endianess>
994                </IntReg>
995            </RegisterDescription>
996        "#;
997        let nodemap =
998            NodeMap::try_from_xml(viva_genapi_xml::parse(xml).unwrap()).expect("build nodemap");
999        let io = MockIo::with_registers(&[(0x500, 0u32.to_be_bytes().to_vec())]);
1000        let mut entries = nodemap.available_enum_entries("Mode", &io).unwrap();
1001        entries.sort();
1002        assert_eq!(entries, vec!["A".to_string(), "B".to_string()]);
1003    }
1004
1005    #[test]
1006    fn float_conversion_roundtrip() {
1007        let mut nodemap = build_nodemap();
1008        let raw = 50_000i64; // 50 ms with 1/1000 scale
1009        let io = MockIo::with_registers(&[(
1010            0x200,
1011            i64_to_bytes("ExposureTime", raw, 4, Sign::Signed, ByteOrder::Big).unwrap(),
1012        )]);
1013        let exposure = nodemap
1014            .get_float("ExposureTime", &io)
1015            .expect("read exposure");
1016        assert!((exposure - 50.0).abs() < 1e-6);
1017        nodemap
1018            .set_float("ExposureTime", 75.0, &io)
1019            .expect("write exposure");
1020        let raw_back = bytes_to_i64(
1021            "ExposureTime",
1022            &io.read(0x200, 4).unwrap(),
1023            Sign::Signed,
1024            ByteOrder::Big,
1025        )
1026        .unwrap();
1027        assert_eq!(raw_back, 75_000);
1028    }
1029
1030    #[test]
1031    fn selector_address_switching() {
1032        let mut nodemap = build_nodemap();
1033        let io = MockIo::with_registers(&[
1034            (
1035                0x300,
1036                i64_to_bytes("GainSelector", 0, 2, Sign::Signed, ByteOrder::Big).unwrap(),
1037            ),
1038            (
1039                0x310,
1040                i64_to_bytes("Gain", 10, 2, Sign::Signed, ByteOrder::Big).unwrap(),
1041            ),
1042            (
1043                0x314,
1044                i64_to_bytes("Gain", 24, 2, Sign::Signed, ByteOrder::Big).unwrap(),
1045            ),
1046        ]);
1047
1048        let gain_all = nodemap.get_integer("Gain", &io).expect("gain for All");
1049        assert_eq!(gain_all, 10);
1050        assert_eq!(io.read_count(0x310), 1);
1051        assert_eq!(io.read_count(0x314), 0);
1052
1053        io.write(
1054            0x314,
1055            &i64_to_bytes("Gain", 32, 2, Sign::Signed, ByteOrder::Big).unwrap(),
1056        )
1057        .expect("update red gain");
1058        nodemap
1059            .set_enum("GainSelector", "Red", &io)
1060            .expect("set selector to red");
1061        let gain_red = nodemap.get_integer("Gain", &io).expect("gain for Red");
1062        assert_eq!(gain_red, 32);
1063        assert_eq!(
1064            io.read_count(0x310),
1065            1,
1066            "previous address should not be reread"
1067        );
1068        assert_eq!(io.read_count(0x314), 1);
1069
1070        let gain_red_cached = nodemap.get_integer("Gain", &io).expect("cached red");
1071        assert_eq!(gain_red_cached, 32);
1072        assert_eq!(io.read_count(0x314), 1, "selector cache should be reused");
1073
1074        nodemap
1075            .set_enum("GainSelector", "Blue", &io)
1076            .expect("set selector to blue");
1077        let err = nodemap.get_integer("Gain", &io).unwrap_err();
1078        match err {
1079            GenApiError::Unavailable(msg) => {
1080                assert!(msg.contains("GainSelector=Blue"));
1081            }
1082            other => panic!("unexpected error: {other:?}"),
1083        }
1084        assert_eq!(
1085            io.read_count(0x314),
1086            1,
1087            "no read expected for missing mapping"
1088        );
1089
1090        io.write(
1091            0x310,
1092            &i64_to_bytes("Gain", 12, 2, Sign::Signed, ByteOrder::Big).unwrap(),
1093        )
1094        .expect("update all gain");
1095        nodemap
1096            .set_enum("GainSelector", "All", &io)
1097            .expect("restore selector to all");
1098        let gain_all_updated = nodemap
1099            .get_integer("Gain", &io)
1100            .expect("gain for All again");
1101        assert_eq!(gain_all_updated, 12);
1102        assert_eq!(
1103            io.read_count(0x310),
1104            2,
1105            "address switch should invalidate cache"
1106        );
1107    }
1108
1109    #[test]
1110    fn range_enforcement() {
1111        let mut nodemap = build_nodemap();
1112        let io = MockIo::with_registers(&[(0x100, vec![0, 0, 0, 16])]);
1113        let err = nodemap.set_integer("Width", 17, &io).unwrap_err();
1114        assert!(matches!(err, GenApiError::Range(_)));
1115    }
1116
1117    #[test]
1118    fn command_exec() {
1119        let mut nodemap = build_nodemap();
1120        let io = MockIo::with_registers(&[]);
1121        nodemap
1122            .exec_command("AcquisitionStart", &io)
1123            .expect("exec command");
1124        let payload = io.read(0x500, 4).expect("command write");
1125        assert_eq!(payload, vec![0, 0, 0, 1]);
1126    }
1127
1128    #[test]
1129    fn indirect_address_resolution() {
1130        let mut nodemap = build_indirect_nodemap();
1131        let io = MockIo::with_registers(&[
1132            (
1133                0x2000,
1134                i64_to_bytes("RegAddr", 0x3000, 4, Sign::Signed, ByteOrder::Big).unwrap(),
1135            ),
1136            (
1137                0x3000,
1138                i64_to_bytes("Gain", 123, 4, Sign::Signed, ByteOrder::Big).unwrap(),
1139            ),
1140            (
1141                0x3100,
1142                i64_to_bytes("Gain", 77, 4, Sign::Signed, ByteOrder::Big).unwrap(),
1143            ),
1144        ]);
1145
1146        let initial = nodemap.get_integer("Gain", &io).expect("read gain");
1147        assert_eq!(initial, 123);
1148        assert_eq!(io.read_count(0x2000), 1);
1149        assert_eq!(io.read_count(0x3000), 1);
1150
1151        nodemap
1152            .set_integer("RegAddr", 0x3100, &io)
1153            .expect("set indirect address");
1154        let updated = nodemap
1155            .get_integer("Gain", &io)
1156            .expect("read gain after change");
1157        assert_eq!(updated, 77);
1158        assert_eq!(io.read_count(0x2000), 1);
1159        assert_eq!(io.read_count(0x3000), 1);
1160        assert_eq!(io.read_count(0x3100), 1);
1161    }
1162
1163    /// A `<pAddress>` term that cannot be an address at all is rejected before
1164    /// the read.
1165    ///
1166    /// Zero deliberately is *not* rejected: under the additive address model a
1167    /// `<pAddress>` supplies one term of a sum, and a base of zero next to a
1168    /// fixed `<Address>` offset is ordinary. Only a value that cannot be a
1169    /// register address — a negative one — is a modelling error rather than a
1170    /// device state.
1171    /// A `<pAddress>` term that cannot be an address at all is rejected before
1172    /// the read.
1173    ///
1174    /// Zero deliberately is *not* rejected: under the additive address model a
1175    /// `<pAddress>` supplies one term of a sum, and a base of zero next to a
1176    /// fixed `<Address>` offset is ordinary. Only a value that cannot be a
1177    /// register address — a negative one, which takes a signed provider — is a
1178    /// modelling error rather than a device state.
1179    #[test]
1180    fn indirect_bad_address() {
1181        const XML: &str = r#"
1182            <RegisterDescription SchemaMajorVersion="1" SchemaMinorVersion="0" SchemaSubMinorVersion="0">
1183                <Integer Name="RegAddr">
1184                    <Address>0x2000</Address>
1185                    <Length>4</Length>
1186                    <AccessMode>RW</AccessMode>
1187                    <Sign>Signed</Sign>
1188                    <Min>-65535</Min>
1189                    <Max>65535</Max>
1190                </Integer>
1191                <Integer Name="Gain">
1192                    <pAddress>RegAddr</pAddress>
1193                    <Length>4</Length>
1194                    <AccessMode>RW</AccessMode>
1195                    <Min>0</Min>
1196                    <Max>255</Max>
1197                </Integer>
1198            </RegisterDescription>
1199        "#;
1200
1201        let model = viva_genapi_xml::parse(XML).expect("parse");
1202        let nodemap = NodeMap::try_from_xml(model).expect("build nodemap");
1203        let io = MockIo::with_registers(&[(
1204            0x2000,
1205            i64_to_bytes("RegAddr", -4, 4, Sign::Signed, ByteOrder::Big).unwrap(),
1206        )]);
1207
1208        let err = nodemap.get_integer("Gain", &io).unwrap_err();
1209        match err {
1210            GenApiError::BadIndirectAddress { name, addr } => {
1211                assert_eq!(name, "Gain");
1212                assert_eq!(addr, -4);
1213            }
1214            other => panic!("unexpected error: {other:?}"),
1215        }
1216    }
1217
1218    /// GenICam registers are unsigned unless `<Sign>Signed</Sign>` says
1219    /// otherwise. Sign-extending everything turned an IPv4 address into a
1220    /// negative number and broke every mask comparison downstream.
1221    #[test]
1222    fn unsigned_registers_do_not_sign_extend() {
1223        const XML: &str = r#"
1224            <RegisterDescription SchemaMajorVersion="1" SchemaMinorVersion="0" SchemaSubMinorVersion="0">
1225                <Integer Name="GevCurrentIPAddress">
1226                    <Address>0x1000</Address>
1227                    <Length>4</Length>
1228                    <AccessMode>RO</AccessMode>
1229                    <Min>0</Min>
1230                    <Max>4294967295</Max>
1231                </Integer>
1232                <Integer Name="TemperatureOffset">
1233                    <Address>0x1004</Address>
1234                    <Length>4</Length>
1235                    <AccessMode>RO</AccessMode>
1236                    <Sign>Signed</Sign>
1237                    <Min>-2147483648</Min>
1238                    <Max>2147483647</Max>
1239                </Integer>
1240            </RegisterDescription>
1241        "#;
1242
1243        let model = viva_genapi_xml::parse(XML).expect("parse");
1244        let nodemap = NodeMap::try_from_xml(model).expect("build nodemap");
1245        let io = MockIo::with_registers(&[
1246            // 192.168.1.160 — the top bit is set.
1247            (0x1000, vec![0xC0, 0xA8, 0x01, 0xA0]),
1248            (0x1004, vec![0xFF, 0xFF, 0xFF, 0xFB]),
1249        ]);
1250
1251        assert_eq!(
1252            nodemap
1253                .get_integer("GevCurrentIPAddress", &io)
1254                .expect("read address"),
1255            0xC0A8_01A0
1256        );
1257        assert_eq!(
1258            nodemap
1259                .get_integer("TemperatureOffset", &io)
1260                .expect("read offset"),
1261            -5
1262        );
1263    }
1264
1265    /// `<FormulaFrom>` is the read direction and `<FormulaTo>` the write one.
1266    ///
1267    /// With an identity converter both directions look the same, which is how
1268    /// this stayed inverted: reads evaluated `<FormulaTo>` and every non-trivial
1269    /// converter — a Hikrobot `FROM * 100` / `TO / 100` pair, for instance —
1270    /// came back wrong by the square of its scale factor.
1271    #[test]
1272    fn converter_directions_are_not_swapped() {
1273        const XML: &str = r#"
1274            <RegisterDescription SchemaMajorVersion="1" SchemaMinorVersion="0" SchemaSubMinorVersion="0">
1275                <IntReg Name="ExposureTimeRaw">
1276                    <Address>0x1000</Address>
1277                    <Length>4</Length>
1278                    <AccessMode>RW</AccessMode>
1279                </IntReg>
1280                <Converter Name="ExposureTime">
1281                    <FormulaTo>FROM * 100</FormulaTo>
1282                    <FormulaFrom>TO / 100</FormulaFrom>
1283                    <pValue>ExposureTimeRaw</pValue>
1284                    <Unit>us</Unit>
1285                </Converter>
1286            </RegisterDescription>
1287        "#;
1288
1289        let model = viva_genapi_xml::parse(XML).expect("parse");
1290        let mut nodemap = NodeMap::try_from_xml(model).expect("build nodemap");
1291        let io = MockIo::with_registers(&[(
1292            0x1000,
1293            i64_to_bytes("ExposureTimeRaw", 5000, 4, Sign::Unsigned, ByteOrder::Big).unwrap(),
1294        )]);
1295
1296        // Read goes through FormulaFrom: 5000 / 100.
1297        let value = nodemap.get_float("ExposureTime", &io).expect("read");
1298        assert!((value - 50.0).abs() < 1e-9, "got {value}");
1299
1300        // Write goes through FormulaTo: 75 * 100.
1301        nodemap.set_float("ExposureTime", 75.0, &io).expect("write");
1302        assert_eq!(
1303            nodemap
1304                .get_integer("ExposureTimeRaw", &io)
1305                .expect("read raw back"),
1306            7500
1307        );
1308        let roundtrip = nodemap.get_float("ExposureTime", &io).expect("re-read");
1309        assert!((roundtrip - 75.0).abs() < 1e-9, "got {roundtrip}");
1310    }
1311
1312    /// A read-modify-write `<FormulaTo>` sees the register's current contents
1313    /// through `OLD`.
1314    #[test]
1315    fn int_converter_write_preserves_other_bits() {
1316        const XML: &str = r#"
1317            <RegisterDescription SchemaMajorVersion="1" SchemaMinorVersion="0" SchemaSubMinorVersion="0">
1318                <IntReg Name="Binning_Reg">
1319                    <Address>0x1000</Address>
1320                    <Length>4</Length>
1321                    <AccessMode>RW</AccessMode>
1322                </IntReg>
1323                <IntConverter Name="BinningHorizontal">
1324                    <FormulaTo>FROM | (OLD &amp; 0xffff0000)</FormulaTo>
1325                    <FormulaFrom>TO &amp; 0x0000ffff</FormulaFrom>
1326                    <pVariable Name="OLD">Binning_Reg</pVariable>
1327                    <pValue>Binning_Reg</pValue>
1328                </IntConverter>
1329            </RegisterDescription>
1330        "#;
1331
1332        let model = viva_genapi_xml::parse(XML).expect("parse");
1333        let mut nodemap = NodeMap::try_from_xml(model).expect("build nodemap");
1334        let io = MockIo::with_registers(&[(
1335            0x1000,
1336            i64_to_bytes(
1337                "Binning_Reg",
1338                0x0004_0002,
1339                4,
1340                Sign::Unsigned,
1341                ByteOrder::Big,
1342            )
1343            .unwrap(),
1344        )]);
1345
1346        assert_eq!(
1347            nodemap
1348                .get_integer("BinningHorizontal", &io)
1349                .expect("read low half"),
1350            2
1351        );
1352
1353        nodemap
1354            .set_integer("BinningHorizontal", 3, &io)
1355            .expect("write low half");
1356        // The vertical half in the high word must survive.
1357        assert_eq!(
1358            nodemap
1359                .get_integer("Binning_Reg", &io)
1360                .expect("read raw back"),
1361            0x0004_0003
1362        );
1363    }
1364
1365    /// Signedness comes from `<Sign>` alone — `<Min>` says nothing about it.
1366    ///
1367    /// `<Min>` is optional and defaults to `i64::MIN`, and **no** `<IntReg>` or
1368    /// `<MaskedIntReg>` in the entire vendor corpus declares one: all 9 779 of
1369    /// them omit it. Inferring "signed" from a negative minimum therefore fires
1370    /// on every register-backed integer on every real camera — which is exactly
1371    /// the case `<Sign>` exists to decide. Note the registers here carry no
1372    /// `<Min>`, which is the shape real documents actually use.
1373    #[test]
1374    fn sign_does_not_depend_on_min() {
1375        const XML: &str = r#"
1376            <RegisterDescription SchemaMajorVersion="1" SchemaMinorVersion="0" SchemaSubMinorVersion="0">
1377                <IntReg Name="GevCurrentIPAddress">
1378                    <Address>0x1000</Address>
1379                    <Length>4</Length>
1380                    <AccessMode>RO</AccessMode>
1381                    <Sign>Unsigned</Sign>
1382                </IntReg>
1383                <IntReg Name="DeviceTemperatureRaw">
1384                    <Address>0x1004</Address>
1385                    <Length>4</Length>
1386                    <AccessMode>RO</AccessMode>
1387                    <Sign>Signed</Sign>
1388                </IntReg>
1389                <IntReg Name="ImplicitlyUnsigned">
1390                    <Address>0x1008</Address>
1391                    <Length>4</Length>
1392                    <AccessMode>RO</AccessMode>
1393                </IntReg>
1394            </RegisterDescription>
1395        "#;
1396
1397        let model = viva_genapi_xml::parse(XML).expect("parse");
1398        let nodemap = NodeMap::try_from_xml(model).expect("build nodemap");
1399        let io = MockIo::with_registers(&[
1400            (0x1000, vec![0xC0, 0xA8, 0x01, 0xA0]),
1401            (0x1004, vec![0xFF, 0xFF, 0xFF, 0xFB]),
1402            (0x1008, vec![0xFF, 0xFF, 0xFF, 0xFF]),
1403        ]);
1404
1405        assert_eq!(
1406            nodemap
1407                .get_integer("GevCurrentIPAddress", &io)
1408                .expect("read address"),
1409            0xC0A8_01A0
1410        );
1411        assert_eq!(
1412            nodemap
1413                .get_integer("DeviceTemperatureRaw", &io)
1414                .expect("read temperature"),
1415            -5
1416        );
1417        // No `<Sign>` at all: GenICam's default is unsigned.
1418        assert_eq!(
1419            nodemap
1420                .get_integer("ImplicitlyUnsigned", &io)
1421                .expect("read default-signed register"),
1422            0xFFFF_FFFFu32 as i64
1423        );
1424    }
1425
1426    /// A `<StructReg>` bit reads as 1, not -1.
1427    ///
1428    /// Entries used to declare the full `i64` range, which looked like a
1429    /// signed field; a set bit then sign-extended to -1 and every
1430    /// `(INQ = 1)` test in the document silently failed.
1431    #[test]
1432    fn struct_entry_bits_read_unsigned() {
1433        const XML: &str = r#"
1434            <RegisterDescription SchemaMajorVersion="1" SchemaMinorVersion="0" SchemaSubMinorVersion="0">
1435                <StructReg Comment="Gain Inquiry Register">
1436                    <Address>0x520</Address>
1437                    <Length>4</Length>
1438                    <AccessMode>RO</AccessMode>
1439                    <Endianess>BigEndian</Endianess>
1440                    <StructEntry Name="GainPresInq_Bit"><Bit>0</Bit></StructEntry>
1441                    <StructEntry Name="GainAutoInq_Bit"><Bit>6</Bit></StructEntry>
1442                </StructReg>
1443            </RegisterDescription>
1444        "#;
1445
1446        let model = viva_genapi_xml::parse(XML).expect("parse");
1447        let nodemap = NodeMap::try_from_xml(model).expect("build nodemap");
1448        // `<Bit>` indices count from the MSB on a big-endian register (the
1449        // same rule the reference implementation applies), so bits 0 and 6 are
1450        // the top byte's 0x80 and 0x02.
1451        let io = MockIo::with_registers(&[(0x520, vec![0x82, 0x00, 0x00, 0x00])]);
1452
1453        assert_eq!(
1454            nodemap
1455                .get_integer("GainPresInq_Bit", &io)
1456                .expect("read presence bit"),
1457            1
1458        );
1459        assert_eq!(
1460            nodemap
1461                .get_integer("GainAutoInq_Bit", &io)
1462                .expect("read auto bit"),
1463            1
1464        );
1465    }
1466
1467    /// The whole point of the additive model: `<Address>` and `<pAddress>` on
1468    /// the same register add up. Keeping only one of them silently read the
1469    /// wrong register on FLIR, Point Grey and Hikrobot cameras (issue #35).
1470    #[test]
1471    fn address_terms_sum() {
1472        const XML: &str = r#"
1473            <RegisterDescription SchemaMajorVersion="1" SchemaMinorVersion="0" SchemaSubMinorVersion="0">
1474                <Integer Name="RegBase">
1475                    <Address>0x2000</Address>
1476                    <Length>4</Length>
1477                    <AccessMode>RW</AccessMode>
1478                    <Min>0</Min>
1479                    <Max>65535</Max>
1480                </Integer>
1481                <Integer Name="Gain">
1482                    <pAddress>RegBase</pAddress>
1483                    <Address>0x8</Address>
1484                    <Length>4</Length>
1485                    <AccessMode>RW</AccessMode>
1486                    <Min>0</Min>
1487                    <Max>255</Max>
1488                </Integer>
1489            </RegisterDescription>
1490        "#;
1491
1492        let model = viva_genapi_xml::parse(XML).expect("parse");
1493        let nodemap = NodeMap::try_from_xml(model).expect("build nodemap");
1494        let io = MockIo::with_registers(&[
1495            (
1496                0x2000,
1497                i64_to_bytes("RegBase", 0x3000, 4, Sign::Signed, ByteOrder::Big).unwrap(),
1498            ),
1499            // Only the summed address holds the value we expect.
1500            (
1501                0x3008,
1502                i64_to_bytes("Gain", 77, 4, Sign::Signed, ByteOrder::Big).unwrap(),
1503            ),
1504            (
1505                0x3000,
1506                i64_to_bytes("Gain", 11, 4, Sign::Signed, ByteOrder::Big).unwrap(),
1507            ),
1508        ]);
1509
1510        assert_eq!(nodemap.get_integer("Gain", &io).expect("read gain"), 77);
1511
1512        // The same addressing, reached by name for raw register access.
1513        assert_eq!(
1514            nodemap
1515                .register_address("Gain", &io)
1516                .expect("resolve summed address"),
1517            (0x3008, 4)
1518        );
1519        // An unknown name is a named error, not a panic.
1520        assert!(matches!(
1521            nodemap.register_address("NoSuchNode", &io),
1522            Err(GenApiError::NodeNotFound(_))
1523        ));
1524    }
1525
1526    /// `<pIndex Offset="N">` scales an index node into the address. AVT Manta
1527    /// and Prosilica use it for every per-trigger inquiry register.
1528    #[test]
1529    fn p_index_scales_into_the_address() {
1530        const XML: &str = r#"
1531            <RegisterDescription SchemaMajorVersion="1" SchemaMinorVersion="0" SchemaSubMinorVersion="0">
1532                <Integer Name="TriggerSelectorIdx">
1533                    <Address>0x2000</Address>
1534                    <Length>4</Length>
1535                    <AccessMode>RW</AccessMode>
1536                    <Min>0</Min>
1537                    <Max>7</Max>
1538                </Integer>
1539                <Integer Name="TriggerInqDelay">
1540                    <Address>0x13400</Address>
1541                    <pIndex Offset="64">TriggerSelectorIdx</pIndex>
1542                    <Length>4</Length>
1543                    <AccessMode>RO</AccessMode>
1544                    <Min>0</Min>
1545                    <Max>255</Max>
1546                </Integer>
1547            </RegisterDescription>
1548        "#;
1549
1550        let model = viva_genapi_xml::parse(XML).expect("parse");
1551        let mut nodemap = NodeMap::try_from_xml(model).expect("build nodemap");
1552        let io = MockIo::with_registers(&[
1553            (
1554                0x2000,
1555                i64_to_bytes("TriggerSelectorIdx", 2, 4, Sign::Signed, ByteOrder::Big).unwrap(),
1556            ),
1557            (
1558                0x13400,
1559                i64_to_bytes("TriggerInqDelay", 1, 4, Sign::Signed, ByteOrder::Big).unwrap(),
1560            ),
1561            (
1562                0x13480,
1563                i64_to_bytes("TriggerInqDelay", 42, 4, Sign::Signed, ByteOrder::Big).unwrap(),
1564            ),
1565        ]);
1566
1567        // index 2 * stride 64 = 0x80 past the base.
1568        assert_eq!(
1569            nodemap
1570                .get_integer("TriggerInqDelay", &io)
1571                .expect("read indexed register"),
1572            42
1573        );
1574
1575        // Changing the index moves the register.
1576        nodemap
1577            .set_integer("TriggerSelectorIdx", 0, &io)
1578            .expect("select index 0");
1579        assert_eq!(
1580            nodemap
1581                .get_integer("TriggerInqDelay", &io)
1582                .expect("read indexed register"),
1583            1
1584        );
1585    }
1586
1587    #[test]
1588    fn enum_literal_entry_read() {
1589        let nodemap = build_enum_pvalue_nodemap();
1590        let io = MockIo::with_registers(&[
1591            (
1592                0x4000,
1593                i64_to_bytes("Mode", 10, 4, Sign::Signed, ByteOrder::Big).unwrap(),
1594            ),
1595            (
1596                0x4100,
1597                i64_to_bytes("RegModeVal", 42, 4, Sign::Signed, ByteOrder::Big).unwrap(),
1598            ),
1599        ]);
1600
1601        let value = nodemap.get_enum("Mode", &io).expect("read mode");
1602        assert_eq!(value, "Fixed10");
1603        assert_eq!(
1604            io.read_count(0x4100),
1605            1,
1606            "provider should be read once for mapping"
1607        );
1608    }
1609
1610    #[test]
1611    fn enum_provider_entry_read() {
1612        let nodemap = build_enum_pvalue_nodemap();
1613        let io = MockIo::with_registers(&[
1614            (
1615                0x4000,
1616                i64_to_bytes("Mode", 42, 4, Sign::Signed, ByteOrder::Big).unwrap(),
1617            ),
1618            (
1619                0x4100,
1620                i64_to_bytes("RegModeVal", 42, 4, Sign::Signed, ByteOrder::Big).unwrap(),
1621            ),
1622        ]);
1623
1624        let value = nodemap.get_enum("Mode", &io).expect("read dynamic mode");
1625        assert_eq!(value, "DynFromReg");
1626        assert_eq!(io.read_count(0x4100), 1);
1627    }
1628
1629    #[test]
1630    fn enum_set_uses_provider_value() {
1631        let mut nodemap = build_enum_pvalue_nodemap();
1632        let io = MockIo::with_registers(&[
1633            (
1634                0x4000,
1635                i64_to_bytes("Mode", 0, 4, Sign::Signed, ByteOrder::Big).unwrap(),
1636            ),
1637            (
1638                0x4100,
1639                i64_to_bytes("RegModeVal", 42, 4, Sign::Signed, ByteOrder::Big).unwrap(),
1640            ),
1641        ]);
1642
1643        nodemap
1644            .set_enum("Mode", "DynFromReg", &io)
1645            .expect("write enum");
1646        let raw = bytes_to_i64(
1647            "Mode",
1648            &io.read(0x4000, 4).unwrap(),
1649            Sign::Signed,
1650            ByteOrder::Big,
1651        )
1652        .unwrap();
1653        assert_eq!(raw, 42);
1654        assert_eq!(io.read_count(0x4100), 1);
1655    }
1656
1657    #[test]
1658    fn enum_provider_update_invalidates_mapping() {
1659        let mut nodemap = build_enum_pvalue_nodemap();
1660        let io = MockIo::with_registers(&[
1661            (
1662                0x4000,
1663                i64_to_bytes("Mode", 42, 4, Sign::Signed, ByteOrder::Big).unwrap(),
1664            ),
1665            (
1666                0x4100,
1667                i64_to_bytes("RegModeVal", 42, 4, Sign::Signed, ByteOrder::Big).unwrap(),
1668            ),
1669        ]);
1670
1671        assert_eq!(nodemap.get_enum("Mode", &io).unwrap(), "DynFromReg");
1672        assert_eq!(io.read_count(0x4100), 1);
1673
1674        nodemap
1675            .set_integer("RegModeVal", 17, &io)
1676            .expect("update provider");
1677        io.write(
1678            0x4000,
1679            &i64_to_bytes("Mode", 0, 4, Sign::Signed, ByteOrder::Big).unwrap(),
1680        )
1681        .expect("reset mode register");
1682
1683        nodemap
1684            .set_enum("Mode", "DynFromReg", &io)
1685            .expect("write enum after provider change");
1686        let raw = bytes_to_i64(
1687            "Mode",
1688            &io.read(0x4000, 4).unwrap(),
1689            Sign::Signed,
1690            ByteOrder::Big,
1691        )
1692        .unwrap();
1693        assert_eq!(raw, 17);
1694    }
1695
1696    #[test]
1697    fn enum_unknown_value_error() {
1698        let nodemap = build_enum_pvalue_nodemap();
1699        let io = MockIo::with_registers(&[
1700            (
1701                0x4000,
1702                i64_to_bytes("Mode", 99, 4, Sign::Signed, ByteOrder::Big).unwrap(),
1703            ),
1704            (
1705                0x4100,
1706                i64_to_bytes("RegModeVal", 42, 4, Sign::Signed, ByteOrder::Big).unwrap(),
1707            ),
1708        ]);
1709
1710        let err = nodemap.get_enum("Mode", &io).unwrap_err();
1711        match err {
1712            GenApiError::EnumValueUnknown { node, value } => {
1713                assert_eq!(node, "Mode");
1714                assert_eq!(value, 99);
1715            }
1716            other => panic!("unexpected error: {other:?}"),
1717        }
1718    }
1719
1720    #[test]
1721    fn enum_entries_are_sorted() {
1722        let nodemap = build_enum_pvalue_nodemap();
1723        let entries = nodemap.enum_entries("Mode").expect("entries");
1724        assert_eq!(
1725            entries,
1726            vec!["DynFromReg".to_string(), "Fixed10".to_string()]
1727        );
1728    }
1729
1730    #[test]
1731    fn bitfield_le_integer_roundtrip() {
1732        let mut nodemap = build_bitfield_nodemap();
1733        let io = MockIo::with_registers(&[(0x5000, vec![0xAA, 0xBB, 0xCC, 0xDD])]);
1734
1735        let value = nodemap
1736            .get_integer("LeByte", &io)
1737            .expect("read little-endian field");
1738        assert_eq!(value, 0xBB);
1739
1740        nodemap
1741            .set_integer("LeByte", 0x55, &io)
1742            .expect("write little-endian field");
1743        let data = io.read(0x5000, 4).expect("read back register");
1744        assert_eq!(data, vec![0xAA, 0x55, 0xCC, 0xDD]);
1745    }
1746
1747    #[test]
1748    fn bitfield_be_integer_roundtrip() {
1749        let mut nodemap = build_bitfield_nodemap();
1750        let io = MockIo::with_registers(&[(0x5004, vec![0b1010_0000, 0b0000_0000])]);
1751
1752        let value = nodemap
1753            .get_integer("BeBits", &io)
1754            .expect("read big-endian bits");
1755        assert_eq!(value, 0b101);
1756
1757        nodemap
1758            .set_integer("BeBits", 0b010, &io)
1759            .expect("write big-endian bits");
1760        let data = io.read(0x5004, 2).expect("read back register");
1761        assert_eq!(data, vec![0b0100_0000, 0b0000_0000]);
1762    }
1763
1764    #[test]
1765    fn bitfield_boolean_toggle() {
1766        let mut nodemap = build_bitfield_nodemap();
1767        let io = MockIo::with_registers(&[(0x5006, vec![0x00, 0x20, 0x00, 0x00])]);
1768
1769        assert!(nodemap.get_bool("PackedFlag", &io).expect("read flag"));
1770
1771        nodemap
1772            .set_bool("PackedFlag", false, &io)
1773            .expect("clear flag");
1774        let data = io.read(0x5006, 4).expect("read cleared");
1775        assert_eq!(data, vec![0x00, 0x00, 0x00, 0x00]);
1776
1777        nodemap.set_bool("PackedFlag", true, &io).expect("set flag");
1778        let data = io.read(0x5006, 4).expect("read set");
1779        assert_eq!(data, vec![0x00, 0x20, 0x00, 0x00]);
1780    }
1781
1782    #[test]
1783    fn bitfield_value_too_wide() {
1784        let mut nodemap = build_bitfield_nodemap();
1785        let io = MockIo::with_registers(&[(0x5004, vec![0x00, 0x00])]);
1786
1787        let err = nodemap
1788            .set_integer("BeBits", 8, &io)
1789            .expect_err("value too wide");
1790        match err {
1791            GenApiError::ValueTooWide {
1792                name, bit_length, ..
1793            } => {
1794                assert_eq!(name, "BeBits");
1795                assert_eq!(bit_length, 3);
1796            }
1797            other => panic!("unexpected error: {other:?}"),
1798        }
1799    }
1800    #[test]
1801    fn swissknife_evaluates_and_invalidates() {
1802        let mut nodemap = build_swissknife_nodemap();
1803        let io = MockIo::with_registers(&[
1804            (
1805                0x3000,
1806                i64_to_bytes("GainRaw", 100, 4, Sign::Signed, ByteOrder::Big).unwrap(),
1807            ),
1808            (
1809                0x3008,
1810                i64_to_bytes("Offset", 3, 4, Sign::Signed, ByteOrder::Big).unwrap(),
1811            ),
1812            (
1813                0x3010,
1814                i64_to_bytes("B", 1, 4, Sign::Signed, ByteOrder::Big).unwrap(),
1815            ),
1816        ]);
1817
1818        let value = nodemap
1819            .get_float("ComputedGain", &io)
1820            .expect("compute gain");
1821        assert!((value - 53.0).abs() < 1e-6);
1822
1823        nodemap
1824            .set_integer("GainRaw", 120, &io)
1825            .expect("update raw gain");
1826        let updated = nodemap
1827            .get_float("ComputedGain", &io)
1828            .expect("recompute gain");
1829        assert!((updated - 63.0).abs() < 1e-6);
1830    }
1831
1832    #[test]
1833    fn swissknife_integer_rounding_and_unary() {
1834        let mut nodemap = build_swissknife_nodemap();
1835        let io = MockIo::with_registers(&[
1836            (
1837                0x3000,
1838                i64_to_bytes("GainRaw", 5, 4, Sign::Signed, ByteOrder::Big).unwrap(),
1839            ),
1840            (
1841                0x3008,
1842                i64_to_bytes("Offset", 0, 4, Sign::Signed, ByteOrder::Big).unwrap(),
1843            ),
1844            (
1845                0x3010,
1846                i64_to_bytes("B", 1, 4, Sign::Signed, ByteOrder::Big).unwrap(),
1847            ),
1848        ]);
1849
1850        // `<IntSwissKnife>` evaluates in integer arithmetic, so 5 / 3 truncates
1851        // to 1. Rounding to 2 would mean the division ran in floating point.
1852        let divided = nodemap
1853            .get_integer("DivideInt", &io)
1854            .expect("integer division");
1855        assert_eq!(divided, 1);
1856
1857        nodemap
1858            .set_integer("GainRaw", 3, &io)
1859            .expect("update gain raw");
1860        let unary = nodemap.get_integer("Unary", &io).expect("unary expression");
1861        assert_eq!(unary, 7);
1862    }
1863
1864    #[test]
1865    fn swissknife_unknown_variable_error() {
1866        const XML: &str = r#"
1867            <RegisterDescription SchemaMajorVersion="1" SchemaMinorVersion="0" SchemaSubMinorVersion="0">
1868                <Integer Name="A">
1869                    <Address>0x2000</Address>
1870                    <Length>4</Length>
1871                    <AccessMode>RW</AccessMode>
1872                    <Min>0</Min>
1873                    <Max>100</Max>
1874                </Integer>
1875                <SwissKnife Name="Bad">
1876                    <Expression>A + Missing</Expression>
1877                    <pVariable Name="A">A</pVariable>
1878                </SwissKnife>
1879            </RegisterDescription>
1880        "#;
1881
1882        let model = viva_genapi_xml::parse(XML).expect("parse invalid swissknife");
1883        let nodemap = NodeMap::try_from_xml(model).expect("model builds despite the bad node");
1884
1885        // The unusable node is dropped and recorded, not fatal: issues #35 and
1886        // #45 were both a single odd node making an entire camera unopenable.
1887        let skipped = nodemap.skipped();
1888        assert_eq!(skipped.len(), 1, "expected exactly one dropped node");
1889        assert_eq!(skipped[0].name.as_deref(), Some("Bad"));
1890        assert!(
1891            skipped[0].error.contains("Missing"),
1892            "the record should name the unresolved variable: {}",
1893            skipped[0].error
1894        );
1895        assert!(nodemap.node("Bad").is_none());
1896
1897        // Everything else still works.
1898        assert!(nodemap.node("A").is_some());
1899    }
1900
1901    /// A formula our parser cannot handle costs that feature, not the camera.
1902    #[test]
1903    fn unparsable_formula_is_isolated() {
1904        const XML: &str = r#"
1905            <RegisterDescription SchemaMajorVersion="1" SchemaMinorVersion="0" SchemaSubMinorVersion="0">
1906                <Integer Name="Width">
1907                    <Address>0x2000</Address>
1908                    <Length>4</Length>
1909                    <AccessMode>RW</AccessMode>
1910                    <Min>0</Min>
1911                    <Max>4096</Max>
1912                </Integer>
1913                <IntSwissKnife Name="Broken">
1914                    <Formula>Width +</Formula>
1915                    <pVariable Name="Width">Width</pVariable>
1916                </IntSwissKnife>
1917            </RegisterDescription>
1918        "#;
1919
1920        let model = viva_genapi_xml::parse(XML).expect("parse model");
1921        let nodemap = NodeMap::try_from_xml(model).expect("model builds despite the bad formula");
1922        assert_eq!(nodemap.skipped().len(), 1);
1923        assert_eq!(nodemap.skipped()[0].name.as_deref(), Some("Broken"));
1924        assert!(nodemap.node("Width").is_some());
1925    }
1926
1927    #[test]
1928    fn swissknife_division_by_zero() {
1929        let nodemap = build_swissknife_nodemap();
1930        let io = MockIo::with_registers(&[
1931            (
1932                0x3000,
1933                i64_to_bytes("GainRaw", 10, 4, Sign::Signed, ByteOrder::Big).unwrap(),
1934            ),
1935            (
1936                0x3008,
1937                i64_to_bytes("Offset", 0, 4, Sign::Signed, ByteOrder::Big).unwrap(),
1938            ),
1939            (
1940                0x3010,
1941                i64_to_bytes("B", 0, 4, Sign::Signed, ByteOrder::Big).unwrap(),
1942            ),
1943        ]);
1944
1945        let err = nodemap
1946            .get_float("DivideByZero", &io)
1947            .expect_err("division by zero");
1948        match err {
1949            GenApiError::ExprEval { name, msg } => {
1950                assert_eq!(name, "DivideByZero");
1951                assert_eq!(msg, "division by zero");
1952            }
1953            other => panic!("unexpected error: {other:?}"),
1954        }
1955    }
1956
1957    // -----------------------------------------------------------------------
1958    // nodes_at_visibility
1959    // -----------------------------------------------------------------------
1960
1961    const VISIBILITY_FIXTURE: &str = r#"
1962        <RegisterDescription SchemaMajorVersion="1" SchemaMinorVersion="0" SchemaSubMinorVersion="0">
1963            <Integer Name="BeginnerNode">
1964                <Address>0x6000</Address>
1965                <Length>4</Length>
1966                <AccessMode>RW</AccessMode>
1967                <Visibility>Beginner</Visibility>
1968                <Min>0</Min>
1969                <Max>100</Max>
1970            </Integer>
1971            <Integer Name="ExpertNode">
1972                <Address>0x6010</Address>
1973                <Length>4</Length>
1974                <AccessMode>RW</AccessMode>
1975                <Visibility>Expert</Visibility>
1976                <Min>0</Min>
1977                <Max>100</Max>
1978            </Integer>
1979            <Integer Name="GuruNode">
1980                <Address>0x6020</Address>
1981                <Length>4</Length>
1982                <AccessMode>RW</AccessMode>
1983                <Visibility>Guru</Visibility>
1984                <Min>0</Min>
1985                <Max>100</Max>
1986            </Integer>
1987            <Integer Name="InvisibleNode">
1988                <Address>0x6030</Address>
1989                <Length>4</Length>
1990                <AccessMode>RW</AccessMode>
1991                <Visibility>Invisible</Visibility>
1992                <Min>0</Min>
1993                <Max>100</Max>
1994            </Integer>
1995        </RegisterDescription>
1996    "#;
1997
1998    #[test]
1999    fn nodes_at_visibility_beginner_returns_only_beginner() {
2000        let model = viva_genapi_xml::parse(VISIBILITY_FIXTURE).expect("parse visibility fixture");
2001        let nodemap = NodeMap::try_from_xml(model).expect("build nodemap");
2002
2003        let visible = nodemap.nodes_at_visibility(Visibility::Beginner);
2004        assert!(
2005            visible.contains(&"BeginnerNode"),
2006            "Beginner node must be visible at Beginner level"
2007        );
2008        assert!(
2009            !visible.contains(&"ExpertNode"),
2010            "Expert node must NOT be visible at Beginner level"
2011        );
2012        assert!(
2013            !visible.contains(&"GuruNode"),
2014            "Guru node must NOT be visible at Beginner level"
2015        );
2016        assert!(
2017            !visible.contains(&"InvisibleNode"),
2018            "Invisible node must NOT be visible at Beginner level"
2019        );
2020    }
2021
2022    #[test]
2023    fn nodes_at_visibility_guru_includes_beginner_and_expert_but_not_invisible() {
2024        let model = viva_genapi_xml::parse(VISIBILITY_FIXTURE).expect("parse visibility fixture");
2025        let nodemap = NodeMap::try_from_xml(model).expect("build nodemap");
2026
2027        let visible = nodemap.nodes_at_visibility(Visibility::Guru);
2028        assert!(
2029            visible.contains(&"BeginnerNode"),
2030            "Beginner node must be visible at Guru level"
2031        );
2032        assert!(
2033            visible.contains(&"ExpertNode"),
2034            "Expert node must be visible at Guru level"
2035        );
2036        assert!(
2037            visible.contains(&"GuruNode"),
2038            "Guru node must be visible at Guru level"
2039        );
2040        assert!(
2041            !visible.contains(&"InvisibleNode"),
2042            "Invisible node must NOT be visible at Guru level"
2043        );
2044    }
2045}