tenseleyflow/sway / 5899776

Browse files

tests/pack_unpack: 17 unit tests round-tripping spec + dlm_source + golden + every error path (S26 X3-P6)

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
5899776f5597a85077fd199516eaf115d2c5bf69
Parents
9b8fab3
Tree
d6c2599

1 changed file

StatusFile+-
A tests/unit/test_pack_unpack.py 266 0
tests/unit/test_pack_unpack.pyadded
@@ -0,0 +1,266 @@
1
+"""Unit tests for ``sway pack`` / ``sway unpack`` (Sprint 26 / X3).
2
+
3
+Round-trips a synthetic spec through pack → unpack → re-load and
4
+verifies every artifact comes back byte-identical (modulo tarball
5
+metadata). Also pins the structural error paths: missing dlm_source,
6
+size cap, refusing to overwrite, malformed manifest, version
7
+mismatch, path-traversal rejection.
8
+
9
+End-to-end "sway run on the unpacked spec matches the pre-pack
10
+verdict" lives in ``tests/integration/test_pack_run_roundtrip.py``.
11
+"""
12
+
13
+from __future__ import annotations
14
+
15
+import json
16
+import tarfile
17
+from pathlib import Path
18
+
19
+import pytest
20
+
21
+from dlm_sway.cli._pack import (
22
+    DEFAULT_MAX_PACK_SIZE_BYTES,
23
+    SWAYPACK_VERSION,
24
+    PackError,
25
+    pack_spec,
26
+)
27
+from dlm_sway.cli._unpack import UnpackError, unpack_swaypack
28
+
29
+
30
+def _write_minimal_spec(spec_path: Path, *, dlm_source: str | None = None) -> None:
31
+    """Write a tiny syntactically-valid sway.yaml. The dlm_source
32
+    field is optional — set it to point at a sibling ``.dlm`` to
33
+    test source-bundling."""
34
+    body = """\
35
+version: 1
36
+models:
37
+  base: {kind: dummy, base: dummy-base}
38
+  ft:   {kind: dummy, base: dummy-base}
39
+defaults:
40
+  seed: 0
41
+suite:
42
+  - {name: dk, kind: delta_kl, prompts: [hello]}
43
+"""
44
+    if dlm_source:
45
+        body += f"dlm_source: {dlm_source}\n"
46
+    spec_path.write_text(body, encoding="utf-8")
47
+
48
+
49
+class TestPackBasics:
50
+    def test_round_trip_minimal_spec(self, tmp_path: Path) -> None:
51
+        spec_path = tmp_path / "sway.yaml"
52
+        _write_minimal_spec(spec_path)
53
+        out = tmp_path / "test.swaypack.tar.gz"
54
+
55
+        report = pack_spec(spec_path, out_path=out, include_null_cache=False)
56
+
57
+        assert out.exists()
58
+        assert report.size_bytes > 0
59
+        assert report.spec_path == spec_path.resolve()
60
+        assert report.section_bytes == 0
61
+        assert report.null_stats_count == 0
62
+        assert report.golden_included is False
63
+
64
+    def test_unpack_round_trip(self, tmp_path: Path) -> None:
65
+        spec_path = tmp_path / "sway.yaml"
66
+        _write_minimal_spec(spec_path)
67
+        out = tmp_path / "test.swaypack.tar.gz"
68
+        pack_spec(spec_path, out_path=out, include_null_cache=False)
69
+
70
+        target = tmp_path / "unpacked"
71
+        report = unpack_swaypack(out, target_dir=target)
72
+
73
+        assert report.out_dir == (target / "swaypack").resolve()
74
+        assert report.spec_path.exists()
75
+        # Spec round-trips byte-identical.
76
+        assert report.spec_path.read_bytes() == spec_path.read_bytes()
77
+        assert report.null_stats_dir is None  # No cache packed.
78
+
79
+    def test_manifest_records_pack_metadata(self, tmp_path: Path) -> None:
80
+        """Manifest must include swaypack_version + sway_version
81
+        + packed_at + counts so an unpacker can reason about the pack."""
82
+        spec_path = tmp_path / "sway.yaml"
83
+        _write_minimal_spec(spec_path)
84
+        out = tmp_path / "test.swaypack.tar.gz"
85
+        pack_spec(spec_path, out_path=out, include_null_cache=False)
86
+
87
+        with tarfile.open(str(out), mode="r:gz") as tar:
88
+            manifest_member = tar.getmember("swaypack/manifest.json")
89
+            f = tar.extractfile(manifest_member)
90
+            assert f is not None
91
+            manifest = json.loads(f.read().decode("utf-8"))
92
+
93
+        assert manifest["swaypack_version"] == SWAYPACK_VERSION
94
+        assert "sway_version" in manifest
95
+        assert "packed_at" in manifest
96
+        assert manifest["section_bytes"] == 0
97
+        assert manifest["null_stats_count"] == 0
98
+        assert manifest["golden_included"] is False
99
+        assert manifest["dlm_source_packed"] is False
100
+
101
+    def test_dlm_source_bundled_when_present(self, tmp_path: Path) -> None:
102
+        """A spec with ``dlm_source`` next to it bundles source.dlm."""
103
+        dlm_path = tmp_path / "doc.dlm"
104
+        dlm_path.write_text("---\ndlm_id: 01TEST\n---\nbody\n", encoding="utf-8")
105
+
106
+        spec_path = tmp_path / "sway.yaml"
107
+        _write_minimal_spec(spec_path, dlm_source="doc.dlm")
108
+        out = tmp_path / "test.swaypack.tar.gz"
109
+
110
+        report = pack_spec(spec_path, out_path=out, include_null_cache=False)
111
+        assert report.section_bytes > 0
112
+
113
+        target = tmp_path / "unpacked"
114
+        unpack_report = unpack_swaypack(out, target_dir=target)
115
+        bundled = unpack_report.out_dir / "source.dlm"
116
+        assert bundled.exists()
117
+        assert bundled.read_bytes() == dlm_path.read_bytes()
118
+
119
+    def test_dlm_source_missing_logs_warning_but_succeeds(
120
+        self, tmp_path: Path, caplog: pytest.LogCaptureFixture
121
+    ) -> None:
122
+        """Spec with dlm_source pointing at a nonexistent file: pack
123
+        succeeds with section_bytes=0 + a warning."""
124
+        spec_path = tmp_path / "sway.yaml"
125
+        _write_minimal_spec(spec_path, dlm_source="missing.dlm")
126
+        out = tmp_path / "test.swaypack.tar.gz"
127
+
128
+        with caplog.at_level("WARNING"):
129
+            report = pack_spec(spec_path, out_path=out, include_null_cache=False)
130
+        assert report.section_bytes == 0
131
+        assert any("missing.dlm" in rec.message for rec in caplog.records)
132
+
133
+
134
+class TestPackErrors:
135
+    def test_refuses_to_overwrite_existing_pack(self, tmp_path: Path) -> None:
136
+        spec_path = tmp_path / "sway.yaml"
137
+        _write_minimal_spec(spec_path)
138
+        out = tmp_path / "test.swaypack.tar.gz"
139
+        out.write_bytes(b"placeholder")
140
+        with pytest.raises(PackError, match="refusing to overwrite"):
141
+            pack_spec(spec_path, out_path=out, include_null_cache=False)
142
+
143
+    def test_size_cap_rejects_oversize_pack(self, tmp_path: Path) -> None:
144
+        """A 0-byte cap should reject any non-empty pack."""
145
+        spec_path = tmp_path / "sway.yaml"
146
+        _write_minimal_spec(spec_path)
147
+        out = tmp_path / "test.swaypack.tar.gz"
148
+        with pytest.raises(PackError, match="exceeds the cap"):
149
+            pack_spec(
150
+                spec_path,
151
+                out_path=out,
152
+                include_null_cache=False,
153
+                max_size_bytes=10,
154
+            )
155
+        # File must NOT have been written when the cap was tripped.
156
+        assert not out.exists()
157
+
158
+    def test_default_max_size_is_50mb(self) -> None:
159
+        """The default cap is the published 50 MB number — a regression
160
+        guard so a stray edit doesn't quietly raise the bar."""
161
+        assert DEFAULT_MAX_PACK_SIZE_BYTES == 50 * 1024 * 1024
162
+
163
+    def test_include_golden_missing_raises(self, tmp_path: Path) -> None:
164
+        spec_path = tmp_path / "sway.yaml"
165
+        _write_minimal_spec(spec_path)
166
+        out = tmp_path / "test.swaypack.tar.gz"
167
+        with pytest.raises(PackError, match="--include-golden"):
168
+            pack_spec(
169
+                spec_path,
170
+                out_path=out,
171
+                include_golden=tmp_path / "nope.json",
172
+                include_null_cache=False,
173
+            )
174
+
175
+    def test_include_golden_bundles_file(self, tmp_path: Path) -> None:
176
+        spec_path = tmp_path / "sway.yaml"
177
+        _write_minimal_spec(spec_path)
178
+        golden = tmp_path / "report.json"
179
+        golden.write_text(json.dumps({"verdict": "pass"}), encoding="utf-8")
180
+        out = tmp_path / "test.swaypack.tar.gz"
181
+
182
+        report = pack_spec(
183
+            spec_path,
184
+            out_path=out,
185
+            include_golden=golden,
186
+            include_null_cache=False,
187
+        )
188
+        assert report.golden_included is True
189
+
190
+        target = tmp_path / "u"
191
+        unpack_swaypack(out, target_dir=target)
192
+        bundled = target / "swaypack" / "golden.json"
193
+        assert bundled.exists()
194
+        assert json.loads(bundled.read_text()) == {"verdict": "pass"}
195
+
196
+
197
+class TestUnpackErrors:
198
+    def test_missing_pack_file_raises(self, tmp_path: Path) -> None:
199
+        with pytest.raises(UnpackError, match="not found"):
200
+            unpack_swaypack(tmp_path / "no.tar.gz", target_dir=tmp_path)
201
+
202
+    def test_pack_path_is_dir_raises(self, tmp_path: Path) -> None:
203
+        d = tmp_path / "adir"
204
+        d.mkdir()
205
+        with pytest.raises(UnpackError, match="must be a file"):
206
+            unpack_swaypack(d, target_dir=tmp_path)
207
+
208
+    def test_refuses_to_overwrite_existing_swaypack_dir(self, tmp_path: Path) -> None:
209
+        spec_path = tmp_path / "sway.yaml"
210
+        _write_minimal_spec(spec_path)
211
+        out = tmp_path / "test.swaypack.tar.gz"
212
+        pack_spec(spec_path, out_path=out, include_null_cache=False)
213
+
214
+        target = tmp_path / "u"
215
+        unpack_swaypack(out, target_dir=target)  # First unpack succeeds.
216
+        with pytest.raises(UnpackError, match="refusing to overwrite"):
217
+            unpack_swaypack(out, target_dir=target)  # Second refuses.
218
+
219
+    def test_corrupt_pack_raises(self, tmp_path: Path) -> None:
220
+        bad = tmp_path / "bad.tar.gz"
221
+        bad.write_bytes(b"not a tarball")
222
+        with pytest.raises(UnpackError, match="failed to extract"):
223
+            unpack_swaypack(bad, target_dir=tmp_path)
224
+
225
+    def test_pack_without_swaypack_root_raises(self, tmp_path: Path) -> None:
226
+        """A tar.gz that doesn't follow the ``swaypack/`` layout is rejected."""
227
+        bad = tmp_path / "wrong.tar.gz"
228
+        with tarfile.open(str(bad), mode="w:gz") as tar:
229
+            info = tarfile.TarInfo(name="other/sway.yaml")
230
+            payload = b"version: 1\n"
231
+            info.size = len(payload)
232
+            import io as _io
233
+
234
+            tar.addfile(info, _io.BytesIO(payload))
235
+        with pytest.raises(UnpackError, match="missing the expected 'swaypack/'"):
236
+            unpack_swaypack(bad, target_dir=tmp_path)
237
+
238
+    def test_missing_manifest_raises(self, tmp_path: Path) -> None:
239
+        """A pack with the right top-level dir but no manifest fails."""
240
+        bad = tmp_path / "no-manifest.tar.gz"
241
+        with tarfile.open(str(bad), mode="w:gz") as tar:
242
+            info = tarfile.TarInfo(name="swaypack/sway.yaml")
243
+            payload = b"version: 1\n"
244
+            info.size = len(payload)
245
+            import io as _io
246
+
247
+            tar.addfile(info, _io.BytesIO(payload))
248
+        with pytest.raises(UnpackError, match="missing manifest.json"):
249
+            unpack_swaypack(bad, target_dir=tmp_path)
250
+
251
+    def test_version_mismatch_raises(self, tmp_path: Path) -> None:
252
+        """A future swaypack_version is rejected with a clear message."""
253
+        bad = tmp_path / "future.tar.gz"
254
+        manifest = json.dumps({"swaypack_version": 99, "spec_filename": "sway.yaml"})
255
+        with tarfile.open(str(bad), mode="w:gz") as tar:
256
+            for name, payload in (
257
+                ("swaypack/sway.yaml", b"version: 1\n"),
258
+                ("swaypack/manifest.json", manifest.encode("utf-8")),
259
+            ):
260
+                info = tarfile.TarInfo(name=name)
261
+                info.size = len(payload)
262
+                import io as _io
263
+
264
+                tar.addfile(info, _io.BytesIO(payload))
265
+        with pytest.raises(UnpackError, match="unsupported swaypack_version=99"):
266
+            unpack_swaypack(bad, target_dir=tmp_path)