• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

bramp / build-along / 20015271182

08 Dec 2025 03:02AM UTC coverage: 90.402% (+0.1%) from 90.299%
20015271182

push

github

bramp
Minor rename of property in subassembly config.

3 of 3 new or added lines in 1 file covered. (100.0%)

125 existing lines in 14 files now uncovered.

11039 of 12211 relevant lines covered (90.4%)

0.9 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

99.72
/src/build_a_long/pdf_extract/validation/validation_test.py
1
"""Tests for validation module."""
2

3
from build_a_long.pdf_extract.classifier import (
1✔
4
    BatchClassificationResult,
5
    Candidate,
6
    ClassificationResult,
7
    TextHistogram,
8
)
9
from build_a_long.pdf_extract.classifier.test_utils import TestScore
1✔
10
from build_a_long.pdf_extract.extractor import PageData
1✔
11
from build_a_long.pdf_extract.extractor.bbox import BBox
1✔
12
from build_a_long.pdf_extract.extractor.lego_page_elements import (
1✔
13
    Manual,
14
    Page,
15
    PageNumber,
16
    Part,
17
    PartCount,
18
    PartsList,
19
    Step,
20
    StepNumber,
21
)
22

23
from .printer import print_validation
1✔
24
from .rules import (
1✔
25
    format_ranges,
26
    validate_catalog_coverage,
27
    validate_elements_within_page,
28
    validate_first_page_number,
29
    validate_missing_page_numbers,
30
    validate_page_number_sequence,
31
    validate_parts_list_has_parts,
32
    validate_parts_lists_no_overlap,
33
    validate_progress_bar_sequence,
34
    validate_step_sequence,
35
    validate_steps_have_parts,
36
    validate_steps_no_significant_overlap,
37
)
38
from .runner import validate_results
1✔
39
from .types import ValidationIssue, ValidationResult, ValidationSeverity
1✔
40

41

42
class TestValidationResult:
1✔
43
    """Tests for ValidationResult class."""
44

45
    def test_empty_result(self) -> None:
1✔
46
        """Test empty validation result."""
47
        result = ValidationResult()
1✔
48
        assert result.error_count == 0
1✔
49
        assert result.warning_count == 0
1✔
50
        assert result.info_count == 0
1✔
51
        assert not result.has_issues()
1✔
52

53
    def test_add_issue(self) -> None:
1✔
54
        """Test adding issues to result."""
55
        result = ValidationResult()
1✔
56
        result.add(
1✔
57
            ValidationIssue(
58
                severity=ValidationSeverity.ERROR,
59
                rule="test",
60
                message="test error",
61
            )
62
        )
63
        result.add(
1✔
64
            ValidationIssue(
65
                severity=ValidationSeverity.WARNING,
66
                rule="test",
67
                message="test warning",
68
            )
69
        )
70
        result.add(
1✔
71
            ValidationIssue(
72
                severity=ValidationSeverity.INFO,
73
                rule="test",
74
                message="test info",
75
            )
76
        )
77

78
        assert result.error_count == 1
1✔
79
        assert result.warning_count == 1
1✔
80
        assert result.info_count == 1
1✔
81
        assert result.has_issues()
1✔
82

83
    def test_frozen_issue(self) -> None:
1✔
84
        """Test that ValidationIssue is immutable."""
85
        import pydantic
1✔
86

87
        issue = ValidationIssue(
1✔
88
            severity=ValidationSeverity.ERROR,
89
            rule="test",
90
            message="test",
91
        )
92
        # Should not be able to modify (Pydantic frozen model raises ValidationError)
93
        try:
1✔
94
            issue.message = "new message"  # type: ignore[misc]
1✔
UNCOV
95
            raise AssertionError("Expected frozen model to raise")
×
96
        except pydantic.ValidationError:
1✔
97
            pass  # Expected
1✔
98

99

100
class TestFormatRanges:
1✔
101
    """Tests for format_ranges helper function."""
102

103
    def test_empty_list(self) -> None:
1✔
104
        """Test empty list."""
105
        assert format_ranges([]) == ""
1✔
106

107
    def test_single_number(self) -> None:
1✔
108
        """Test single number."""
109
        assert format_ranges([5]) == "5"
1✔
110

111
    def test_consecutive_range(self) -> None:
1✔
112
        """Test consecutive numbers form a range."""
113
        assert format_ranges([1, 2, 3, 4, 5]) == "1-5"
1✔
114

115
    def test_separate_numbers(self) -> None:
1✔
116
        """Test non-consecutive numbers."""
117
        assert format_ranges([1, 3, 5]) == "1, 3, 5"
1✔
118

119
    def test_mixed_ranges(self) -> None:
1✔
120
        """Test mixed ranges and single numbers."""
