1use crate::address::{default_format_by_device_type, is_direct_bit_device_type};
2use crate::error::HostLinkError;
3use crate::model::KvModelInfo;
4use std::sync::OnceLock;
5
6const RANGE_CSV_DATA: &str = r#"DeviceType,Base,KV-NANO,KV-NANO(XYM),KV-3000/5000,KV-3000/5000(XYM),KV-7000,KV-7000(XYM),KV-8000,KV-8000(XYM),KV-X500,KV-X500(XYM)
7R,10,R00000-R59915,"X0-599F,Y0-599F",R00000-R99915,"X0-999F,Y0-999F",R00000-R199915,"X0-1999F,Y0-1999F",R00000-R199915,"X0-1999F,Y0-1999F",R00000-R199915,"X0-1999F,Y0-1999F"
8B,16,B0000-B1FFF,B0000-B1FFF,B0000-B3FFF,B0000-B3FFF,B0000-B7FFF,B0000-B7FFF,B0000-B7FFF,B0000-B7FFF,B0000-B7FFF,B0000-B7FFF
9MR,10,MR00000-MR59915,M0-9599,MR00000-MR99915,M0-15999,MR000000-MR399915,M000000-M63999,MR000000-MR399915,M000000-M63999,MR000000-MR399915,M000000-M63999
10LR,10,LR00000-LR19915,L0-3199,LR00000-LR99915,L0-15999,LR00000-LR99915,L00000-L15999,LR00000-LR99915,L00000-L15999,LR00000-LR99915,L00000-L15999
11CR,10,CR0000-CR8915,CR0000-CR8915,CR0000-CR3915,CR0000-CR3915,CR0000-CR7915,CR0000-CR7915,CR0000-CR7915,CR0000-CR7915,CR0000-CR7915,CR0000-CR7915
12CM,10,CM0000-CM8999,CM0000-CM8999,CM0000-CM5999,CM0000-CM5999,CM0000-CM5999,CM0000-CM5999,CM0000-CM7599,CM0000-CM7599,CM0000-CM7599,CM0000-CM7599
13T,10,T0000-T0511,T0000-T0511,T0000-T3999,T0000-T3999,T0000-T3999,T0000-T3999,T0000-T3999,T0000-T3999,T0000-T3999,T0000-T3999
14C,10,C0000-C0255,C0000-C0255,C0000-C3999,C0000-C3999,C0000-C3999,C0000-C3999,C0000-C3999,C0000-C3999,C0000-C3999,C0000-C3999
15DM,10,DM00000-DM32767,D0-32767,DM00000-DM65534,D0-65534,DM00000-DM65534,D00000-D65534,DM00000-DM65534,D00000-D65534,DM00000-DM65534,D00000-D65534
16EM,10,-,-,EM00000-EM65534,E0-65534,EM00000-EM65534,E00000-E65534,EM00000-EM65534,E00000-E65534,EM00000-EM65534,E00000-E65534
17FM,10,-,-,FM00000-FM32767,F0-32767,FM00000-FM32767,F00000-F32767,FM00000-FM32767,F00000-F32767,FM00000-FM32767,F00000-F32767
18ZF,10,-,-,ZF000000-ZF131071,ZF000000-ZF131071,ZF000000-ZF524287,ZF000000-ZF524287,ZF000000-ZF524287,ZF000000-ZF524287,ZF000000-ZF524287,ZF000000-ZF524287
19W,16,W0000-W3FFF,W0000-W3FFF,W0000-W3FFF,W0000-W3FFF,W0000-W7FFF,W0000-W7FFF,W0000-W7FFF,W0000-W7FFF,W0000-W7FFF,W0000-W7FFF
20TM,10,TM000-TM511,TM000-TM511,TM000-TM511,TM000-TM511,TM000-TM511,TM000-TM511,TM000-TM511,TM000-TM511,TM000-TM511,TM000-TM511
21VM,10,VM0-9499,VM0-9499,VM0-49999,VM0-49999,VM0-63999,VM0-63999,VM0-589823,VM0-589823,-,-
22VB,16,VB0-1FFF,VB0-1FFF,VB0-3FFF,VB0-3FFF,VB0-F9FF,VB0-F9FF,VB0-F9FF,VB0-F9FF,-,-
23Z,10,Z1-12,Z1-12,Z1-12,Z1-12,Z1-12,Z1-12,Z1-12,Z1-12,-,-
24CTH,10,CTH0-3,CTH0-3,CTH0-1,CTH0-3,-,-,-,-,-,-
25CTC,10,CTC0-7,CTC0-7,CTC0-3,CTC0-3,-,-,-,-,-,-
26AT,10,-,-,AT0-7,AT0-7,AT0-7,AT0-7,AT0-7,AT0-7,-,-
27"#;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum KvDeviceRangeNotation {
31 Decimal,
32 Hexadecimal,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum KvDeviceRangeCategory {
37 Bit,
38 Word,
39 TimerCounter,
40 Index,
41 FileRefresh,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct KvDeviceRangeSegment {
46 pub device: String,
47 pub category: KvDeviceRangeCategory,
48 pub is_bit_device: bool,
49 pub notation: KvDeviceRangeNotation,
50 pub lower_bound: u32,
51 pub upper_bound: Option<u32>,
52 pub point_count: Option<u32>,
53 pub address_range: String,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct KvDeviceRangeEntry {
58 pub device: String,
59 pub device_type: String,
60 pub category: KvDeviceRangeCategory,
61 pub is_bit_device: bool,
62 pub notation: KvDeviceRangeNotation,
63 pub supported: bool,
64 pub lower_bound: u32,
65 pub upper_bound: Option<u32>,
66 pub point_count: Option<u32>,
67 pub address_range: Option<String>,
68 pub source: String,
69 pub notes: Option<String>,
70 pub segments: Vec<KvDeviceRangeSegment>,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct KvDeviceRangeCatalog {
75 pub model: String,
76 pub model_code: String,
77 pub has_model_code: bool,
78 pub requested_model: String,
79 pub resolved_model: String,
80 pub entries: Vec<KvDeviceRangeEntry>,
81}
82
83impl KvDeviceRangeCatalog {
84 pub fn entry(&self, device_type: &str) -> Option<&KvDeviceRangeEntry> {
85 let wanted = device_type.trim();
86 self.entries
87 .iter()
88 .find(|entry| entry.device_type.eq_ignore_ascii_case(wanted))
89 .or_else(|| {
90 self.entries
91 .iter()
92 .find(|entry| entry.device.eq_ignore_ascii_case(wanted))
93 })
94 .or_else(|| {
95 self.entries.iter().find(|entry| {
96 entry
97 .segments
98 .iter()
99 .any(|segment| segment.device.eq_ignore_ascii_case(wanted))
100 })
101 })
102 }
103}
104
105pub fn device_range_catalog_for_model(
106 model: impl AsRef<str>,
107) -> Result<KvDeviceRangeCatalog, HostLinkError> {
108 build_catalog(model.as_ref(), None)
109}
110
111pub(crate) fn device_range_catalog_for_query_model(
112 model: &KvModelInfo,
113) -> Result<KvDeviceRangeCatalog, HostLinkError> {
114 build_catalog(&model.model, Some(&model.code))
115}
116
117fn build_catalog(
118 requested_model: &str,
119 model_code: Option<&str>,
120) -> Result<KvDeviceRangeCatalog, HostLinkError> {
121 let requested_model = requested_model.trim().to_owned();
122 if requested_model.is_empty() {
123 return Err(HostLinkError::protocol("Model name must not be empty"));
124 }
125
126 let table = range_table()?;
127 let resolved_model = resolve_model_column(table, &requested_model)?;
128 let model_index = table
129 .model_headers
130 .iter()
131 .position(|header| header == resolved_model)
132 .ok_or_else(|| {
133 HostLinkError::protocol(format!(
134 "Resolved model column '{resolved_model}' was not found in the embedded device range table."
135 ))
136 })?;
137
138 let entries = table
139 .rows
140 .iter()
141 .map(|row| build_entry(row, model_index, resolved_model))
142 .collect::<Vec<_>>();
143
144 Ok(KvDeviceRangeCatalog {
145 model: resolved_model.to_owned(),
146 model_code: model_code.unwrap_or_default().to_owned(),
147 has_model_code: model_code.is_some(),
148 requested_model,
149 resolved_model: resolved_model.to_owned(),
150 entries,
151 })
152}
153
154pub fn available_device_range_models() -> Vec<String> {
155 range_table()
156 .map(|table| table.model_headers.clone())
157 .unwrap_or_default()
158}
159
160#[derive(Debug, Clone)]
161struct RangeTable {
162 model_headers: Vec<String>,
163 rows: Vec<RangeRow>,
164}
165
166#[derive(Debug, Clone)]
167struct RangeRow {
168 device_type: String,
169 notation: KvDeviceRangeNotation,
170 ranges: Vec<String>,
171}
172
173static RANGE_TABLE: OnceLock<Result<RangeTable, String>> = OnceLock::new();
174
175fn range_table() -> Result<&'static RangeTable, HostLinkError> {
176 RANGE_TABLE
177 .get_or_init(|| parse_range_table().map_err(|error| error.to_string()))
178 .as_ref()
179 .map_err(|error| HostLinkError::protocol(error.clone()))
180}
181
182fn parse_range_table() -> Result<RangeTable, HostLinkError> {
183 let mut lines = RANGE_CSV_DATA
184 .lines()
185 .filter(|line| !line.trim().is_empty());
186 let header_line = lines
187 .next()
188 .ok_or_else(|| HostLinkError::protocol("Embedded device range table is empty"))?;
189 let headers = parse_csv_line(header_line)?;
190 if headers.len() < 3 {
191 return Err(HostLinkError::protocol(
192 "Embedded device range table must contain at least DeviceType, Base, and one model column",
193 ));
194 }
195
196 let model_headers = headers[2..]
197 .iter()
198 .map(|header| header.trim().to_owned())
199 .collect::<Vec<_>>();
200 let mut rows = Vec::new();
201
202 for line in lines {
203 let fields = parse_csv_line(line)?;
204 if fields.len() != headers.len() {
205 return Err(HostLinkError::protocol(format!(
206 "Embedded device range row has {} columns but {} were expected: {line}",
207 fields.len(),
208 headers.len()
209 )));
210 }
211
212 rows.push(RangeRow {
213 device_type: fields[0].trim().to_owned(),
214 notation: notation_from_base(&fields[1])?,
215 ranges: fields[2..]
216 .iter()
217 .map(|value| value.trim().to_owned())
218 .collect(),
219 });
220 }
221
222 Ok(RangeTable {
223 model_headers,
224 rows,
225 })
226}
227
228fn parse_csv_line(line: &str) -> Result<Vec<String>, HostLinkError> {
229 let mut fields = Vec::new();
230 let mut current = String::new();
231 let mut chars = line.trim_end_matches('\r').chars().peekable();
232 let mut in_quotes = false;
233
234 while let Some(ch) = chars.next() {
235 match ch {
236 '"' => {
237 if in_quotes && chars.peek() == Some(&'"') {
238 current.push('"');
239 chars.next();
240 } else {
241 in_quotes = !in_quotes;
242 }
243 }
244 ',' if !in_quotes => {
245 fields.push(current);
246 current = String::new();
247 }
248 _ => current.push(ch),
249 }
250 }
251
252 if in_quotes {
253 return Err(HostLinkError::protocol(format!(
254 "Embedded device range table contains an unterminated quoted field: {line}"
255 )));
256 }
257
258 fields.push(current);
259 Ok(fields)
260}
261
262fn notation_from_base(base_text: &str) -> Result<KvDeviceRangeNotation, HostLinkError> {
263 let normalized = base_text.trim();
264 if normalized.starts_with("10") {
265 Ok(KvDeviceRangeNotation::Decimal)
266 } else if normalized.starts_with("16") {
267 Ok(KvDeviceRangeNotation::Hexadecimal)
268 } else {
269 Err(HostLinkError::protocol(format!(
270 "Unsupported base cell '{base_text}' in the embedded device range table"
271 )))
272 }
273}
274
275fn build_entry(row: &RangeRow, model_index: usize, resolved_model: &str) -> KvDeviceRangeEntry {
276 let range_text = row.ranges[model_index].trim();
277 let supported = !range_text.is_empty() && range_text != "-";
278 let address_range = supported.then(|| range_text.to_owned());
279 let segments = address_range
280 .as_deref()
281 .map(|text| parse_segments(row, text))
282 .unwrap_or_default();
283 let primary_device = primary_device_name(row, &segments);
284 let (category, is_bit_device) = device_metadata(&primary_device);
285 let notation = entry_notation(row.notation, &segments);
286 let (lower_bound, upper_bound, point_count) = summarize_entry_bounds(&segments);
287 let notes = entry_notes(&segments);
288
289 KvDeviceRangeEntry {
290 device: primary_device,
291 device_type: row.device_type.clone(),
292 category,
293 is_bit_device,
294 notation,
295 supported,
296 lower_bound,
297 upper_bound,
298 point_count,
299 address_range,
300 source: format!("Embedded device range table ({resolved_model})"),
301 notes,
302 segments,
303 }
304}
305
306fn parse_segments(row: &RangeRow, range_text: &str) -> Vec<KvDeviceRangeSegment> {
307 range_text
308 .split(',')
309 .map(str::trim)
310 .filter(|segment| !segment.is_empty())
311 .map(|segment| {
312 let device = segment_device(segment);
313 let device = if device.is_empty() {
314 row.device_type.clone()
315 } else {
316 device
317 };
318 let (category, is_bit_device) = device_metadata(&device);
319 let notation = notation_for_device(row.notation, &device);
320 let (lower_bound, upper_bound, point_count) =
321 parse_segment_bounds(segment, notation, &device);
322 KvDeviceRangeSegment {
323 device,
324 category,
325 is_bit_device,
326 notation,
327 lower_bound,
328 upper_bound,
329 point_count,
330 address_range: segment.to_owned(),
331 }
332 })
333 .collect()
334}
335
336fn segment_device(segment: &str) -> String {
337 segment
338 .chars()
339 .take_while(|ch| ch.is_ascii_alphabetic())
340 .collect::<String>()
341}
342
343fn primary_device_name(row: &RangeRow, segments: &[KvDeviceRangeSegment]) -> String {
344 let unique_devices = segments.iter().map(|segment| segment.device.as_str()).fold(
345 Vec::<&str>::new(),
346 |mut devices, device| {
347 if !devices
348 .iter()
349 .any(|existing| existing.eq_ignore_ascii_case(device))
350 {
351 devices.push(device);
352 }
353 devices
354 },
355 );
356 if unique_devices.len() == 1 {
357 unique_devices[0].to_owned()
358 } else {
359 row.device_type.clone()
360 }
361}
362
363fn summarize_entry_bounds(segments: &[KvDeviceRangeSegment]) -> (u32, Option<u32>, Option<u32>) {
364 let Some(first) = segments.first() else {
365 return (0, None, None);
366 };
367 let all_same = segments.iter().skip(1).all(|segment| {
368 segment.lower_bound == first.lower_bound
369 && segment.upper_bound == first.upper_bound
370 && segment.point_count == first.point_count
371 });
372 if all_same {
373 (first.lower_bound, first.upper_bound, first.point_count)
374 } else {
375 (first.lower_bound, None, None)
376 }
377}
378
379fn entry_notation(
380 fallback: KvDeviceRangeNotation,
381 segments: &[KvDeviceRangeSegment],
382) -> KvDeviceRangeNotation {
383 let Some(first) = segments.first() else {
384 return fallback;
385 };
386 if segments
387 .iter()
388 .skip(1)
389 .all(|segment| segment.notation == first.notation)
390 {
391 first.notation
392 } else {
393 fallback
394 }
395}
396
397fn entry_notes(segments: &[KvDeviceRangeSegment]) -> Option<String> {
398 (segments.len() > 1).then(|| {
399 "Published address range expands to multiple alias devices; inspect segments.".to_owned()
400 })
401}
402
403fn parse_segment_bounds(
404 segment: &str,
405 notation: KvDeviceRangeNotation,
406 default_device: &str,
407) -> (u32, Option<u32>, Option<u32>) {
408 let Some((start_text, end_text)) = segment.split_once('-') else {
409 return (0, None, None);
410 };
411 let start = parse_segment_number(start_text, notation, default_device);
412 let end = parse_segment_number(end_text, notation, default_device);
413 let point_count = start
414 .zip(end)
415 .and_then(|(lower, upper)| upper.checked_sub(lower))
416 .and_then(|distance| distance.checked_add(1));
417 (start.unwrap_or(0), end, point_count)
418}
419
420fn parse_segment_number(
421 text: &str,
422 notation: KvDeviceRangeNotation,
423 default_device: &str,
424) -> Option<u32> {
425 let normalized = text.trim();
426 let trimmed = normalized
427 .strip_prefix(default_device)
428 .unwrap_or(normalized)
429 .trim_start_matches(|ch: char| ch.is_ascii_alphabetic());
430 if trimmed.is_empty() {
431 return None;
432 }
433 if matches!(default_device, "X" | "Y") {
434 return parse_xym_segment_number(trimmed);
435 }
436 match notation {
437 KvDeviceRangeNotation::Decimal => trimmed.parse().ok(),
438 KvDeviceRangeNotation::Hexadecimal => u32::from_str_radix(trimmed, 16).ok(),
439 }
440}
441
442fn parse_xym_segment_number(text: &str) -> Option<u32> {
443 let (bank_text, bit_text) = text.split_at(text.len().saturating_sub(1));
444 if !bank_text.bytes().all(|byte| byte.is_ascii_digit()) {
445 return None;
446 }
447 let bank = if bank_text.is_empty() {
448 0
449 } else {
450 bank_text.parse::<u32>().ok()?
451 };
452 let bit = u32::from_str_radix(bit_text, 16).ok()?;
453 bank.checked_mul(16)?.checked_add(bit)
454}
455
456fn device_metadata(device_type: &str) -> (KvDeviceRangeCategory, bool) {
457 if matches!(device_type, "Z") {
458 return (KvDeviceRangeCategory::Index, false);
459 }
460 if matches!(device_type, "ZF") {
461 return (KvDeviceRangeCategory::FileRefresh, false);
462 }
463 if matches!(device_type, "T" | "C" | "AT" | "CTH" | "CTC") {
464 return (KvDeviceRangeCategory::TimerCounter, false);
465 }
466 if is_direct_bit_device_type(device_type) {
467 return (KvDeviceRangeCategory::Bit, true);
468 }
469 match default_format_by_device_type(device_type) {
470 "" => (KvDeviceRangeCategory::Bit, true),
471 _ => (KvDeviceRangeCategory::Word, false),
472 }
473}
474
475fn notation_for_device(
476 fallback: KvDeviceRangeNotation,
477 device_type: &str,
478) -> KvDeviceRangeNotation {
479 if matches!(device_type, "B" | "W" | "VB" | "X" | "Y") {
480 KvDeviceRangeNotation::Hexadecimal
481 } else {
482 fallback
483 }
484}
485
486fn resolve_model_column<'a>(
487 table: &'a RangeTable,
488 requested_model: &str,
489) -> Result<&'a str, HostLinkError> {
490 let normalized = normalize_model_key(requested_model);
491 if let Some(header) = direct_model_match(table, &normalized) {
492 return Ok(header);
493 }
494
495 let wants_xym = normalized.ends_with("(XYM)");
496 let base_model = normalized.strip_suffix("(XYM)").unwrap_or(&normalized);
497 let resolved_family = match base_model {
498 value if value.starts_with("KV-NANO") || value.starts_with("KV-N") => "KV-NANO",
499 value
500 if value.starts_with("KV-3000")
501 || value.starts_with("KV-5000")
502 || value.starts_with("KV-5500") =>
503 {
504 "KV-3000/5000"
505 }
506 value
507 if value.starts_with("KV-7000")
508 || value.starts_with("KV-7300")
509 || value.starts_with("KV-7500") =>
510 {
511 "KV-7000"
512 }
513 value if value.starts_with("KV-8000") => "KV-8000",
514 value if value.starts_with("KV-X5") || value.starts_with("KV-X3") => "KV-X500",
515 _ => {
516 let supported = table.model_headers.join(", ");
517 return Err(HostLinkError::protocol(format!(
518 "Unsupported model '{requested_model}'. Supported range models: {supported}."
519 )));
520 }
521 };
522
523 let resolved_key = if wants_xym {
524 format!("{resolved_family}(XYM)")
525 } else {
526 resolved_family.to_owned()
527 };
528
529 direct_model_match(table, &resolved_key).ok_or_else(|| {
530 HostLinkError::protocol(format!(
531 "Resolved model '{resolved_key}' was not found in the embedded device range table."
532 ))
533 })
534}
535
536fn direct_model_match<'a>(table: &'a RangeTable, normalized: &str) -> Option<&'a str> {
537 table
538 .model_headers
539 .iter()
540 .find(|header| normalize_model_key(header) == normalized)
541 .map(String::as_str)
542}
543
544fn normalize_model_key(text: &str) -> String {
545 text.trim()
546 .trim_end_matches('\0')
547 .chars()
548 .filter(|ch| !ch.is_whitespace())
549 .collect::<String>()
550 .to_ascii_uppercase()
551}
552
553#[cfg(test)]
554mod tests {
555 use super::{
556 KvDeviceRangeCategory, KvDeviceRangeNotation, available_device_range_models,
557 device_range_catalog_for_model, normalize_model_key,
558 };
559
560 #[test]
561 fn available_models_include_xym_columns_from_csv() {
562 let models = available_device_range_models();
563 assert!(models.iter().any(|model| model == "KV-7000"));
564 assert!(models.iter().any(|model| model == "KV-7000(XYM)"));
565 }
566
567 #[test]
568 fn resolves_known_runtime_model_names_to_csv_family_columns() {
569 let catalog = device_range_catalog_for_model("KV-8000A").unwrap();
570 assert_eq!(catalog.model, "KV-8000");
571 assert_eq!(catalog.model_code, "");
572 assert!(!catalog.has_model_code);
573 assert_eq!(catalog.resolved_model, "KV-8000");
574 assert_eq!(
575 catalog.entry("DM").unwrap().address_range.as_deref(),
576 Some("DM00000-DM65534")
577 );
578
579 let x_catalog = device_range_catalog_for_model("KV-X530").unwrap();
580 assert_eq!(x_catalog.resolved_model, "KV-X500");
581 assert_eq!(
582 x_catalog.entry("ZF").unwrap().address_range.as_deref(),
583 Some("ZF000000-ZF524287")
584 );
585
586 let tm = catalog.entry("TM").unwrap();
587 assert_eq!(tm.category, KvDeviceRangeCategory::Word);
588 assert!(!tm.is_bit_device);
589 assert_eq!(tm.address_range.as_deref(), Some("TM000-TM511"));
590 }
591
592 #[test]
593 fn xym_catalog_splits_multi_device_ranges_into_segments() {
594 let catalog = device_range_catalog_for_model("KV-3000/5000(XYM)").unwrap();
595 let entry = catalog.entry("R").unwrap();
596
597 assert_eq!(entry.device, "R");
598 assert_eq!(entry.category, KvDeviceRangeCategory::Bit);
599 assert!(entry.is_bit_device);
600 assert_eq!(entry.notation, KvDeviceRangeNotation::Hexadecimal);
601 assert_eq!(entry.lower_bound, 0);
602 assert_eq!(entry.upper_bound, Some(999 * 16 + 15));
603 assert_eq!(entry.point_count, Some(1_000 * 16));
604 assert_eq!(entry.address_range.as_deref(), Some("X0-999F,Y0-999F"));
605 assert!(
606 entry
607 .notes
608 .as_deref()
609 .unwrap()
610 .contains("multiple alias devices")
611 );
612 assert_eq!(entry.segments.len(), 2);
613 assert_eq!(entry.segments[0].device, "X");
614 assert_eq!(
615 entry.segments[0].notation,
616 KvDeviceRangeNotation::Hexadecimal
617 );
618 assert_eq!(entry.segments[0].lower_bound, 0);
619 assert_eq!(entry.segments[0].upper_bound, Some(999 * 16 + 15));
620 assert_eq!(entry.segments[0].point_count, Some(1_000 * 16));
621 assert_eq!(entry.segments[0].address_range, "X0-999F");
622 assert_eq!(entry.segments[1].device, "Y");
623 assert_eq!(
624 entry.segments[1].notation,
625 KvDeviceRangeNotation::Hexadecimal
626 );
627 assert_eq!(entry.segments[1].lower_bound, 0);
628 assert_eq!(entry.segments[1].upper_bound, Some(999 * 16 + 15));
629 assert_eq!(entry.segments[1].point_count, Some(1_000 * 16));
630 assert_eq!(entry.segments[1].address_range, "Y0-999F");
631 assert_eq!(catalog.entry("X").unwrap().device_type, "R");
632
633 let kv8000 = device_range_catalog_for_model("KV-8000(XYM)").unwrap();
634 let r = kv8000.entry("R").unwrap();
635 assert_eq!(r.upper_bound, Some(1_999 * 16 + 15));
636 assert_eq!(r.point_count, Some(2_000 * 16));
637 assert_eq!(r.segments[0].upper_bound, Some(1_999 * 16 + 15));
638 assert_eq!(r.segments[1].upper_bound, Some(1_999 * 16 + 15));
639
640 let dm = catalog.entry("DM").unwrap();
641 assert_eq!(dm.device, "D");
642 assert_eq!(dm.category, KvDeviceRangeCategory::Word);
643 assert!(!dm.is_bit_device);
644 assert_eq!(dm.lower_bound, 0);
645 assert_eq!(dm.upper_bound, Some(65534));
646 assert_eq!(dm.point_count, Some(65535));
647 assert_eq!(dm.notation, KvDeviceRangeNotation::Decimal);
648 assert_eq!(dm.segments[0].device, "D");
649 assert_eq!(dm.segments[0].address_range, "D0-65534");
650 assert_eq!(catalog.entry("D").unwrap().device_type, "DM");
651
652 let fm = catalog.entry("FM").unwrap();
653 assert_eq!(fm.device, "F");
654 assert_eq!(fm.address_range.as_deref(), Some("F0-32767"));
655 assert_eq!(fm.segments[0].device, "F");
656 assert_eq!(fm.segments[0].address_range, "F0-32767");
657 }
658
659 #[test]
660 fn corrected_catalog_typos_are_published_consistently() {
661 let nano = device_range_catalog_for_model("KV-N24nn").unwrap();
662 assert_eq!(
663 nano.entry("CM").unwrap().address_range.as_deref(),
664 Some("CM0000-CM8999")
665 );
666
667 let xym = device_range_catalog_for_model("KV-3000/5000(XYM)").unwrap();
668 assert_eq!(
669 xym.entry("CR").unwrap().address_range.as_deref(),
670 Some("CR0000-CR3915")
671 );
672 }
673
674 #[test]
675 fn single_device_ranges_keep_their_device_prefixes() {
676 let nano = device_range_catalog_for_model("KV-N24nn").unwrap();
677 assert_eq!(
678 nano.entry("VM").unwrap().address_range.as_deref(),
679 Some("VM0-9499")
680 );
681 assert_eq!(
682 nano.entry("VB").unwrap().address_range.as_deref(),
683 Some("VB0-1FFF")
684 );
685 assert_eq!(
686 nano.entry("CTC").unwrap().address_range.as_deref(),
687 Some("CTC0-7")
688 );
689
690 let kv3000 = device_range_catalog_for_model("KV-3000/5000").unwrap();
691 assert_eq!(
692 kv3000.entry("AT").unwrap().address_range.as_deref(),
693 Some("AT0-7")
694 );
695 assert_eq!(
696 kv3000.entry("CTH").unwrap().address_range.as_deref(),
697 Some("CTH0-1")
698 );
699 }
700
701 #[test]
702 fn unsupported_entries_remain_present_but_marked_unsupported() {
703 let catalog = device_range_catalog_for_model("KV-N24nn").unwrap();
704 let em = catalog.entry("EM").unwrap();
705
706 assert!(!em.supported);
707 assert!(em.address_range.is_none());
708 assert!(em.segments.is_empty());
709 }
710
711 #[test]
712 fn normalize_model_key_removes_whitespace_and_uppercases() {
713 assert_eq!(normalize_model_key(" kv-x500 (xym) "), "KV-X500(XYM)");
714 }
715}