1use std::fs::File;
2use std::io::{BufRead, BufReader, BufWriter, Seek, SeekFrom, Write};
3use std::path::{Path, PathBuf};
4use std::str::{FromStr, SplitWhitespace};
5
6use super::{read_all, read_capped, EventSource, IoError, LoadOptions, RawEvent, SliceSource};
7use crate::{EventStream, EventStreamBuilder};
8
9pub fn write_text_stream(path: impl AsRef<Path>, stream: &EventStream) -> Result<(), IoError> {
14 let mut writer = BufWriter::new(File::create(path).map_err(IoError::Io)?);
15 let (xs, ys, ts, ps) = (stream.xs(), stream.ys(), stream.ts(), stream.ps());
16 for index in 0..stream.len() {
17 writeln!(
18 writer,
19 "{} {} {} {}",
20 ts[index],
21 xs[index],
22 ys[index],
23 u8::from(ps[index])
24 )
25 .map_err(IoError::Io)?;
26 }
27 writer.flush().map_err(IoError::Io)
28}
29
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub enum TimeUnit {
35 Seconds,
36 Milliseconds,
37 Microseconds,
38 Nanoseconds,
39}
40
41impl TimeUnit {
42 fn to_microseconds(self, value: f64) -> i64 {
43 let microseconds = match self {
44 Self::Seconds => value * 1e6,
45 Self::Milliseconds => value * 1e3,
46 Self::Microseconds => value,
47 Self::Nanoseconds => value / 1e3,
48 };
49 microseconds.round() as i64
50 }
51
52 #[cfg_attr(not(feature = "hdf5"), allow(dead_code))]
56 pub(crate) fn from_scale_ms(scale_ms: f64) -> Option<TimeUnit> {
57 for (unit, expected) in [
58 (TimeUnit::Nanoseconds, 1e-6),
59 (TimeUnit::Microseconds, 1e-3),
60 (TimeUnit::Milliseconds, 1.0),
61 (TimeUnit::Seconds, 1e3),
62 ] {
63 if (scale_ms - expected).abs() <= expected * 1e-9 {
64 return Some(unit);
65 }
66 }
67 None
68 }
69
70 pub(crate) fn infer_from_span(span: i64) -> TimeUnit {
76 let span = span.max(0) as f64;
77 if span * 1e-9 >= 1.0 {
78 TimeUnit::Nanoseconds
79 } else if span * 1e-6 >= 1.0 {
80 TimeUnit::Microseconds
81 } else if span * 1e-3 >= 1.0 {
82 TimeUnit::Milliseconds
83 } else {
84 TimeUnit::Seconds
85 }
86 }
87
88 #[cfg(feature = "hdf5")]
91 pub(crate) fn microseconds_from_int(self, value: i64) -> i64 {
92 let value = i128::from(value);
93 let microseconds = match self {
94 Self::Seconds => value * 1_000_000,
95 Self::Milliseconds => value * 1_000,
96 Self::Microseconds => value,
97 Self::Nanoseconds => value / 1_000,
98 };
99 microseconds.clamp(i64::MIN as i128, i64::MAX as i128) as i64
100 }
101}
102
103#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
105pub enum ColumnOrder {
106 #[default]
108 Txyp,
109 Xytp,
111}
112
113#[derive(Clone, Copy, Debug)]
114pub struct TextOptions {
115 pub width: usize,
116 pub height: usize,
117 pub time_unit: TimeUnit,
118 pub order: ColumnOrder,
119}
120
121impl TextOptions {
122 pub fn new(width: usize, height: usize) -> Self {
124 Self {
125 width,
126 height,
127 time_unit: TimeUnit::Seconds,
128 order: ColumnOrder::Txyp,
129 }
130 }
131}
132
133#[derive(Debug)]
137pub struct TextReader<R> {
138 reader: R,
139 options: TextOptions,
140 buffer: String,
141 line: usize,
142}
143
144impl<R: BufRead> TextReader<R> {
145 pub fn new(reader: R, options: TextOptions) -> Result<Self, IoError> {
146 if options.width == 0 || options.height == 0 {
147 return Err(IoError::InvalidSensorSize);
148 }
149 Ok(Self {
150 reader,
151 options,
152 buffer: String::new(),
153 line: 0,
154 })
155 }
156
157 fn parse_line(&self, line: &str) -> Result<RawEvent, IoError> {
158 let mut fields = line.split_whitespace();
159 let (t, x, y, p) = match self.options.order {
160 ColumnOrder::Txyp => {
161 let t = self.field(&mut fields, "t")?;
162 (
163 t,
164 self.field(&mut fields, "x")?,
165 self.field(&mut fields, "y")?,
166 self.field(&mut fields, "p")?,
167 )
168 }
169 ColumnOrder::Xytp => {
170 let x = self.field(&mut fields, "x")?;
171 let y = self.field(&mut fields, "y")?;
172 (
173 self.field(&mut fields, "t")?,
174 x,
175 y,
176 self.field(&mut fields, "p")?,
177 )
178 }
179 };
180 Ok(RawEvent {
181 x: self.parse(x, "x")?,
182 y: self.parse(y, "y")?,
183 t: self
184 .options
185 .time_unit
186 .to_microseconds(self.parse::<f64>(t, "t")?),
187 p: self.parse::<i32>(p, "p")? > 0,
188 })
189 }
190
191 fn field<'a>(&self, fields: &mut SplitWhitespace<'a>, name: &str) -> Result<&'a str, IoError> {
192 fields.next().ok_or_else(|| IoError::Parse {
193 line: self.line,
194 message: format!("missing {name}"),
195 })
196 }
197
198 fn parse<T: FromStr>(&self, value: &str, field: &str) -> Result<T, IoError> {
199 value.parse().map_err(|_| IoError::Parse {
200 line: self.line,
201 message: format!("invalid {field}: {value:?}"),
202 })
203 }
204}
205
206impl<R: BufRead> EventSource for TextReader<R> {
207 fn sensor_size(&self) -> (usize, usize) {
208 (self.options.width, self.options.height)
209 }
210
211 fn timestamp_scale_ms(&self) -> f64 {
212 0.001
213 }
214
215 fn next_event(&mut self) -> Result<Option<RawEvent>, IoError> {
216 loop {
217 self.buffer.clear();
218 self.line += 1;
219 if self.reader.read_line(&mut self.buffer)? == 0 {
220 return Ok(None);
221 }
222 let trimmed = self.buffer.trim();
223 if trimmed.is_empty() || trimmed.starts_with('#') {
224 continue;
225 }
226 return self.parse_line(trimmed).map(Some);
227 }
228 }
229}
230
231pub fn open(
233 path: impl AsRef<Path>,
234 options: TextOptions,
235) -> Result<TextReader<BufReader<File>>, IoError> {
236 TextReader::new(BufReader::new(File::open(path)?), options)
237}
238
239pub fn read_text(path: impl AsRef<Path>, options: TextOptions) -> Result<EventStream, IoError> {
241 read_all(open(path, options)?)
242}
243
244struct RawRow {
247 x: u16,
248 y: u16,
249 t: f64,
250 p: bool,
251}
252
253pub fn load_text(path: impl AsRef<Path>, options: &LoadOptions) -> Result<EventStream, IoError> {
258 if let (Some((width, height)), Some(time_unit)) = (options.sensor_size, options.time_unit) {
259 let text_options = TextOptions {
260 width,
261 height,
262 time_unit,
263 order: options.order,
264 };
265 return read_capped(open(path.as_ref(), text_options)?, options.max_events);
266 }
267
268 let rows = read_raw_rows(path.as_ref(), options.order)?;
269 let (width, height) = options
270 .sensor_size
271 .unwrap_or_else(|| infer_sensor_size(&rows));
272 let time_unit = options.time_unit.unwrap_or_else(|| infer_time_unit(&rows));
273 if width == 0 || height == 0 {
274 return Err(IoError::InvalidSensorSize);
275 }
276
277 let mut builder = EventStreamBuilder::new(width, height, 0.001);
278 for row in &rows {
279 builder.push(row.x, row.y, time_unit.to_microseconds(row.t), row.p);
280 if options.max_events.is_some_and(|max| builder.len() >= max) {
281 break;
282 }
283 }
284 Ok(builder.build())
285}
286
287fn infer_sensor_size(rows: &[RawRow]) -> (usize, usize) {
290 let width = rows.iter().map(|row| usize::from(row.x)).max();
291 let height = rows.iter().map(|row| usize::from(row.y)).max();
292 match (width, height) {
293 (Some(width), Some(height)) => (width + 1, height + 1),
294 _ => (1, 1),
295 }
296}
297
298fn infer_time_unit(rows: &[RawRow]) -> TimeUnit {
300 if rows.iter().any(|row| row.t.fract() != 0.0) {
301 return TimeUnit::Seconds;
302 }
303 let min = rows.iter().map(|row| row.t).fold(f64::INFINITY, f64::min);
304 let max = rows
305 .iter()
306 .map(|row| row.t)
307 .fold(f64::NEG_INFINITY, f64::max);
308 if min.is_finite() {
309 TimeUnit::infer_from_span((max - min) as i64)
310 } else {
311 TimeUnit::Seconds
312 }
313}
314
315fn parse_raw_row(trimmed: &str, order: ColumnOrder, number: usize) -> Result<RawRow, IoError> {
318 let mut fields = trimmed.split_whitespace();
319 let mut next = |name: &str| {
320 fields.next().ok_or(IoError::Parse {
321 line: number,
322 message: format!("missing {name}"),
323 })
324 };
325 let (t, x, y, p) = match order {
326 ColumnOrder::Txyp => {
327 let t = next("t")?;
328 (t, next("x")?, next("y")?, next("p")?)
329 }
330 ColumnOrder::Xytp => {
331 let x = next("x")?;
332 let y = next("y")?;
333 (next("t")?, x, y, next("p")?)
334 }
335 };
336 let parse = |value: &str, field: &str| {
337 value.parse::<f64>().map_err(|_| IoError::Parse {
338 line: number,
339 message: format!("invalid {field}: {value:?}"),
340 })
341 };
342 Ok(RawRow {
343 x: parse(x, "x")? as u16,
344 y: parse(y, "y")? as u16,
345 t: parse(t, "t")?,
346 p: parse(p, "p")? > 0.0,
347 })
348}
349
350fn read_raw_rows(path: &Path, order: ColumnOrder) -> Result<Vec<RawRow>, IoError> {
351 let reader = BufReader::new(File::open(path)?);
352 let mut rows = Vec::new();
353 for (index, line) in reader.lines().enumerate() {
354 let line = line?;
355 let trimmed = line.trim();
356 if trimmed.is_empty() || trimmed.starts_with('#') {
357 continue;
358 }
359 rows.push(parse_raw_row(trimmed, order, index + 1)?);
360 }
361 Ok(rows)
362}
363
364const TEXT_INDEX_STRIDE: usize = 4096;
367
368#[derive(Clone, Copy)]
369struct TextIndexEntry {
370 offset: u64,
371 count: usize,
372 t_us: i64,
373}
374
375pub struct TextSliceSource {
380 path: PathBuf,
381 order: ColumnOrder,
382 time_unit: TimeUnit,
383 sensor: (usize, usize),
384 total: usize,
385 span_us: (i64, i64),
386 index: Vec<TextIndexEntry>,
387}
388
389pub fn open_text_slice(
393 path: impl AsRef<Path>,
394 options: &LoadOptions,
395) -> Result<TextSliceSource, IoError> {
396 let path = path.as_ref();
397 let mut reader = BufReader::new(File::open(path)?);
398 let mut buffer = String::new();
399 let mut offset = 0u64;
400 let mut line_no = 0usize;
401 let mut kept = 0usize;
402 let mut samples: Vec<(u64, usize, f64)> = Vec::new();
403 let (mut max_x, mut max_y) = (0u16, 0u16);
404 let (mut min_t, mut max_t) = (f64::INFINITY, f64::NEG_INFINITY);
405 let mut fractional = false;
406 let mut sorted = true;
407 let mut previous_t = f64::NEG_INFINITY;
408
409 loop {
410 buffer.clear();
411 let line_start = offset;
412 let bytes = reader.read_line(&mut buffer)?;
413 if bytes == 0 {
414 break;
415 }
416 offset += bytes as u64;
417 line_no += 1;
418 let trimmed = buffer.trim();
419 if trimmed.is_empty() || trimmed.starts_with('#') {
420 continue;
421 }
422 let row = parse_raw_row(trimmed, options.order, line_no)?;
423 if let Some((width, height)) = options.sensor_size {
424 if usize::from(row.x) >= width || usize::from(row.y) >= height {
425 continue; }
427 }
428 if kept.is_multiple_of(TEXT_INDEX_STRIDE) {
429 samples.push((line_start, kept, row.t));
430 }
431 max_x = max_x.max(row.x);
432 max_y = max_y.max(row.y);
433 min_t = min_t.min(row.t);
434 max_t = max_t.max(row.t);
435 fractional |= row.t.fract() != 0.0;
436 sorted &= row.t >= previous_t;
437 previous_t = row.t;
438 kept += 1;
439 }
440
441 let sensor = options
442 .sensor_size
443 .unwrap_or((usize::from(max_x) + 1, usize::from(max_y) + 1));
444 if sensor.0 == 0 || sensor.1 == 0 {
445 return Err(IoError::InvalidSensorSize);
446 }
447 if !sorted {
448 return Err(IoError::Format(
449 "text timestamps are not sorted; in-place slicing requires time-ordered events"
450 .to_owned(),
451 ));
452 }
453 let time_unit = options.time_unit.unwrap_or_else(|| {
454 if fractional || !min_t.is_finite() {
455 TimeUnit::Seconds
456 } else {
457 TimeUnit::infer_from_span((max_t - min_t) as i64)
458 }
459 });
460
461 let index = samples
462 .into_iter()
463 .map(|(offset, count, t)| TextIndexEntry {
464 offset,
465 count,
466 t_us: time_unit.to_microseconds(t),
467 })
468 .collect();
469 let span_us = if kept == 0 {
470 (0, 0)
471 } else {
472 (
473 time_unit.to_microseconds(min_t),
474 time_unit.to_microseconds(max_t),
475 )
476 };
477 Ok(TextSliceSource {
478 path: path.to_path_buf(),
479 order: options.order,
480 time_unit,
481 sensor,
482 total: kept,
483 span_us,
484 index,
485 })
486}
487
488impl TextSliceSource {
489 fn reader_at(&self, offset: u64) -> Result<TextReader<BufReader<File>>, IoError> {
491 let mut file = File::open(&self.path)?;
492 file.seek(SeekFrom::Start(offset))?;
493 TextReader::new(
494 BufReader::new(file),
495 TextOptions {
496 width: self.sensor.0,
497 height: self.sensor.1,
498 time_unit: self.time_unit,
499 order: self.order,
500 },
501 )
502 }
503
504 fn keeps(&self, x: u16, y: u16) -> bool {
505 usize::from(x) < self.sensor.0 && usize::from(y) < self.sensor.1
506 }
507}
508
509impl SliceSource for TextSliceSource {
510 fn sensor_size(&self) -> (usize, usize) {
511 self.sensor
512 }
513
514 fn timestamp_scale_ms(&self) -> f64 {
515 0.001
516 }
517
518 fn n_events(&self) -> usize {
519 self.total
520 }
521
522 fn time_span(&self) -> (i64, i64) {
523 self.span_us
524 }
525
526 fn slice_index(&self, i0: usize, i1: usize) -> Result<EventStream, IoError> {
527 let i0 = i0.min(self.total);
528 let i1 = i1.clamp(i0, self.total);
529 let mut builder = EventStreamBuilder::new(self.sensor.0, self.sensor.1, 0.001);
530 if i0 == i1 || self.index.is_empty() {
531 return Ok(builder.build());
532 }
533 let entry = self.index[self.index.partition_point(|e| e.count <= i0) - 1];
535 let mut reader = self.reader_at(entry.offset)?;
536 let mut index = entry.count;
537 while index < i1 {
538 let Some(event) = reader.next_event()? else {
539 break;
540 };
541 if !self.keeps(event.x, event.y) {
542 continue; }
544 if index >= i0 {
545 builder.push(event.x, event.y, event.t, event.p);
546 }
547 index += 1;
548 }
549 Ok(builder.build())
550 }
551
552 fn slice_time(&self, t0: i64, t1: i64) -> Result<EventStream, IoError> {
553 let mut builder = EventStreamBuilder::new(self.sensor.0, self.sensor.1, 0.001);
554 if self.index.is_empty() {
555 return Ok(builder.build());
556 }
557 let entry = self.index[self
561 .index
562 .partition_point(|e| e.t_us < t0)
563 .saturating_sub(1)];
564 let mut reader = self.reader_at(entry.offset)?;
565 while let Some(event) = reader.next_event()? {
566 if event.t >= t1 {
567 break; }
569 if event.t >= t0 {
570 builder.push(event.x, event.y, event.t, event.p);
571 }
572 }
573 Ok(builder.build())
574 }
575}
576
577#[cfg(test)]
578mod tests {
579 use std::io::Cursor;
580
581 use super::{load_text, open_text_slice, ColumnOrder, TextOptions, TextReader, TimeUnit};
582 use crate::io::{read_all, IoError, LoadOptions, SliceSource};
583 use crate::EventStream;
584
585 fn read(data: &str, options: TextOptions) -> Result<EventStream, IoError> {
586 read_all(TextReader::new(Cursor::new(data), options).unwrap())
587 }
588
589 #[test]
590 fn parses_txyp_seconds_skips_noise_and_drops_out_of_bounds() {
591 let data = "0.0 1 2 1\n0.000002 3 0 0\n\n# comment\n0.00001 0 4 1\n0.00002 4 0 1\n";
592 let stream = read(data, TextOptions::new(4, 5)).unwrap();
593
594 assert_eq!(stream.len(), 3); assert_eq!(stream.xs(), &[1, 3, 0]);
596 assert_eq!(stream.ys(), &[2, 0, 4]);
597 assert_eq!(stream.ts(), &[0, 2, 10]);
598 assert_eq!(stream.ps(), &[true, false, true]);
599 assert_eq!(stream.sensor_size(), (4, 5));
600 assert_eq!(stream.timestamp_scale_ms(), 0.001);
601 }
602
603 #[test]
604 fn supports_xytp_order_and_negative_polarity() {
605 let options = TextOptions {
606 order: ColumnOrder::Xytp,
607 ..TextOptions::new(8, 8)
608 };
609 let stream = read("1 2 0.5 -1\n", options).unwrap();
610
611 assert_eq!(stream.xs(), &[1]);
612 assert_eq!(stream.ys(), &[2]);
613 assert_eq!(stream.ts(), &[500_000]); assert_eq!(stream.ps(), &[false]); }
616
617 #[test]
618 fn converts_time_units_to_microseconds() {
619 for (unit, raw, expected) in [
620 (TimeUnit::Microseconds, "7", 7_i64),
621 (TimeUnit::Milliseconds, "2", 2_000),
622 (TimeUnit::Nanoseconds, "2400", 2),
623 ] {
624 let data = format!("{raw} 0 0 1\n");
625 let options = TextOptions {
626 time_unit: unit,
627 ..TextOptions::new(4, 4)
628 };
629 assert_eq!(
630 read(&data, options).unwrap().ts(),
631 &[expected],
632 "unit {unit:?}"
633 );
634 }
635 }
636
637 #[test]
638 fn reports_parse_errors_with_line_numbers() {
639 let error = read("0.0 1 2 1\n0.0 nope 2 1\n", TextOptions::new(4, 4)).unwrap_err();
640 match error {
641 IoError::Parse { line, .. } => assert_eq!(line, 2),
642 other => panic!("expected parse error, got {other:?}"),
643 }
644 }
645
646 #[test]
647 fn reports_missing_fields() {
648 let error = read("0.0 1 2\n", TextOptions::new(4, 4)).unwrap_err();
649 assert!(matches!(error, IoError::Parse { line: 1, .. }));
650 }
651
652 #[test]
653 fn rejects_zero_sensor_size() {
654 let error = TextReader::new(Cursor::new(""), TextOptions::new(0, 4)).unwrap_err();
655 assert!(matches!(error, IoError::InvalidSensorSize));
656 }
657
658 #[test]
659 fn empty_input_yields_empty_stream() {
660 let stream = read("\n\n# only comments\n", TextOptions::new(4, 4)).unwrap();
661 assert!(stream.is_empty());
662 assert_eq!(stream.sensor_size(), (4, 4));
663 }
664
665 fn write_temp(tag: &str, contents: &str) -> std::path::PathBuf {
666 let dir = std::env::temp_dir().join(format!("eventcv-{tag}-{}", std::process::id()));
667 std::fs::create_dir_all(&dir).unwrap();
668 let path = dir.join("events.txt");
669 std::fs::write(&path, contents).unwrap();
670 path
671 }
672
673 #[test]
674 fn load_text_infers_size_and_microseconds() {
675 let path = write_temp("txtus", "1000000 0 0 1\n3000000 3 1 0\n6000000 1 2 1\n");
677 let stream = load_text(&path, &LoadOptions::default()).unwrap();
678
679 assert_eq!(stream.sensor_size(), (4, 3));
680 assert_eq!(stream.len(), 3); assert_eq!(stream.ts(), &[1_000_000, 3_000_000, 6_000_000]);
682 std::fs::remove_dir_all(path.parent().unwrap()).ok();
683 }
684
685 #[test]
686 fn load_text_infers_seconds_from_a_fractional_value() {
687 let path = write_temp("txtsec", "0.0 0 0 1\n0.5 1 1 0\n");
688 let stream = load_text(&path, &LoadOptions::default()).unwrap();
689
690 assert_eq!(stream.ts(), &[0, 500_000]); std::fs::remove_dir_all(path.parent().unwrap()).ok();
692 }
693
694 #[test]
695 fn explicit_options_override_inference() {
696 let path = write_temp("txtexp", "7 0 0 1\n");
697 let options = LoadOptions {
698 sensor_size: Some((4, 4)),
699 time_unit: Some(TimeUnit::Microseconds),
700 ..LoadOptions::default()
701 };
702 let stream = load_text(&path, &options).unwrap();
703
704 assert_eq!(stream.sensor_size(), (4, 4));
705 assert_eq!(stream.ts(), &[7]); std::fs::remove_dir_all(path.parent().unwrap()).ok();
707 }
708
709 #[test]
710 fn text_slice_source_matches_load() {
711 let mut data = String::new();
713 for i in 0..10 {
714 data.push_str(&format!("{} {} {} {}\n", i * 1000, i, i % 5, i % 2));
715 }
716 let path = write_temp("txtslice", &data);
717 let options = LoadOptions {
719 time_unit: Some(TimeUnit::Microseconds),
720 ..LoadOptions::default()
721 };
722 let source = open_text_slice(&path, &options).unwrap();
723 let full = load_text(&path, &options).unwrap();
724
725 assert_eq!(source.n_events(), full.len());
726 assert_eq!(source.sensor_size(), full.sensor_size()); assert_eq!(source.time_span(), (0, 9000));
728
729 assert_eq!(
730 source.slice_time(2000, 6000).unwrap().ts(),
731 &[2000, 3000, 4000, 5000]
732 );
733 assert_eq!(
734 source.slice_index(3, 7).unwrap().ts(),
735 &[3000, 4000, 5000, 6000]
736 );
737
738 let whole = source.slice_index(0, source.n_events()).unwrap();
739 assert_eq!(whole.xs(), full.xs());
740 assert_eq!(whole.ts(), full.ts());
741 assert_eq!(whole.ps(), full.ps());
742 std::fs::remove_dir_all(path.parent().unwrap()).ok();
743 }
744
745 #[test]
746 fn text_slice_rejects_unsorted_timestamps() {
747 let path = write_temp("txtunsorted", "0 0 0 1\n5000 1 1 0\n2000 2 2 1\n");
748 match open_text_slice(&path, &LoadOptions::default()) {
749 Err(IoError::Format(message)) => assert!(message.contains("not sorted")),
750 Err(other) => panic!("expected a not-sorted error, got {other:?}"),
751 Ok(_) => panic!("expected unsorted text to be rejected"),
752 }
753 std::fs::remove_dir_all(path.parent().unwrap()).ok();
754 }
755
756 #[test]
757 fn text_slice_keeps_same_timestamp_events_spanning_the_index_stride() {
758 let dense = super::TEXT_INDEX_STRIDE * 2 + 100;
762 let mut data = String::from("0 0 0 1\n"); for _ in 0..dense {
764 data.push_str("1000 1 1 1\n");
765 }
766 data.push_str("2000 2 2 0\n"); let path = write_temp("txtdense", &data);
768 let options = LoadOptions {
769 time_unit: Some(TimeUnit::Microseconds),
770 ..LoadOptions::default()
771 };
772 let source = open_text_slice(&path, &options).unwrap();
773
774 assert_eq!(source.slice_time(1000, 2000).unwrap().len(), dense);
775 let tiled = source.slice_time(0, 1000).unwrap().len()
777 + source.slice_time(1000, 2000).unwrap().len()
778 + source.slice_time(2000, 3000).unwrap().len();
779 assert_eq!(tiled, source.n_events());
780 std::fs::remove_dir_all(path.parent().unwrap()).ok();
781 }
782
783 #[test]
784 fn text_slice_empty_file() {
785 let path = write_temp("txtsliceempty", "\n# only a comment\n");
786 let source = open_text_slice(&path, &LoadOptions::default()).unwrap();
787
788 assert_eq!(source.n_events(), 0);
789 assert_eq!(source.time_span(), (0, 0));
790 assert!(source.slice_time(0, 1000).unwrap().is_empty());
791 assert!(source.slice_index(0, 10).unwrap().is_empty());
792 std::fs::remove_dir_all(path.parent().unwrap()).ok();
793 }
794
795 #[test]
796 fn write_text_stream_round_trips_at_event_level() {
797 let mut builder = crate::EventStreamBuilder::new(16, 12, 0.001);
798 for &(x, y, t, p) in &[(0u16, 0u16, 5i64, true), (15, 11, 2_500_000, false)] {
799 builder.push(x, y, t, p);
800 }
801 let stream = builder.build();
802
803 let dir = std::env::temp_dir().join(format!("eventcv-txtrt-{}", std::process::id()));
804 std::fs::create_dir_all(&dir).unwrap();
805 let path = dir.join("events.txt");
806 super::write_text_stream(&path, &stream).unwrap();
807
808 let options = LoadOptions {
811 sensor_size: Some((16, 12)),
812 time_unit: Some(TimeUnit::Microseconds),
813 ..LoadOptions::default()
814 };
815 let loaded = load_text(&path, &options).unwrap();
816 assert_eq!(loaded.xs(), stream.xs());
817 assert_eq!(loaded.ys(), stream.ys());
818 assert_eq!(loaded.ts(), stream.ts());
819 assert_eq!(loaded.ps(), stream.ps());
820 assert_eq!(loaded.sensor_size(), (16, 12));
821 std::fs::remove_dir_all(&dir).ok();
822 }
823}