121
        assert format_ranges([1, 2, 3, 5, 7, 8, 9]) == "1-3, 5, 7-9"
1✔
122

123
    def test_long_list_truncation(self) -> None:
1✔
124
        """Test that very long output is truncated."""
125
        # Create a list that would produce a very long string
126
        numbers = list(range(1, 200, 2))  # Odd numbers 1-199
1✔
127
        result = format_ranges(numbers)
1✔
128
        assert len(result) <= 100
1✔
129
        assert result.endswith("...")
1✔
130

131

132
class TestValidateMissingPageNumbers:
1✔
133
    """Tests for validate_missing_page_numbers rule."""
134

135
    def test_no_missing_pages(self) -> None:
1✔
136
        """Test when all pages have page numbers."""
137
        validation = ValidationResult()
1✔
138
        validate_missing_page_numbers(validation, [], 10)
1✔
139
        assert not validation.has_issues()
1✔
140

141
    def test_high_coverage(self) -> None:
1✔
142
        """Test >90% coverage produces INFO."""
143
        validation = ValidationResult()
1✔
144
        validate_missing_page_numbers(validation, [1], 20)  # 95% coverage
1✔
145
        assert validation.info_count == 1
1✔
146
        assert validation.issues[0].severity == ValidationSeverity.INFO
1✔
147

148
    def test_medium_coverage(self) -> None:
1✔
149
        """Test 50-90% coverage produces WARNING."""
150
        validation = ValidationResult()
1✔
151
        validate_missing_page_numbers(validation, [1, 2, 3], 10)  # 70% coverage
1✔
152
        assert validation.warning_count == 1
1✔
153

154
    def test_low_coverage(self) -> None:
1✔
155
        """Test <50% coverage produces ERROR."""
156
        validation = ValidationResult()
1✔
157
        validate_missing_page_numbers(validation, list(range(1, 8)), 10)  # 30% coverage
1✔
158
        assert validation.error_count == 1
1✔
159

160

161
class TestValidateStepSequence:
1✔
162
    """Tests for validate_step_sequence rule."""
163

164
    def test_empty_steps(self) -> None:
1✔
165
        """Test empty step list."""
166
        validation = ValidationResult()
1✔
167
        validate_step_sequence(validation, [])
1✔
168
        assert not validation.has_issues()
1✔
169

170
    def test_valid_sequence(self) -> None:
1✔
171
        """Test valid step sequence starting at 1."""
172
        validation = ValidationResult()
1✔
173
        validate_step_sequence(validation, [(1, 1), (2, 2), (3, 3)])
1✔
174
        assert not validation.has_issues()
1✔
175

176
    def test_duplicate_steps(self) -> None:
1✔
177
        """Test duplicate step numbers."""
178
        validation = ValidationResult()
1✔
179
        validate_step_sequence(validation, [(1, 1), (2, 1), (3, 2)])  # Step 1 twice
1✔
180
        # Should have warning about duplicates
181
        assert any(i.rule == "duplicate_steps" for i in validation.issues)
1✔
182

183
    def test_step_gaps(self) -> None:
1✔
184
        """Test gaps in step sequence."""
185
        validation = ValidationResult()
1✔
186
        validate_step_sequence(validation, [(1, 1), (2, 3)])  # Missing step 2
1✔
187
        assert any(i.rule == "step_gaps" for i in validation.issues)
1✔
188

189
    def test_step_not_starting_at_one(self) -> None:
1✔
190
        """Test sequence not starting at 1."""
191
        validation = ValidationResult()
1✔
192
        validate_step_sequence(validation, [(1, 5), (2, 6), (3, 7)])  # Starts at 5
1✔
193
        assert any(i.rule == "step_start" for i in validation.issues)
1✔
194

195

196
class TestValidateFirstPageNumber:
1✔
197
    """Tests for validate_first_page_number rule."""
198

199
    def test_no_page_numbers(self) -> None:
1✔
200
        """Test when no page numbers detected."""
201
        validation = ValidationResult()
1✔
202
        validate_first_page_number(validation, [])
1✔
203
        assert validation.error_count == 1
1✔
204
        assert validation.issues[0].rule == "no_page_numbers"
1✔
205

206
    def test_reasonable_first_page(self) -> None:
1✔
207
        """Test reasonable first page number."""
208
        validation = ValidationResult()
1✔
209
        validate_first_page_number(validation, [1, 2, 3])
1✔
210
        assert not validation.has_issues()
1✔
211

212
    def test_high_first_page(self) -> None:
1✔
213
        """Test high first page number."""
214
        validation = ValidationResult()
1✔
215
        validate_first_page_number(validation, [15, 16, 17])
1✔
216
        assert any(i.rule == "high_first_page" for i in validation.issues)
