ducpy 3.1.0__tar.gz → 3.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. {ducpy-3.1.0 → ducpy-3.2.0}/Cargo.lock +9 -7
  2. {ducpy-3.1.0 → ducpy-3.2.0}/Cargo.toml +0 -1
  3. {ducpy-3.1.0 → ducpy-3.2.0}/PKG-INFO +2 -2
  4. {ducpy-3.1.0 → ducpy-3.2.0}/README.md +1 -1
  5. {ducpy-3.1.0 → ducpy-3.2.0}/packages/ducpy/crate/Cargo.toml +1 -1
  6. {ducpy-3.1.0 → ducpy-3.2.0}/packages/ducrs/README.md +1 -1
  7. {ducpy-3.1.0 → ducpy-3.2.0}/packages/ducrs/src/api/version_control.rs +4 -93
  8. ducpy-3.2.0/packages/ducrs/src/lib.rs +298 -0
  9. {ducpy-3.1.0 → ducpy-3.2.0}/packages/ducrs/src/parse.rs +214 -98
  10. ducpy-3.2.0/packages/ducrs/src/serde_utils.rs +90 -0
  11. {ducpy-3.1.0 → ducpy-3.2.0}/packages/ducrs/src/serialize.rs +17 -10
  12. {ducpy-3.1.0 → ducpy-3.2.0}/packages/ducrs/src/types.rs +42 -24
  13. ducpy-3.1.0/packages/ducrs/src/lib.rs +0 -94
  14. {ducpy-3.1.0 → ducpy-3.2.0}/LICENSE +0 -0
  15. {ducpy-3.1.0 → ducpy-3.2.0}/packages/ducpy/crate/src/lib.rs +0 -0
  16. {ducpy-3.1.0 → ducpy-3.2.0}/packages/ducrs/.gitignore +0 -0
  17. {ducpy-3.1.0 → ducpy-3.2.0}/packages/ducrs/Cargo.toml +0 -0
  18. {ducpy-3.1.0 → ducpy-3.2.0}/packages/ducrs/LICENSE +0 -0
  19. {ducpy-3.1.0 → ducpy-3.2.0}/packages/ducrs/build.rs +0 -0
  20. {ducpy-3.1.0 → ducpy-3.2.0}/packages/ducrs/package.json +0 -0
  21. {ducpy-3.1.0 → ducpy-3.2.0}/packages/ducrs/release.config.cjs +0 -0
  22. {ducpy-3.1.0 → ducpy-3.2.0}/packages/ducrs/src/api/document.rs +0 -0
  23. {ducpy-3.1.0 → ducpy-3.2.0}/packages/ducrs/src/api/meta.rs +0 -0
  24. {ducpy-3.1.0 → ducpy-3.2.0}/packages/ducrs/src/api/mod.rs +0 -0
  25. {ducpy-3.1.0 → ducpy-3.2.0}/packages/ducrs/src/db/bootstrap.rs +0 -0
  26. {ducpy-3.1.0 → ducpy-3.2.0}/packages/ducrs/src/db/mod.rs +0 -0
  27. {ducpy-3.1.0 → ducpy-3.2.0}/packages/ducrs/src/db/native.rs +0 -0
  28. {ducpy-3.1.0 → ducpy-3.2.0}/packages/ducrs/src/db/wasm.rs +0 -0
  29. {ducpy-3.1.0 → ducpy-3.2.0}/packages/ducrs/tests/.gitignore +0 -0
  30. {ducpy-3.1.0 → ducpy-3.2.0}/pyproject.toml +0 -0
  31. {ducpy-3.1.0 → ducpy-3.2.0}/src/ducpy_native/__init__.py +0 -0
