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.
- {ducpy-3.1.0 → ducpy-3.2.0}/Cargo.lock +9 -7
- {ducpy-3.1.0 → ducpy-3.2.0}/Cargo.toml +0 -1
- {ducpy-3.1.0 → ducpy-3.2.0}/PKG-INFO +2 -2
- {ducpy-3.1.0 → ducpy-3.2.0}/README.md +1 -1
- {ducpy-3.1.0 → ducpy-3.2.0}/packages/ducpy/crate/Cargo.toml +1 -1
- {ducpy-3.1.0 → ducpy-3.2.0}/packages/ducrs/README.md +1 -1
- {ducpy-3.1.0 → ducpy-3.2.0}/packages/ducrs/src/api/version_control.rs +4 -93
- ducpy-3.2.0/packages/ducrs/src/lib.rs +298 -0
- {ducpy-3.1.0 → ducpy-3.2.0}/packages/ducrs/src/parse.rs +214 -98
- ducpy-3.2.0/packages/ducrs/src/serde_utils.rs +90 -0
- {ducpy-3.1.0 → ducpy-3.2.0}/packages/ducrs/src/serialize.rs +17 -10
- {ducpy-3.1.0 → ducpy-3.2.0}/packages/ducrs/src/types.rs +42 -24
- ducpy-3.1.0/packages/ducrs/src/lib.rs +0 -94
- {ducpy-3.1.0 → ducpy-3.2.0}/LICENSE +0 -0
- {ducpy-3.1.0 → ducpy-3.2.0}/packages/ducpy/crate/src/lib.rs +0 -0
- {ducpy-3.1.0 → ducpy-3.2.0}/packages/ducrs/.gitignore +0 -0
- {ducpy-3.1.0 → ducpy-3.2.0}/packages/ducrs/Cargo.toml +0 -0
- {ducpy-3.1.0 → ducpy-3.2.0}/packages/ducrs/LICENSE +0 -0
- {ducpy-3.1.0 → ducpy-3.2.0}/packages/ducrs/build.rs +0 -0
- {ducpy-3.1.0 → ducpy-3.2.0}/packages/ducrs/package.json +0 -0
- {ducpy-3.1.0 → ducpy-3.2.0}/packages/ducrs/release.config.cjs +0 -0
- {ducpy-3.1.0 → ducpy-3.2.0}/packages/ducrs/src/api/document.rs +0 -0
- {ducpy-3.1.0 → ducpy-3.2.0}/packages/ducrs/src/api/meta.rs +0 -0
- {ducpy-3.1.0 → ducpy-3.2.0}/packages/ducrs/src/api/mod.rs +0 -0
- {ducpy-3.1.0 → ducpy-3.2.0}/packages/ducrs/src/db/bootstrap.rs +0 -0
- {ducpy-3.1.0 → ducpy-3.2.0}/packages/ducrs/src/db/mod.rs +0 -0
- {ducpy-3.1.0 → ducpy-3.2.0}/packages/ducrs/src/db/native.rs +0 -0
- {ducpy-3.1.0 → ducpy-3.2.0}/packages/ducrs/src/db/wasm.rs +0 -0
- {ducpy-3.1.0 → ducpy-3.2.0}/packages/ducrs/tests/.gitignore +0 -0
- {ducpy-3.1.0 → ducpy-3.2.0}/pyproject.toml +0 -0
- {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.
|
|
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.
|
|
716
|
+
version = "1.3.7"
|
|
717
717
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
718
|
-
checksum = "
|
|
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.
|
|
2482
|
+
version = "0.8.42"
|
|
2481
2483
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
2482
|
-
checksum = "
|
|
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.
|
|
2491
|
+
version = "0.8.42"
|
|
2490
2492
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
2491
|
-
checksum = "
|
|
2493
|
+
checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f"
|
|
2492
2494
|
dependencies = [
|
|
2493
2495
|
"proc-macro2",
|
|
2494
2496
|
"quote",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ducpy
|
|
3
|
-
Version: 3.
|
|
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://
|
|
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://
|
|
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>
|
|
@@ -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://
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
+
}
|