1✔
217

218

219
class TestValidatePageNumberSequence:
1✔
220
    """Tests for validate_page_number_sequence rule."""
221

222
    def test_single_page(self) -> None:
1✔
223
        """Test single page number."""
224
        validation = ValidationResult()
1✔
225
        validate_page_number_sequence(validation, [1])
1✔
226
        assert not validation.has_issues()
1✔
227

228
    def test_valid_sequence(self) -> None:
1✔
229
        """Test valid consecutive sequence."""
230
        validation = ValidationResult()
1✔
231
        validate_page_number_sequence(validation, [1, 2, 3, 4, 5])
1✔
232
        assert not validation.has_issues()
1✔
233

234
    def test_valid_sequence_starting_later(self) -> None:
1✔
235
        """Test valid consecutive sequence that doesn't start at 1.
236

237
        First few pages missing is OK (e.g., cover pages without page numbers).
238
        """
239
        validation = ValidationResult()
1✔
240
        validate_page_number_sequence(validation, [5, 6, 7, 8, 9])
1✔
241
        assert not validation.has_issues()
1✔
242

243
    def test_valid_sequence_ending_early(self) -> None:
1✔
244
        """Test valid consecutive sequence that might end before the last page.
245

246
        Last few pages missing is OK (e.g., back cover without page numbers).
247
        This tests the sequence is consecutive - we don't know total pages here.
248
        """
249
        validation = ValidationResult()
1✔
250
        # Sequence 10-14 is consecutive, even if there could be more pages
251
        validate_page_number_sequence(validation, [10, 11, 12, 13, 14])
1✔
252
        assert not validation.has_issues()
1✔
253

254
    def test_valid_sequence_starting_later_and_ending_early(self) -> None:
1✔
255
        """Test consecutive sequence with both start and end pages missing.
256

257
        Both first N and last M pages can be missing, as long as there are no
258
        gaps in the middle.
259
        """
260
        validation = ValidationResult()
1✔
261
        validate_page_number_sequence(validation, [5, 6, 7, 8, 9, 10])
1✔
262
        assert not validation.has_issues()
1✔
263

264
    def test_decreasing_sequence(self) -> None:
1✔
265
        """Test decreasing page numbers."""
266
        validation = ValidationResult()
1✔
267
        validate_page_number_sequence(validation, [1, 2, 5, 3, 4])  # Decreases at 3
1✔
268
        assert any(i.rule == "page_sequence" for i in validation.issues)
1✔
269

270
    def test_gap_in_middle(self) -> None:
1✔
271
        """Test gap in the middle of page numbers."""
272
        validation = ValidationResult()
1✔
273
        validate_page_number_sequence(validation, [1, 2, 5, 6])  # Gap: 2->5
1✔
274
        assert any(i.rule == "page_gaps" for i in validation.issues)
1✔
275
        # Should be a warning now
276
        gap_issue = next(i for i in validation.issues if i.rule == "page_gaps")
1✔
277
        assert gap_issue.severity == ValidationSeverity.WARNING
1✔
278

279
    def test_small_gap_not_allowed(self) -> None:
1✔
280
        """Test that even small gaps (>1) are flagged."""
281
        validation = ValidationResult()
1✔
282
        validate_page_number_sequence(validation, [1, 2, 4, 5])  # Gap: 2->4
1✔
283
        assert any(i.rule == "page_gaps" for i in validation.issues)
1✔
284

285

286
class TestValidateProgressBarSequence:
1✔
287
    """Tests for validate_progress_bar_sequence rule."""
288

289
    def test_empty_progress_bars(self) -> None:
1✔
290
        """Test empty progress bar list."""
291
        validation = ValidationResult()
1✔
292
        validate_progress_bar_sequence(validation, [])
1✔
293
        assert not validation.has_issues()
1✔
294

295
    def test_valid_sequence(self) -> None:
1✔
296
        """Test valid monotonically increasing sequence."""
297
        validation = ValidationResult()
1✔
298
        # (page, value) tuples
299
        validate_progress_bar_sequence(
1✔
300
            validation, [(1, 0.1), (2, 0.2), (3, 0.3), (4, 0.4)]
301
        )
302
        assert not validation.has_issues()
1✔
303

304
    def test_decreasing_sequence(self) -> None:
1✔
305
        """Test decreasing progress bar values."""
306
        validation = ValidationResult()
1✔
307
        validate_progress_bar_sequence(
1✔
308
            validation,
309
            [(1, 0.5), (2, 0.4), (3, 0.6)],  # Decreases at p.2
310
        )
311
        assert validation.warning_count == 1
1✔
312
        assert validation.issues[0].rule == "progress_bar_decrease"
1✔
313