@@ -411,7 +411,7 @@ dependencies = [
411
411
 
412
412
  [[package]]
413
413
  name = "ducpy-native"
414
- version = "3.1.0"
414
+ version = "3.2.0"
415
415
  dependencies = [
416
416
  "duc",
417
417
  "pyo3",
@@ -713,10 +713,11 @@ checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
713
713
 
714
714
  [[package]]
715
715
  name = "hipdf"
716
- version = "1.3.5"
716
+ version = "1.3.7"
717
717
  source = "registry+https://github.com/rust-lang/crates.io-index"
718
- checksum = "e3b986659f7ff291144addff668450799e4c2cd69686601cd48dfe23d7afc316"
718
+ checksum = "2dfe672796312ef1e60b2866d6dac03b67c32dc7276a71c6d34b573dd00502f1"
719
719
  dependencies = [
720
+ "image",
720
721
  "jpeg-decoder",
721
722
  "lopdf",
722
723
  "miniz_oxide",
@@ -764,6 +765,7 @@ dependencies = [
764
765
  "byteorder-lite",
765
766
  "color_quant",
766
767
  "gif",
768
+ "image-webp",
767
769
  "moxcms",
768
770
  "num-traits",
769
771
  "png 0.18.0",
@@ -2477,18 +2479,18 @@ dependencies = [
2477
2479
 
2478
2480
  [[package]]
2479
2481
  name = "zerocopy"
2480
- version = "0.8.41"
2482
+ version = "0.8.42"
2481
2483
  source = "registry+https://github.com/rust-lang/crates.io-index"
2482
- checksum = "96e13bc581734df6250836c59a5f44f3c57db9f9acb9dc8e3eaabdaf6170254d"
2484
+ checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3"
2483
2485
  dependencies = [
2484
2486
  "zerocopy-derive",
2485
2487
  ]
2486
2488
 
2487
2489
  [[package]]
2488
2490
  name = "zerocopy-derive"
2489
- version = "0.8.41"
2491
+ version = "0.8.42"
2490
2492
  source = "registry+https://github.com/rust-lang/crates.io-index"
2491
- checksum = "3545ea9e86d12ab9bba9fcd99b54c1556fd3199007def5a03c375623d05fac1c"
2493
+ checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f"
2492
2494
  dependencies = [
2493
2495
  "proc-macro2",
2494
2496
  "quote",
@@ -2,7 +2,6 @@
2
2
  resolver = "2"
3
3
  members = ["packages/ducrs", "packages/ducpy/crate"]
4
4
 
5
-
6
5
  [profile.release]
7
6
  lto = true
8
7
  opt-level = "s"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ducpy
3
- Version: 3.1.0
3
+ Version: 3.2.0
4
4
  Classifier: License :: OSI Approved :: MIT License
5
5
  Requires-Dist: nanoid>=2.0.0
6
6
  License-File: LICENSE
@@ -19,7 +19,7 @@ Project-URL: Source, https://github.com/ducflair/duc/tree/main/packages/ducpy
19
19
 
20
20
  <p align="center">
21
21
  <br/>
22
- <a href="https://duc.ducflair.com" target="_blank"><img width="256px" src="https://raw.githubusercontent.com/ducflair/assets/refs/heads/main/src/duc/duc-extended.png" /></a>
22
+ <a href="https://duc.ducflair.com" target="_blank"><img width="256px" src="https://cdn.jsdelivr.net/gh/ducflair/assets@main/src/duc/duc-extended.png" /></a>
23
23
  <p align="center">2D CAD File Format</p>
24
24
  <p align="center" style="align: center;">
25
25
  <a href="https://pypi.org/project/ducpy/"><img src="https://shields.io/badge/Pip-blue?logo=Pypi&logoColor=white&style=round-square" alt="Pip" /></a>
@@ -2,7 +2,7 @@
2
2
 
3
3
  <p align="center">
4
4
  <br/>
5
- <a href="https://duc.ducflair.com" target="_blank"><img width="256px" src="https://raw.githubusercontent.com/ducflair/assets/refs/heads/main/src/duc/duc-extended.png" /></a>
5
+ <a href="https://duc.ducflair.com" target="_blank"><img width="256px" src="https://cdn.jsdelivr.net/gh/ducflair/assets@main/src/duc/duc-extended.png" /></a>
6
6
  <p align="center">2D CAD File Format</p>
7
7
  <p align="center" style="align: center;">
8
8
  <a href="https://pypi.org/project/ducpy/"><img src="https://shields.io/badge/Pip-blue?logo=Pypi&logoColor=white&style=round-square" alt="Pip" /></a>
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "ducpy-native"
3
- version = "3.1.0"
3
+ version = "3.2.0"
4
4
  edition = "2021"
5
5
  description = "Python native extension for the duc file format (used by ducpy)"
6
6
  publish = false
@@ -2,7 +2,7 @@
2
2
 
3
3
  <p align="center">
4
4
  <br/>
5
- <a href="https://duc.ducflair.com" target="_blank"><img width="256px" src="https://raw.githubusercontent.com/ducflair/assets/refs/heads/main/src/duc/duc-extended.png" /></a>
5
+ <a href="https://duc.ducflair.com" target="_blank"><img width="256px" src="https://cdn.jsdelivr.net/gh/ducflair/assets@main/src/duc/duc-extended.png" /></a>
6
6
  <p align="center">2D CAD File Format</p>
7
7
  <p align="center" style="align: center;">
8
8
  <a href="https://crates.io/crates/duc/"><img src="https://shields.io/badge/Crates-FFC933?logo=Rust&logoColor=646464&style=round-square" alt="Crates" /></a>
@@ -313,7 +313,7 @@ impl<'a> VersionControl<'a> {
313
313
  self.conn.with(|c| {
314
314
  c.query_row(
315
315
  "SELECT current_version, current_schema_version, chain_count,
316
- last_pruned, total_size
316
+ total_size
317
317
  FROM version_graph WHERE id = 1",
318
318
  [],
319
319
  |row| {
@@ -321,8 +321,7 @@ impl<'a> VersionControl<'a> {
321
321
  current_version: row.get(0)?,
322
322
  current_schema_version: row.get(1)?,
323
323
  chain_count: row.get(2)?,
324
- last_pruned: row.get::<_, Option<i64>>(3)?.unwrap_or(0),
325
- total_size: row.get::<_, Option<i64>>(4)?.unwrap_or(0),
324
+ total_size: row.get::<_, Option<i64>>(3)?.unwrap_or(0),
326
325
  })
327
326
  },
328
327
  )
@@ -465,93 +464,6 @@ impl<'a> VersionControl<'a> {
465
464
  })
466
465
  }
467
466
 
468
- // ────────────────────────────────────────────────────────────────────────
469
- // WRITE — Pruning
470
- // ────────────────────────────────────────────────────────────────────────
471
-
472
- /// Prune old deltas that precede the given `keep_after_version`.
473
- /// Checkpoints are never pruned (they are self-contained recovery points).
474
- /// Returns the number of deltas removed.
475
- pub fn prune_deltas_before(&self, keep_after_version: i64) -> DbResult<usize> {
476
- self.conn.with(|c| {
477
- let removed = c
478
- .execute(
479
- "DELETE FROM deltas WHERE version_number < ?1",
480
- [keep_after_version],
481
- )
482
- .map_err(DbError::from)?;
483
-
484
- // Update last_pruned timestamp
485
- let now_ms = std::time::SystemTime::now()
486
- .duration_since(std::time::UNIX_EPOCH)
487
- .unwrap_or_default()
488
- .as_millis() as i64;
489
-
490
- c.execute(
491
- "UPDATE version_graph SET last_pruned = ?1 WHERE id = 1",
492
- [now_ms],
493
- )
494
- .map_err(DbError::from)?;
495
-
496
- // Recalculate total_size
497
- self.recalculate_total_size(c)?;
498
-
499
- Ok(removed)
500
- })
501
- }
502
-
503
- /// Remove ALL versions (checkpoints + deltas) that are strictly older than
504
- /// `keep_after_version`, except the most recent checkpoint before that
505
- /// boundary (needed as a recovery base).
506
- pub fn prune_before(&self, keep_after_version: i64) -> DbResult<usize> {
507
- self.conn.with(|c| {
508
- // Find the latest checkpoint at or before the boundary — keep it
509
- let safe_cp_version: Option<i64> = c
510
- .query_row(
511
- "SELECT MAX(version_number) FROM checkpoints
512
- WHERE version_number <= ?1",
513
- [keep_after_version],
514
- |row| row.get(0),
515
- )
516
- .optional()
517
- .map_err(DbError::from)?
518
- .flatten();
519
-
520
- let safe_version = safe_cp_version.unwrap_or(0);
521
-
522
- // Delete deltas before the safe checkpoint
523
- let del_deltas = c
524
- .execute(
525
- "DELETE FROM deltas WHERE version_number < ?1",
526
- [safe_version],
527
- )
528
- .map_err(DbError::from)?;
529
-
530
- // Delete checkpoints before the safe checkpoint (keep the safe one)
531
- let del_cps = c
532
- .execute(
533
- "DELETE FROM checkpoints WHERE version_number < ?1",
534
- [safe_version],
535
- )
536
- .map_err(DbError::from)?;
537
-
538
- let now_ms = std::time::SystemTime::now()
539
- .duration_since(std::time::UNIX_EPOCH)
540
- .unwrap_or_default()
541
- .as_millis() as i64;
542
-
543
- c.execute(
544
- "UPDATE version_graph SET last_pruned = ?1 WHERE id = 1",
545
- [now_ms],
546
- )
547
- .map_err(DbError::from)?;
548
-
549
- self.recalculate_total_size(c)?;
550
-
551
- Ok(del_deltas + del_cps)
552
- })
553
- }
554
-
555
467
  // ────────────────────────────────────────────────────────────────────────
556
468
  // WRITE — Revert
557
469
  // ────────────────────────────────────────────────────────────────────────
@@ -808,7 +720,7 @@ pub(crate) fn read_version_graph_inner(
808
720
  let mut vg_stmt = conn
809
721
  .prepare(
810
722
  "SELECT current_version, current_schema_version, user_checkpoint_version_id,
811
- latest_version_id, chain_count, last_pruned, total_size
723
+ latest_version_id, chain_count, total_size
812
724
  FROM version_graph WHERE id = 1",
813
725
  )
814
726
  .map_err(DbError::from)?;
@@ -819,8 +731,7 @@ pub(crate) fn read_version_graph_inner(
819
731
  current_version: row.get(0)?,
820
732
  current_schema_version: row.get(1)?,
821
733
  chain_count: row.get(4)?,
822
- last_pruned: row.get::<_, Option<i64>>(5)?.unwrap_or(0),
823
- total_size: row.get::<_, Option<i64>>(6)?.unwrap_or(0),
734
+ total_size: row.get::<_, Option<i64>>(5)?.unwrap_or(0),
824
735
  },
825
736
  row.get::<_, Option<String>>(2)?.unwrap_or_default(),
826
737
  row.get::<_, Option<String>>(3)?.unwrap_or_default(),
@@ -0,0 +1,298 @@
1
+ #[allow(unused_imports)]
2
+ #[allow(non_camel_case_types)]
3
+ #[allow(non_snake_case)]
4
+ #[allow(non_upper_case_globals)]
5
+ pub mod parse;
6
+ pub mod serde_utils;
7
+ pub mod serialize;
8
+ pub mod types;
9
+
10
+ // SQLite storage layer and high-level document API
11
+ pub mod db;
12
+ pub mod api;
13
+
14
+ #[cfg(test)]
15
+ mod tests {
16
+ use std::collections::HashSet;
17
+ use std::fs;
18
+ use std::path::{Path, PathBuf};
19
+
20
+ use crate::parse;
21
+ use crate::serialize;
22
+ use crate::types::*;
23
+
24
+ fn assets_dir() -> PathBuf {
25
+ Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/testing/duc-files")
26
+ }
27
+
28
+ fn all_duc_files() -> Vec<PathBuf> {
29
+ let dir = assets_dir();
30
+ assert!(dir.exists(), "assets dir missing: {}", dir.display());
31
+ let mut files: Vec<PathBuf> = fs::read_dir(&dir)
32
+ .expect("read assets dir")
33
+ .filter_map(|e| e.ok())
34
+ .map(|e| e.path())
35
+ .filter(|p| p.extension().is_some_and(|ext| ext == "duc"))
36
+ .collect();
37
+ files.sort();
38
+ assert!(!files.is_empty(), "no .duc files in {}", dir.display());
39
+ files
40
+ }
41
+
42
+ fn load(path: &Path) -> Vec<u8> {
43
+ fs::read(path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()))
44
+ }
45
+
46
+ // ── Parse every asset ────────────────────────────────────────────────────
47
+
48
+ #[test]
49
+ fn parse_all_assets() {
50
+ for path in all_duc_files() {
51
+ let buf = load(&path);
52
+ let state = parse::parse(&buf)
53
+ .unwrap_or_else(|e| panic!("parse {} failed: {e}", path.display()));
54
+
55
+ assert!(!state.version.is_empty(), "{}: version must not be empty", path.display());
56
+ assert!(!state.source.is_empty(), "{}: source must not be empty", path.display());
57
+ assert!(!state.elements.is_empty() || !state.layers.is_empty(),
58
+ "{}: must have elements or layers", path.display());
59
+ }
60
+ }
61
+
62
+ // ── Round-trip: parse → serialize → re-parse preserves data ──────────────
63
+
64
+ #[test]
65
+ fn roundtrip_all_assets() {
66
+ for path in all_duc_files() {
67
+ let original = load(&path);
68
+ let parsed = parse::parse(&original)
69
+ .unwrap_or_else(|e| panic!("parse {} failed: {e}", path.display()));
70
+
71
+ let serialized = serialize::serialize(&parsed)
72
+ .unwrap_or_else(|e| panic!("serialize {} failed: {e}", path.display()));
73
+ assert!(!serialized.is_empty(), "{}: serialized must not be empty", path.display());
74
+
75
+ let reparsed = parse::parse(&serialized)
76
+ .unwrap_or_else(|e| panic!("re-parse {} failed: {e}", path.display()));
77
+
78
+ assert_eq!(
79
+ parsed.elements.len(), reparsed.elements.len(),
80
+ "{}: element count mismatch", path.display()
81
+ );
82
+ assert_eq!(
83
+ parsed.layers.len(), reparsed.layers.len(),
84
+ "{}: layer count mismatch", path.display()
85
+ );
86
+ assert_eq!(
87
+ parsed.blocks.len(), reparsed.blocks.len(),
88
+ "{}: block count mismatch", path.display()
89
+ );
90
+ assert_eq!(
91
+ parsed.block_instances.len(), reparsed.block_instances.len(),
92
+ "{}: block instance count mismatch", path.display()
93
+ );
94
+ assert_eq!(
95
+ parsed.groups.len(), reparsed.groups.len(),
96
+ "{}: group count mismatch", path.display()
97
+ );
98
+ assert_eq!(
99
+ parsed.regions.len(), reparsed.regions.len(),
100
+ "{}: region count mismatch", path.display()
101
+ );
102
+ }
103
+ }
104
+
105
+ // ── Lazy parse skips external files ──────────────────────────────────────
106
+
107
+ #[test]
108
+ fn lazy_parse_skips_external_files() {
109
+ for path in all_duc_files() {
110
+ let buf = load(&path);
111
+ let full = parse::parse(&buf)
112
+ .unwrap_or_else(|e| panic!("parse {} failed: {e}", path.display()));
113
+ let lazy = parse::parse_lazy(&buf)
114
+ .unwrap_or_else(|e| panic!("parse_lazy {} failed: {e}", path.display()));
115
+
116
+ assert!(lazy.external_files.is_none(), "{}: lazy parse must omit external_files", path.display());
117
+ assert!(lazy.external_files_data.is_none(), "{}: lazy parse must omit external_files_data", path.display());
118
+ assert_eq!(full.elements.len(), lazy.elements.len(), "{}: element count diverged", path.display());
119
+ assert_eq!(full.layers.len(), lazy.layers.len(), "{}: layer count diverged", path.display());
120
+ }
121
+ }
122
+
123
+ // ── Element IDs are unique within each file ──────────────────────────────
124
+
125
+ #[test]
126
+ fn element_ids_unique() {
127
+ for path in all_duc_files() {
128
+ let buf = load(&path);
129
+ let state = parse::parse(&buf)
130
+ .unwrap_or_else(|e| panic!("parse {} failed: {e}", path.display()));
131
+
132
+ let mut seen = HashSet::new();
133
+ for wrapper in &state.elements {
134
+ let id = element_id(&wrapper.element);
135
+ assert!(seen.insert(id.clone()), "{}: duplicate element id '{id}'", path.display());
136
+ }
137
+ }
138
+ }
139
+
140
+ // ── Every element references a valid layer ───────────────────────────────
141
+
142
+ #[test]
143
+ fn element_layer_refs_valid() {
144
+ for path in all_duc_files() {
145
+ let buf = load(&path);
146
+ let state = parse::parse(&buf)
147
+ .unwrap_or_else(|e| panic!("parse {} failed: {e}", path.display()));
148
+
149
+ let layer_ids: HashSet<String> = state.layers.iter().map(|l| l.id.clone()).collect();
150
+
151
+ for wrapper in &state.elements {
152
+ let base = element_base(&wrapper.element);
153
+ if let Some(ref lid) = base.layer_id {
154
+ assert!(
155
+ layer_ids.contains(lid),
156
+ "{}: element '{}' references non-existent layer '{lid}'",
157
+ path.display(), base.id
158
+ );
159
+ }
160
+ }
161
+ }
162
+ }
163
+
164
+ // ── Block instances reference valid blocks ───────────────────────────────
165
+
166
+ #[test]
167
+ fn block_instance_refs_valid() {
168
+ for path in all_duc_files() {
169
+ let buf = load(&path);
170
+ let state = parse::parse(&buf)
171
+ .unwrap_or_else(|e| panic!("parse {} failed: {e}", path.display()));
172
+
173
+ let block_ids: HashSet<String> = state.blocks.iter().map(|b| b.id.clone()).collect();
174
+
175
+ for inst in &state.block_instances {
176
+ assert!(
177
+ block_ids.contains(&inst.block_id),
178
+ "{}: block instance '{}' references non-existent block '{}'",
179
+ path.display(), inst.id, inst.block_id
180
+ );
181
+ }
182
+ }
183
+ }
184
+
185
+ // ── External files: list ↔ get round-trip ────────────────────────────────
186
+
187
+ #[test]
188
+ fn external_files_list_get_consistent() {
189
+ for path in all_duc_files() {
190
+ let buf = load(&path);
191
+ let listing = parse::list_external_files(&buf)
192
+ .unwrap_or_else(|e| panic!("list_external_files {} failed: {e}", path.display()));
193
+
194
+ for meta in &listing {
195
+ let file = parse::get_external_file(&buf, &meta.id)
196
+ .unwrap_or_else(|e| panic!("get_external_file {} id={} failed: {e}", path.display(), meta.id));
197
+ assert!(file.is_some(), "{}: listed file '{}' not found via get", path.display(), meta.id);
198
+ let loaded = file.unwrap();
199
+ assert_eq!(loaded.file.id, meta.id);
200
+ assert!(!loaded.file.revisions.is_empty(), "{}: file '{}' has no revisions", path.display(), meta.id);
201
+ }
202
+ }
203
+ }
204
+
205
+ // ── DB layer: open in-memory, schema bootstraps correctly ────────────────
206
+
207
+ #[test]
208
+ fn db_open_memory_and_schema() {
209
+ use crate::api::DucDocument;
210
+
211
+ let doc = DucDocument::open_memory().expect("open_memory");
212
+ let version = doc.schema_version().expect("schema_version");
213
+ assert!(version > 0, "schema version must be positive, got {version}");
214
+ }
215
+
216
+ // ── Compression: serialized output is smaller than raw SQLite ────────────
217
+
218
+ #[test]
219
+ fn serialized_is_compressed() {
220
+ for path in all_duc_files() {
221
+ let buf = load(&path);
222
+ assert!(!parse::is_sqlite_header(&buf), "{}: assets should be compressed, not raw SQLite", path.display());
223
+
224
+ let raw = parse::decompress_duc_bytes(&buf)
225
+ .unwrap_or_else(|e| panic!("decompress {} failed: {e}", path.display()));
226
+
227
+ let state = parse::parse(&buf)
228
+ .unwrap_or_else(|e| panic!("parse {} failed: {e}", path.display()));
229
+ let recompressed = serialize::serialize(&state)
230
+ .unwrap_or_else(|e| panic!("serialize {} failed: {e}", path.display()));
231
+
232
+ assert!(
233
+ recompressed.len() < raw.len(),
234
+ "{}: compressed ({} B) should be smaller than raw SQLite ({} B)",
235
+ path.display(), recompressed.len(), raw.len()
236
+ );
237
+ }
238
+ }
239
+
240
+ // ── Global state invariants ──────────────────────────────────────────────
241
+
242
+ #[test]
243
+ fn global_state_valid() {
244
+ for path in all_duc_files() {
245
+ let buf = load(&path);
246
+ let state = parse::parse(&buf)
247
+ .unwrap_or_else(|e| panic!("parse {} failed: {e}", path.display()));
248
+
249
+ if let Some(ref gs) = state.duc_global_state {
250
+ assert!(!gs.view_background_color.is_empty(), "{}: background color must not be empty", path.display());
251
+ assert!(!gs.main_scope.is_empty(), "{}: main_scope must not be empty", path.display());
252
+ }
253
+ }
254
+ }
255
+
256
+ // ── Error handling: garbage input produces a clear error ──────────────────
257
+
258
+ #[test]
259
+ fn parse_rejects_garbage() {
260
+ let cases: &[(&str, &[u8])] = &[
261
+ ("empty", &[]),
262
+ ("random", &[0xFF; 64]),
263
+ ("text", b"Not a DUC file"),
264
+ ];
265
+ for (label, data) in cases {
266
+ assert!(
267
+ parse::parse(data).is_err(),
268
+ "parse should reject {label} input"
269
+ );
270
+ }
271
+ }
272
+
273
+ // ── Helpers ──────────────────────────────────────────────────────────────
274
+
275
+ fn element_id(el: &DucElementEnum) -> String {
276
+ element_base(el).id.clone()
277
+ }
278
+
279
+ fn element_base(el: &DucElementEnum) -> &DucElementBase {
280
+ match el {
281
+ DucElementEnum::DucRectangleElement(e) => &e.base,
282
+ DucElementEnum::DucPolygonElement(e) => &e.base,
283
+ DucElementEnum::DucEllipseElement(e) => &e.base,
284
+ DucElementEnum::DucEmbeddableElement(e) => &e.base,
285
+ DucElementEnum::DucPdfElement(e) => &e.base,
286
+ DucElementEnum::DucTableElement(e) => &e.base,
287
+ DucElementEnum::DucImageElement(e) => &e.base,
288
+ DucElementEnum::DucTextElement(e) => &e.base,
289
+ DucElementEnum::DucLinearElement(e) => &e.linear_base.base,
290
+ DucElementEnum::DucArrowElement(e) => &e.linear_base.base,
291
+ DucElementEnum::DucFreeDrawElement(e) => &e.base,
292
+ DucElementEnum::DucFrameElement(e) => &e.stack_element_base.base,
293
+ DucElementEnum::DucPlotElement(e) => &e.stack_element_base.base,
294
+ DucElementEnum::DucDocElement(e) => &e.base,
295
+ DucElementEnum::DucModelElement(e) => &e.base,
296
+ }
297
+ }
298
+ }