314
    def test_consistent_increments(self) -> None:
1✔
315
        """Test consistent progress increments (steady rate)."""
316
        validation = ValidationResult()
1✔
317
        # Constant 0.1 increment
318
        validate_progress_bar_sequence(
1✔
319
            validation,
320
            [(1, 0.1), (2, 0.2), (3, 0.3), (4, 0.4), (5, 0.5), (6, 0.6)],
321
        )
322
        assert not validation.has_issues()
1✔
323

324
    def test_inconsistent_increments(self) -> None:
1✔
325
        """Test inconsistent progress increments (high variance)."""
326
        validation = ValidationResult()
1✔
327
        # Increments vary wildly: 0.01, 0.4, 0.01, 0.01, 0.01
328
        validate_progress_bar_sequence(
1✔
329
            validation,
330
            [(1, 0.1), (2, 0.11), (3, 0.51), (4, 0.52), (5, 0.53), (6, 0.54)],
331
        )
332
        assert any(i.rule == "progress_bar_inconsistent" for i in validation.issues)
1✔
333
        issue = next(
1✔
334
            i for i in validation.issues if i.rule == "progress_bar_inconsistent"
335
        )
336
        assert issue.severity == ValidationSeverity.INFO
1✔
337

338
    def test_not_enough_samples(self) -> None:
1✔
339
        """Test that consistency check is skipped for few samples."""
340
        validation = ValidationResult()
1✔
341
        # Highly inconsistent, but only 5 samples (needs >5)
342
        validate_progress_bar_sequence(
1✔
343
            validation,
344
            [(1, 0.1), (2, 0.11), (3, 0.51), (4, 0.52), (5, 0.53)],
345
        )
346
        # Should pass because consistency check requires >5 samples
347
        assert not validation.has_issues()
1✔
348

349

350
class TestValidateCatalogCoverage:
1✔
351
    """Tests for validate_catalog_coverage rule."""
352

353
    def _make_part_with_image(self, image_id: str) -> Part:
1✔
354
        """Create a Part with a diagram image ID."""
355
        from build_a_long.pdf_extract.extractor.lego_page_elements import PartImage
1✔
356

357
        return Part(
1✔
358
            bbox=BBox(0, 0, 10, 10),
359
            count=PartCount(bbox=BBox(0, 0, 5, 5), count=1),
360
            diagram=PartImage(bbox=BBox(0, 0, 10, 10), image_id=image_id),
361
        )
362

363
    def _make_manual(
1✔
364
        self, instruction_image_ids: list[str], catalog_image_ids: list[str]
365
    ) -> Manual:
366
        """Create a Manual with specified parts."""
367
        pages = []
1✔
368

369
        # Instruction page
370
        if instruction_image_ids:
1✔
371
            parts = [self._make_part_with_image(iid) for iid in instruction_image_ids]
1✔
372
            step = Step(
1✔
373
                bbox=BBox(0, 0, 100, 100),
374
                step_number=StepNumber(bbox=BBox(0, 0, 10, 10), value=1),
375
                parts_list=PartsList(bbox=BBox(0, 0, 50, 50), parts=parts),
376
            )
377
            pages.append(
1✔
378
                Page(
379
                    bbox=BBox(0, 0, 100, 100),
380
                    pdf_page_number=1,
381
                    page_number=PageNumber(bbox=BBox(90, 90, 100, 100), value=1),
382
                    categories={Page.PageType.INSTRUCTION},
383
                    steps=[step],
384
                )
385
            )
386

387
        # Catalog page
388
        if catalog_image_ids:
1✔
389
            parts = [self._make_part_with_image(iid) for iid in catalog_image_ids]
1✔
390
            pages.append(
1✔
391
                Page(
392
                    bbox=BBox(0, 0, 100, 100),
393
                    pdf_page_number=2,
394
                    page_number=PageNumber(bbox=BBox(90, 90, 100, 100), value=2),
395
                    categories={Page.PageType.CATALOG},
396
                    catalog=parts,
397
                )
398
            )
399

400
        return Manual(pages=pages)
1✔
401

402
    def test_no_catalog_pages(self) -> None:
1✔
403
        """Test when no catalog pages are present."""
404
        manual = self._make_manual(["img1"], [])
1✔
405
        validation = ValidationResult()
1✔
406
        validate_catalog_coverage(validation, manual)
1✔
407
        assert not validation.has_issues()
1✔
408

409
    def test_no_instruction_parts(self) -> None:
1✔
410
        """Test when no instruction parts are found."""
411
        manual = self._make_manual([], ["img1"])
1✔
412
        validation = ValidationResult()
1✔
413
        validate_catalog_coverage(validation, manual)
1✔
414
        assert not validation.has_issues()
1✔
415

416
    def test_perfect_coverage(self) -> None:
1✔
417
        """Test when all instruction parts are in catalog."""
418
        manual = self._make_manual(["img1", "img2"], ["img1", "img2", "img3"])
1✔
419
        validation = ValidationResult()
1✔
420
        validate_catalog_coverage(validation, manual)
1✔
421
        # Should have INFO about coverage
422
        assert validation.info_count == 1
1✔
423
        assert "100.0%" in validation.issues[0].message
1✔
424

425
    def test_partial_coverage_experimental(self) -> None:
1✔
426
        """Test partial coverage with experimental flag (INFO)."""
427
        # 1 match, 1 missing -> 50% coverage
428
        manual = self._make_manual(["img1", "img2"], ["img1"])
1✔
429
        validation = ValidationResult()
1✔
430
        validate_catalog_coverage(validation, manual, experimental=True)
1✔
431

432
        # 1 INFO for coverage stat, 1 INFO for missing parts (experimental)
433
        assert validation.info_count == 2
1✔
434
        assert validation.warning_count == 0
1✔
435
        assert any(i.rule == "missing_from_catalog" for i in validation.issues)
1✔
436
        missing_issue = next(
1✔
437
            i for i in validation.issues if i.rule == "missing_from_catalog"
438
        )
439
        assert missing_issue.severity == ValidationSeverity.INFO
1✔
440
        assert "[EXPERIMENTAL]" in missing_issue.message
1✔
441

442
    def test_partial_coverage_strict(self) -> None:
1✔
443
        """Test partial coverage without experimental flag (WARNING)."""
444
        # 1 match, 1 missing -> 50% coverage
445
        manual = self._make_manual(["img1", "img2"], ["img1"])
1✔
446
        validation = ValidationResult()
1✔
447
        validate_catalog_coverage(validation, manual, experimental=False)
1✔
448

449
        # 1 INFO for coverage stat, 1 WARNING for missing parts
450
        assert validation.info_count == 1
1✔
451
        assert validation.warning_count == 1
1✔
452
        assert any(i.rule == "missing_from_catalog" for i in validation.issues)
1✔
453
        missing_issue = next(
1✔
454
            i for i in validation.issues if i.rule == "missing_from_catalog"
455
        )
456
        assert missing_issue.severity == ValidationSeverity.WARNING
1✔
457
        assert "[EXPERIMENTAL]" not in missing_issue.message
1✔
458

459
    def test_zero_coverage(self) -> None:
1✔
460
        """Test zero coverage (should not warn, assumes no image reuse)."""
461
        manual = self._make_manual(["img1"], ["img2"])
1✔
462
        validation = ValidationResult()
1✔
463
        validate_catalog_coverage(validation, manual)
1✔
464

465
        # Only stats info, no warning because coverage is 0% (implies different images used)
466
        assert validation.info_count == 1
1✔
467
        assert validation.warning_count == 0
1✔
468
        assert "0.0%" in validation.issues[0].message
1✔
469

470

471
class TestValidateStepsHaveParts:
1✔
472
    """Tests for validate_steps_have_parts rule."""
473

474
    def test_all_steps_have_parts(self) -> None:
1✔
475
        """Test when all steps have parts."""
476
        validation = ValidationResult()
1✔
477
        validate_steps_have_parts(validation, [])
1✔
478
        assert not validation.has_issues()
1✔
479

480
    def test_some_steps_missing_parts(self) -> None:
1✔
481
        """Test some steps missing parts."""
482
        validation = ValidationResult()
1✔
483
        # (page, step_number) tuples
484
        validate_steps_have_parts(validation, [(1, 1), (3, 5), (5, 10)])
1✔
485
        assert validation.info_count == 1
1✔
486
        issue = validation.issues[0]
1✔
487
        assert issue.rule == "steps_without_parts"
1✔
488
        assert issue.pages == [1, 3, 5]
1✔
489
        assert issue.details is not None
1✔
490
        assert "step 1 (p.1)" in issue.details
1✔
491
        assert "step 5 (p.3)" in issue.details
1✔
492
        assert "step 10 (p.5)" in issue.details
1✔
493

494

495
def _make_page_data(page_num: int) -> PageData:
1✔
496
    """Create a minimal PageData for testing."""
497
    return PageData(
1✔
498
        page_number=page_num,
499
        bbox=BBox(0, 0, 100, 100),
500
        blocks=[],
501
    )
502

503

504
def _make_classification_result(
1✔
505
    page_data: PageData,
506
    page_number_val: int | None = None,
507
    step_numbers: list[int] | None = None,
508
    include_parts: bool = True,
509
) -> ClassificationResult:
510
    """Create a ClassificationResult with a Page for testing.
511

512
    Args:
513
        page_data: The PageData to associate
514
        page_number_val: The LEGO page number value (None for no page number)
515
        step_numbers: List of step numbers to include
516
        include_parts: Whether to include parts lists in steps
517
    """
518
    result = ClassificationResult(page_data=page_data)
1✔
519

520
    # Build the Page object
521
    page_num_elem = (
1✔
522
        PageNumber(bbox=BBox(0, 90, 10, 100), value=page_number_val)
523
        if page_number_val is not None
524
        else None
525
    )
526

527
    step_elems: list[Step] = []
1✔
528
    if step_numbers:
1✔
529
        for step_num in step_numbers:
1✔
530
            parts_list = None
1✔
531
            if include_parts:
1✔
532
                parts_list = PartsList(
1✔
533
                    bbox=BBox(0, 0, 20, 10),
534
                    parts=[
535
                        Part(
536
                            bbox=BBox(0, 0, 10, 10),
537
                            count=PartCount(bbox=BBox(0, 0, 5, 5), count=1),
538
                        )
539
                    ],
540
                )
541
            step_elems.append(
1✔
542
                Step(
543
                    bbox=BBox(0, 0, 80, 80),
544
                    step_number=StepNumber(bbox=BBox(0, 10, 10, 20), value=step_num),
545
                    parts_list=parts_list,
546
                )
547
            )
548

549
    page = Page(
1✔
550
        bbox=BBox(0, 0, 100, 100),
551
        pdf_page_number=page_data.page_number,
552
        page_number=page_num_elem,
553
        steps=step_elems,
554
    )
555

556
    # Add a candidate for the page
557
    candidate = Candidate(
1✔
558
        label="page",
559
        source_blocks=[],
560
        bbox=page.bbox,
561
        score=1.0,
562
        score_details=TestScore(),
563
        constructed=page,
564
    )
565
    result.add_candidate(candidate)
1✔
566

567
    return result
1✔
568

569

570
class TestValidateResults:
1✔
571
    """Tests for the main validate_results function."""
572

573
    def test_perfect_document(self) -> None:
1✔
574
        """Test document with no issues."""
575
        pages = [_make_page_data(i) for i in range(1, 4)]
1✔
576
        results = [
1✔
577
            _make_classification_result(pages[0], page_number_val=1, step_numbers=[1]),
578
            _make_classification_result(pages[1], page_number_val=2, step_numbers=[2]),
579
            _make_classification_result(pages[2], page_number_val=3, step_numbers=[3]),
580
        ]
581
        batch_result = BatchClassificationResult(
1✔
582
            results=results, histogram=TextHistogram.empty()
583
        )
584

585
        validation = validate_results(batch_result)
1✔
586
        # No errors or warnings expected
587
        assert validation.error_count == 0
1✔
588
        assert validation.warning_count == 0
1✔
589

590
    def test_missing_page_numbers(self) -> None:
1✔
591
        """Test detection of missing page numbers."""
592
        pages = [_make_page_data(i) for i in range(1, 4)]
1✔
593
        results = [
1✔
594
            _make_classification_result(
595
                pages[0], page_number_val=None, step_numbers=[1]
596
            ),
597
            _make_classification_result(pages[1], page_number_val=2, step_numbers=[2]),
598
            _make_classification_result(
599
                pages[2], page_number_val=None, step_numbers=[3]
600
            ),
601
        ]
602
        batch_result = BatchClassificationResult(
1✔
603
            results=results, histogram=TextHistogram.empty()
604
        )
605

606
        validation = validate_results(batch_result)
1✔
607
        assert any(i.rule == "missing_page_numbers" for i in validation.issues)
1✔
608

609
    def test_step_sequence_issues(self) -> None:
1✔
610
        """Test detection of step sequence issues."""
611
        pages = [_make_page_data(i) for i in range(1, 4)]
1✔
612
        results = [
1✔
613
            _make_classification_result(pages[0], page_number_val=1, step_numbers=[1]),
614
            _make_classification_result(
615
                pages[1], page_number_val=2, step_numbers=[3]
616
            ),  # Skipped step 2
617
            _make_classification_result(pages[2], page_number_val=3, step_numbers=[4]),
618
        ]
619
        batch_result = BatchClassificationResult(
1✔
620
            results=results, histogram=TextHistogram.empty()
621
        )
622

623
        validation = validate_results(batch_result)
1✔
624
        assert any(i.rule == "step_gaps" for i in validation.issues)
1✔
625

626

627
class TestPrintValidation:
1✔
628
    """Tests for print_validation function."""
629

630
    def test_print_no_issues(self, capsys: object) -> None:
1✔
631
        """Test printing when no issues."""
632
        validation = ValidationResult()
1✔
633
        print_validation(validation)
1✔
634
        # Check output contains success message
635
        captured = capsys.readouterr()  # type: ignore[union-attr]
1✔
636
        assert "passed" in captured.out
1✔
637

638
    def test_print_with_issues(self, capsys: object) -> None:
1✔
639
        """Test printing with various issues."""
640
        validation = ValidationResult()
1✔
641
        validation.add(
1✔
642
            ValidationIssue(
643
                severity=ValidationSeverity.ERROR,
644
                rule="test_error",
645
                message="Test error message",
646
                pages=[1, 2, 3],
647
            )
648
        )
649
        validation.add(
1✔
650
            ValidationIssue(
651
                severity=ValidationSeverity.WARNING,
652
                rule="test_warning",
653
                message="Test warning message",
654
                details="Some details",
655
            )
656
        )
657

658
        print_validation(validation, use_color=False)
1✔
659
        captured = capsys.readouterr()  # type: ignore[union-attr]
1✔
660

661
        assert "test_error" in captured.out
1✔
662
        assert "Test error message" in captured.out
1✔
663
        assert "test_warning" in captured.out
1✔
664
        assert "Some details" in captured.out
1✔
665

666

667
# =============================================================================
668
# Domain Invariant Validation Rules Tests
669
# =============================================================================
670

671

672
def _make_page_with_steps(
1✔
673
    step_data: list[tuple[int, BBox, BBox | None]],  # (step_num, step_bbox, pl_bbox)
674
    page_number_val: int = 1,
675
    page_bbox: BBox | None = None,
676
) -> tuple[Page, PageData]:
677
    """Create a Page with steps for testing domain invariants.
678

679
    Args:
680
        step_data: List of (step_number, step_bbox, parts_list_bbox) tuples.
681
            If parts_list_bbox is None, no parts list is added.
682
        page_number_val: The page number value
683
        page_bbox: The page bounding box (default 0,0,100,100)
684

685
    Returns:
686
        Tuple of (Page, PageData)
687
    """
688
    if page_bbox is None:
1✔
689
        page_bbox = BBox(0, 0, 100, 100)
1✔
690

691
    page_data = PageData(
1✔
692
        page_number=1,
693
        bbox=page_bbox,
694
        blocks=[],
695
    )
696

697
    steps = []
1✔
698
    for step_num, step_bbox, pl_bbox in step_data:
1✔
699
        parts_list = None
1✔
700
        if pl_bbox is not None:
1✔
701
            # Create a parts list with one part
702
            part = Part(
1✔
703
                bbox=BBox(pl_bbox.x0, pl_bbox.y0, pl_bbox.x1, pl_bbox.y1 - 5),
704
                count=PartCount(
705
                    bbox=BBox(pl_bbox.x0, pl_bbox.y1 - 5, pl_bbox.x1, pl_bbox.y1),
706
                    count=1,
707
                ),
708
            )
709
            parts_list = PartsList(bbox=pl_bbox, parts=[part])
1✔
710

711
        step = Step(
1✔
712
            bbox=step_bbox,
713
            step_number=StepNumber(
714
                bbox=BBox(
715
                    step_bbox.x0, step_bbox.y0, step_bbox.x0 + 10, step_bbox.y0 + 10
716
                ),
717
                value=step_num,
718
            ),
719
            parts_list=parts_list,
720
        )
721
        steps.append(step)
1✔
722

723
    page = Page(
1✔
724
        bbox=page_bbox,
725
        pdf_page_number=1,
726
        page_number=PageNumber(bbox=BBox(90, 90, 100, 100), value=page_number_val),
727
        steps=steps,
728
    )
729

730
    return page, page_data
1✔
731

732

733
class TestValidatePartsListHasParts:
1✔
734
    """Tests for validate_parts_list_has_parts rule."""
735

736
    def test_no_empty_parts_lists(self) -> None:
1✔
737
        """Test page with all parts lists having parts."""
738
        page, page_data = _make_page_with_steps(
1✔
739
            [
740
                (1, BBox(0, 0, 50, 50), BBox(40, 0, 50, 20)),
741
            ]
742
        )
743
        validation = ValidationResult()
1✔
744
        validate_parts_list_has_parts(validation, page, page_data)
1✔
745
        assert not validation.has_issues()
1✔
746

747
    def test_empty_parts_list(self) -> None:
1✔
748
        """Test detection of empty parts list."""
749
        page, page_data = _make_page_with_steps(
1✔
750
            [
751
                (1, BBox(0, 0, 50, 50), BBox(40, 0, 50, 20)),
752
            ]
753
        )
754
        # Manually empty the parts list
755
        page.steps[0].parts_list.parts = []  # type: ignore[union-attr]
1✔
756

757
        validation = ValidationResult()
1✔
758
        validate_parts_list_has_parts(validation, page, page_data)
1✔
759
        assert validation.warning_count == 1
1✔
760
        assert validation.issues[0].rule == "empty_parts_list"
1✔
761

762

763
class TestValidatePartsListsNoOverlap:
1✔
764
    """Tests for validate_parts_lists_no_overlap rule."""
765

766
    def test_non_overlapping_parts_lists(self) -> None:
1✔
767
        """Test page with non-overlapping parts lists."""
768
        page, page_data = _make_page_with_steps(
1✔
769
            [
770
                (1, BBox(0, 0, 45, 50), BBox(35, 0, 45, 20)),
771
                (2, BBox(55, 0, 100, 50), BBox(90, 0, 100, 20)),
772
            ]
773
        )
774
        validation = ValidationResult()
1✔
775
        validate_parts_lists_no_overlap(validation, page, page_data)
1✔
776
        assert not validation.has_issues()
1✔
777

778
    def test_overlapping_parts_lists(self) -> None:
1✔
779
        """Test detection of overlapping parts lists."""
780
        page, page_data = _make_page_with_steps(
1✔
781
            [
782
                (1, BBox(0, 0, 60, 50), BBox(40, 0, 60, 20)),
783
                (2, BBox(40, 0, 100, 50), BBox(40, 0, 60, 20)),  # Same bbox!
784
            ]
785
        )
786
        validation = ValidationResult()
1✔
787
        validate_parts_lists_no_overlap(validation, page, page_data)
1✔
788
        assert validation.error_count == 1
1✔
789
        assert validation.issues[0].rule == "overlapping_parts_lists"
1✔
790

791

792
class TestValidateStepsNoSignificantOverlap:
1✔
793
    """Tests for validate_steps_no_significant_overlap rule."""
794

795
    def test_non_overlapping_steps(self) -> None:
1✔
796
        """Test page with non-overlapping steps."""
797
        page, page_data = _make_page_with_steps(
1✔
798
            [
799
                (1, BBox(0, 0, 45, 50), None),
800
                (2, BBox(55, 0, 100, 50), None),
801
            ]
802
        )
803
        validation = ValidationResult()
1✔
804
        validate_steps_no_significant_overlap(validation, page, page_data)
1✔
805
        assert not validation.has_issues()
1✔
806

807
    def test_significantly_overlapping_steps(self) -> None:
1✔
808
        """Test detection of significantly overlapping steps."""
809
        page, page_data = _make_page_with_steps(
1✔
810
            [
811
                (1, BBox(0, 0, 80, 50), None),
812
                (2, BBox(20, 0, 100, 50), None),  # 60% overlap
813
            ]
814
        )
815
        validation = ValidationResult()
1✔
816
        validate_steps_no_significant_overlap(
1✔
817
            validation, page, page_data, overlap_threshold=0.05
818
        )
819
        assert validation.warning_count == 1
1✔
820
        assert validation.issues[0].rule == "overlapping_steps"
1✔
821

822
    def test_minor_overlap_allowed(self) -> None:
1✔
823
        """Test that minor overlap below threshold is allowed."""
824
        page, page_data = _make_page_with_steps(
1✔
825
            [
826
                (1, BBox(0, 0, 51, 50), None),
827
                (2, BBox(50, 0, 100, 50), None),  # 1px overlap
828
            ]
829
        )
830
        validation = ValidationResult()
1✔
831
        validate_steps_no_significant_overlap(
1✔
832
            validation, page, page_data, overlap_threshold=0.05
833
        )
834
        assert not validation.has_issues()
1✔
835

836

837
class TestValidateElementsWithinPage:
1✔
838
    """Tests for validate_elements_within_page rule."""
839

840
    def test_elements_within_bounds(self) -> None:
1✔
841
        """Test page with all elements within bounds."""
842
        page, page_data = _make_page_with_steps(
1✔
843
            [
844
                (1, BBox(10, 10, 90, 90), BBox(70, 10, 90, 30)),
845
            ]
846
        )
847
        validation = ValidationResult()
1✔
848
        validate_elements_within_page(validation, page, page_data)
1✔
849
        assert not validation.has_issues()
1✔
850

851
    def test_element_outside_bounds(self) -> None:
1✔
852
        """Test detection of element outside page bounds."""
853
        page, page_data = _make_page_with_steps(
1✔
854
            [
855
                (1, BBox(10, 10, 110, 90), None),  # Extends past right edge
856
            ]
857
        )
858
        validation = ValidationResult()
1✔
859
        validate_elements_within_page(validation, page, page_data)
1✔
860
        assert validation.error_count >= 1
1✔
861
        assert any(i.rule == "element_outside_page" for i in validation.issues)
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc