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

bramp / build-along / 20379241271

19 Dec 2025 06:37PM UTC coverage: 89.145% (+0.008%) from 89.137%
20379241271

push

github

bramp
fix(drawing): prevent text clipping for bounding box labels at bottom of page

When a bounding box is near the bottom of the page, the label text drawn below it could be clipped. This change moves the text inside the bounding box (aligned to the bottom) if it would otherwise be drawn off-screen.

20 of 20 new or added lines in 2 files covered. (100.0%)

89 existing lines in 7 files now uncovered.

13033 of 14620 relevant lines covered (89.15%)

0.89 hits per line

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

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

3
from typing import Any
1✔
4

5
import pydantic
1✔
6

7
from build_a_long.pdf_extract.classifier import (
1✔
8
    BatchClassificationResult,
9
    Candidate,
10
    ClassificationResult,
11
    TextHistogram,
12
)
13
from build_a_long.pdf_extract.classifier.test_utils import TestScore
1✔
14
from build_a_long.pdf_extract.extractor import PageData
1✔
15
from build_a_long.pdf_extract.extractor.bbox import BBox
1✔
16
from build_a_long.pdf_extract.extractor.lego_page_elements import (
1✔
17
    Background,
18
    CatalogContent,
19
    Divider,
20
    InstructionContent,
21
    Manual,
22
    Page,
23
    PageNumber,
24
    Part,
25
    PartCount,
26
    PartImage,
27
    PartsList,
28
    ProgressBar,
29
    Step,
30
    StepNumber,
31
)
32

33
from .printer import print_validation
1✔
34
from .rules import (
1✔
35
    format_ranges,
36
    validate_catalog_coverage,
37
    validate_elements_within_page,
38
    validate_first_page_number,
39
    validate_missing_page_numbers,
40
    validate_no_divider_intersection,
41
    validate_page_number_sequence,
42
    validate_parts_list_has_parts,
43
    validate_parts_lists_no_overlap,
44
    validate_progress_bar_sequence,
45
    validate_step_sequence,
46
    validate_steps_have_parts,
47
    validate_steps_no_significant_overlap,
48
)
49
from .runner import validate_results
1✔
50
from .types import ValidationIssue, ValidationResult, ValidationSeverity
1✔
51

52

53
class TestValidationResult:
1✔
54
    """Tests for ValidationResult class."""
55

56
    def test_empty_result(self) -> None:
1✔
57
        """Test empty validation result."""
58
        result = ValidationResult()
1✔
59
        assert result.error_count == 0
1✔
60
        assert result.warning_count == 0
1✔
61
        assert result.info_count == 0
1✔
62
        assert not result.has_issues()
1✔
63

64
    def test_add_issue(self) -> None:
1✔
65
        """Test adding issues to result."""
66
        result = ValidationResult()
1✔
67
        result.add(
1✔
68
            ValidationIssue(
69
                severity=ValidationSeverity.ERROR,
70
                rule="test",
71
                message="test error",
72
            )
73
        )
74
        result.add(
1✔
75
            ValidationIssue(
76
                severity=ValidationSeverity.WARNING,
77
                rule="test",
78
                message="test warning",
79
            )
80
        )
81
        result.add(
1✔
82
            ValidationIssue(
83
                severity=ValidationSeverity.INFO,
84
                rule="test",
85
                message="test info",
86
            )
87
        )
88

89
        assert result.error_count == 1
1✔
90
        assert result.warning_count == 1
1✔
91
        assert result.info_count == 1
1✔
92
        assert result.has_issues()
1✔
93

94
    def test_frozen_issue(self) -> None:
1✔
95
        """Test that ValidationIssue is immutable."""
96

97
        issue = ValidationIssue(
1✔
98
            severity=ValidationSeverity.ERROR,
99
            rule="test",
100
            message="test",
101
        )
102
        # Should not be able to modify (Pydantic frozen model raises ValidationError)
103
        try:
1✔
104
            issue.message = "new message"  # type: ignore[misc]
1✔
UNCOV
105
            raise AssertionError("Expected frozen model to raise")
×
106
        except pydantic.ValidationError:
1✔
107
            pass  # Expected
1✔
108

109

110
class TestFormatRanges:
1✔
111
    """Tests for format_ranges helper function."""
112

113
    def test_empty_list(self) -> None:
1✔
114
        """Test empty list."""
115
        assert format_ranges([]) == ""
1✔
116

117
    def test_single_number(self) -> None:
1✔
118
        """Test single number."""
119
        assert format_ranges([5]) == "5"
1✔
120

121
    def test_consecutive_range(self) -> None:
1✔
122
        """Test consecutive numbers form a range."""
123
        assert format_ranges([1, 2, 3, 4, 5]) == "1-5"
1✔
124

125
    def test_separate_numbers(self) -> None:
1✔
126
        """Test non-consecutive numbers."""
127
        assert format_ranges([1, 3, 5]) == "1, 3, 5"
1✔
128

129
    def test_mixed_ranges(self) -> None:
1✔
130
        """Test mixed ranges and single numbers."""
131
        assert format_ranges([1, 2, 3, 5, 7, 8, 9]) == "1-3, 5, 7-9"
1✔
132

133
    def test_long_list_truncation(self) -> None:
1✔
134
        """Test that very long output is truncated."""
135
        # Create a list that would produce a very long string
136
        numbers = list(range(1, 200, 2))  # Odd numbers 1-199
1✔
137
        result = format_ranges(numbers)
1✔
138
        assert len(result) <= 100
1✔
139
        assert result.endswith("...")
1✔
140

141

142
class TestValidateMissingPageNumbers:
1✔
143
    """Tests for validate_missing_page_numbers rule."""
144

145
    def test_no_missing_pages(self) -> None:
1✔
146
        """Test when all pages have page numbers."""
147
        validation = ValidationResult()
1✔
148
        validate_missing_page_numbers(validation, [], 10)
1✔
149
        assert not validation.has_issues()
1✔
150

151
    def test_high_coverage(self) -> None:
1✔
152
        """Test >90% coverage produces INFO."""
153
        validation = ValidationResult()
1✔
154
        validate_missing_page_numbers(validation, [1], 20)  # 95% coverage
1✔
155
        assert validation.info_count == 1
1✔
156
        assert validation.issues[0].severity == ValidationSeverity.INFO
1✔
157

158
    def test_medium_coverage(self) -> None:
1✔
159
        """Test 50-90% coverage produces WARNING."""
160
        validation = ValidationResult()
1✔
161
        validate_missing_page_numbers(validation, [1, 2, 3], 10)  # 70% coverage
1✔
162
        assert validation.warning_count == 1
1✔
163

164
    def test_low_coverage(self) -> None:
1✔
165
        """Test <50% coverage produces ERROR."""
166
        validation = ValidationResult()
1✔
167
        validate_missing_page_numbers(validation, list(range(1, 8)), 10)  # 30% coverage
1✔
168
        assert validation.error_count == 1
1✔
169

170

171
class TestValidateStepSequence:
1✔
172
    """Tests for validate_step_sequence rule."""
173

174
    def test_empty_steps(self) -> None:
1✔
175
        """Test empty step list."""
176
        validation = ValidationResult()
1✔
177
        validate_step_sequence(validation, [])
1✔
178
        assert not validation.has_issues()
1✔
179

180
    def test_valid_sequence(self) -> None:
1✔
181
        """Test valid step sequence starting at 1."""
182
        validation = ValidationResult()
1✔
183
        validate_step_sequence(validation, [(1, 1), (2, 2), (3, 3)])
1✔
184
        assert not validation.has_issues()
1✔
185

186
    def test_duplicate_steps(self) -> None:
1✔
187
        """Test duplicate step numbers."""
188
        validation = ValidationResult()
1✔
189
        validate_step_sequence(validation, [(1, 1), (2, 1), (3, 2)])  # Step 1 twice
1✔
190
        # Should have warning about duplicates
191
        assert any(i.rule == "duplicate_steps" for i in validation.issues)
1✔
192

193
    def test_step_gaps(self) -> None:
1✔
194
        """Test gaps in step sequence."""
195
        validation = ValidationResult()
1✔
196
        validate_step_sequence(validation, [(1, 1), (2, 3)])  # Missing step 2
1✔
197
        assert any(i.rule == "step_gaps" for i in validation.issues)
1✔
198

199
    def test_step_not_starting_at_one(self) -> None:
1✔
200
        """Test sequence not starting at 1."""
201
        validation = ValidationResult()
1✔
202
        validate_step_sequence(validation, [(1, 5), (2, 6), (3, 7)])  # Starts at 5
1✔
203
        assert any(i.rule == "step_start" for i in validation.issues)
1✔
204

205

206
class TestValidateFirstPageNumber:
1✔
207
    """Tests for validate_first_page_number rule."""
208

209
    def test_no_page_numbers(self) -> None:
1✔
210
        """Test when no page numbers detected."""
211
        validation = ValidationResult()
1✔
212
        validate_first_page_number(validation, [])
1✔
213
        assert validation.error_count == 1
1✔
214
        assert validation.issues[0].rule == "no_page_numbers"
1✔
215

216
    def test_reasonable_first_page(self) -> None:
1✔
217
        """Test reasonable first page number."""
218
        validation = ValidationResult()
1✔
219
        validate_first_page_number(validation, [1, 2, 3])
1✔
220
        assert not validation.has_issues()
1✔
221

222
    def test_high_first_page(self) -> None:
1✔
223
        """Test high first page number."""
224
        validation = ValidationResult()
1✔
225
        validate_first_page_number(validation, [15, 16, 17])
1✔
226
        assert any(i.rule == "high_first_page" for i in validation.issues)
1✔
227

228

229
class TestValidatePageNumberSequence:
1✔
230
    """Tests for validate_page_number_sequence rule."""
231

232
    def test_single_page(self) -> None:
1✔
233
        """Test single page number."""
234
        validation = ValidationResult()
1✔
235
        validate_page_number_sequence(validation, [1])
1✔
236
        assert not validation.has_issues()
1✔
237

238
    def test_valid_sequence(self) -> None:
1✔
239
        """Test valid consecutive sequence."""
240
        validation = ValidationResult()
1✔
241
        validate_page_number_sequence(validation, [1, 2, 3, 4, 5])
1✔
242
        assert not validation.has_issues()
1✔
243

244
    def test_valid_sequence_starting_later(self) -> None:
1✔
245
        """Test valid consecutive sequence that doesn't start at 1.
246

247
        First few pages missing is OK (e.g., cover pages without page numbers).
248
        """
249
        validation = ValidationResult()
1✔
250
        validate_page_number_sequence(validation, [5, 6, 7, 8, 9])
1✔
251
        assert not validation.has_issues()
1✔
252

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

256
        Last few pages missing is OK (e.g., back cover without page numbers).
257
        This tests the sequence is consecutive - we don't know total pages here.
258
        """
259
        validation = ValidationResult()
1✔
260
        # Sequence 10-14 is consecutive, even if there could be more pages
261
        validate_page_number_sequence(validation, [10, 11, 12, 13, 14])
1✔
262
        assert not validation.has_issues()
1✔
263

264
    def test_valid_sequence_starting_later_and_ending_early(self) -> None:
1✔
265
        """Test consecutive sequence with both start and end pages missing.
266

267
        Both first N and last M pages can be missing, as long as there are no
268
        gaps in the middle.
269
        """
270
        validation = ValidationResult()
1✔
271
        validate_page_number_sequence(validation, [5, 6, 7, 8, 9, 10])
1✔
272
        assert not validation.has_issues()
1✔
273

274
    def test_decreasing_sequence(self) -> None:
1✔
275
        """Test decreasing page numbers."""
276
        validation = ValidationResult()
1✔
277
        validate_page_number_sequence(validation, [1, 2, 5, 3, 4])  # Decreases at 3
1✔
278
        assert any(i.rule == "page_sequence" for i in validation.issues)
1✔
279

280
    def test_gap_in_middle(self) -> None:
1✔
281
        """Test gap in the middle of page numbers."""
282
        validation = ValidationResult()
1✔
283
        validate_page_number_sequence(validation, [1, 2, 5, 6])  # Gap: 2->5
1✔
284
        assert any(i.rule == "page_gaps" for i in validation.issues)
1✔
285
        # Should be a warning now
286
        gap_issue = next(i for i in validation.issues if i.rule == "page_gaps")
1✔
287
        assert gap_issue.severity == ValidationSeverity.WARNING
1✔
288

289
    def test_small_gap_not_allowed(self) -> None:
1✔
290
        """Test that even small gaps (>1) are flagged."""
291
        validation = ValidationResult()
1✔
292
        validate_page_number_sequence(validation, [1, 2, 4, 5])  # Gap: 2->4
1✔
293
        assert any(i.rule == "page_gaps" for i in validation.issues)
1✔
294

295

296
class TestValidateProgressBarSequence:
1✔
297
    """Tests for validate_progress_bar_sequence rule."""
298

299
    def test_empty_progress_bars(self) -> None:
1✔
300
        """Test empty progress bar list."""
301
        validation = ValidationResult()
1✔
302
        validate_progress_bar_sequence(validation, [])
1✔
303
        assert not validation.has_issues()
1✔
304

305
    def test_valid_sequence(self) -> None:
1✔
306
        """Test valid monotonically increasing sequence."""
307
        validation = ValidationResult()
1✔
308
        # (page, value) tuples
309
        validate_progress_bar_sequence(
1✔
310
            validation, [(1, 0.1), (2, 0.2), (3, 0.3), (4, 0.4)]
311
        )
312
        assert not validation.has_issues()
1✔
313

314
    def test_decreasing_sequence(self) -> None:
1✔
315
        """Test decreasing progress bar values."""
316
        validation = ValidationResult()
1✔
317
        validate_progress_bar_sequence(
1✔
318
            validation,
319
            [(1, 0.5), (2, 0.4), (3, 0.6)],  # Decreases at p.2
320
        )
321
        assert validation.warning_count == 1
1✔
322
        assert validation.issues[0].rule == "progress_bar_decrease"
1✔
323

324
    def test_consistent_increments(self) -> None:
1✔
325
        """Test consistent progress increments (steady rate)."""
326
        validation = ValidationResult()
1✔
327
        # Constant 0.1 increment
328
        validate_progress_bar_sequence(
1✔
329
            validation,
330
            [(1, 0.1), (2, 0.2), (3, 0.3), (4, 0.4), (5, 0.5), (6, 0.6)],
331
        )
332
        assert not validation.has_issues()
1✔
333

334
    def test_inconsistent_increments(self) -> None:
1✔
335
        """Test inconsistent progress increments (high variance)."""
336
        validation = ValidationResult()
1✔
337
        # Increments vary wildly: 0.01, 0.4, 0.01, 0.01, 0.01
338
        validate_progress_bar_sequence(
1✔
339
            validation,
340
            [(1, 0.1), (2, 0.11), (3, 0.51), (4, 0.52), (5, 0.53), (6, 0.54)],
341
        )
342
        assert any(i.rule == "progress_bar_inconsistent" for i in validation.issues)
1✔
343
        issue = next(
1✔
344
            i for i in validation.issues if i.rule == "progress_bar_inconsistent"
345
        )
346
        assert issue.severity == ValidationSeverity.INFO
1✔
347

348
    def test_not_enough_samples(self) -> None:
1✔
349
        """Test that consistency check is skipped for few samples."""
350
        validation = ValidationResult()
1✔
351
        # Highly inconsistent, but only 5 samples (needs >5)
352
        validate_progress_bar_sequence(
1✔
353
            validation,
354
            [(1, 0.1), (2, 0.11), (3, 0.51), (4, 0.52), (5, 0.53)],
355
        )
356
        # Should be ignored because there are fewer than 5 samples
357
        assert not validation.has_issues()
1✔
358

359

360
class TestValidateCatalogCoverage:
1✔
361
    """Tests for validate_catalog_coverage rule."""
362

363
    def _make_part_with_image(
1✔
364
        self,
365
        image_id: str | None = None,
366
        xref: int | None = None,
367
        digest: bytes | None = None,
368
    ) -> Part:
369
        """Create a Part with a diagram image ID, xref, and/or digest."""
370

371
        return Part(
1✔
372
            bbox=BBox(0, 0, 10, 10),
373
            count=PartCount(bbox=BBox(0, 0, 5, 5), count=1),
374
            diagram=PartImage(
375
                bbox=BBox(0, 0, 10, 10),
376
                image_id=image_id,
377
                xref=xref,
378
                digest=digest,
379
            ),
380
        )
381

382
    def _make_manual(
1✔
383
        self,
384
        instruction_parts_config: list[dict[str, Any]],
385
        catalog_parts_config: list[dict[str, Any]],
386
    ) -> Manual:
387
        """Create a Manual with specified parts.
388

389
        Args:
390
            instruction_parts_config: List of dicts with keys 'image_id', 'xref',
391
                'digest'
392
            catalog_parts_config: List of dicts with keys 'image_id', 'xref',
393
                'digest'
394
        """
395
        pages = []
1✔
396

397
        # Instruction page
398
        if instruction_parts_config:
1✔
399
            parts = [
1✔
400
                self._make_part_with_image(**cfg) for cfg in instruction_parts_config
401
            ]
402
            step = Step(
1✔
403
                bbox=BBox(0, 0, 100, 100),
404
                step_number=StepNumber(bbox=BBox(0, 0, 10, 10), value=1),
405
                parts_list=PartsList(bbox=BBox(0, 0, 50, 50), parts=parts),
406
            )
407
            pages.append(
1✔
408
                Page(
409
                    bbox=BBox(0, 0, 100, 100),
410
                    pdf_page_number=1,
411
                    page_number=PageNumber(bbox=BBox(90, 90, 100, 100), value=1),
412
                    categories={Page.PageType.INSTRUCTION},
413
                    instruction=InstructionContent(steps=[step]),
414
                )
415
            )
416

417
        # Catalog page
418
        if catalog_parts_config:
1✔
419
            parts = [self._make_part_with_image(**cfg) for cfg in catalog_parts_config]
1✔
420
            pages.append(
1✔
421
                Page(
422
                    bbox=BBox(0, 0, 100, 100),
423
                    pdf_page_number=2,
424
                    page_number=PageNumber(bbox=BBox(90, 90, 100, 100), value=2),
425
                    categories={Page.PageType.CATALOG},
426
                    catalog=CatalogContent(parts=parts),
427
                )
428
            )
429

430
        return Manual(pages=pages)
1✔
431

432
    def test_no_catalog_pages(self) -> None:
1✔
433
        """Test when no catalog pages are present."""
434
        manual = self._make_manual([{"xref": 1}], [])
1✔
435
        validation = ValidationResult()
1✔
436
        validate_catalog_coverage(validation, manual)
1✔
437
        assert not validation.has_issues()
1✔
438

439
    def test_no_instruction_parts(self) -> None:
1✔
440
        """Test when no instruction parts are found."""
441
        manual = self._make_manual([], [{"xref": 1}])
1✔
442
        validation = ValidationResult()
1✔
443
        validate_catalog_coverage(validation, manual)
1✔
444
        assert not validation.has_issues()
1✔
445

446
    def test_perfect_coverage_xref(self) -> None:
1✔
447
        """Test when all instruction parts are in catalog using xref."""
448
        manual = self._make_manual(
1✔
449
            [{"xref": 1}, {"xref": 2}],
450
            [{"xref": 1}, {"xref": 2}, {"xref": 3}],
451
        )
452
        validation = ValidationResult()
1✔
453
        validate_catalog_coverage(validation, manual)
1✔
454
        assert validation.info_count == 1
1✔
455
        assert "100.0%" in validation.issues[0].message
1✔
456

457
    def test_perfect_coverage_digest(self) -> None:
1✔
458
        """Test when all instruction parts are in catalog using digest."""
459
        manual = self._make_manual(
1✔
460
            [{"digest": b"a"}, {"digest": b"b"}],
461
            [{"digest": b"a"}, {"digest": b"b"}, {"digest": b"c"}],
462
        )
463
        validation = ValidationResult()
1✔
464
        validate_catalog_coverage(validation, manual)
1✔
465
        assert validation.info_count == 1
1✔
466
        assert "100.0%" in validation.issues[0].message
1✔
467

468
    def test_mixed_matching(self) -> None:
1✔
469
        """Test matching using both xref and digest."""
470
        manual = self._make_manual(
1✔
471
            [
472
                {"xref": 1},  # Matches by xref
473
                {"digest": b"b"},  # Matches by digest
474
                {"xref": 3, "digest": b"c"},  # Matches by xref (preferred)
475
            ],
476
            [
477
                {"xref": 1, "digest": b"x"},
478
                {"xref": 9, "digest": b"b"},
479
                {"xref": 3, "digest": b"z"},
480
            ],
481
        )
482
        validation = ValidationResult()
1✔
483
        validate_catalog_coverage(validation, manual)
1✔
484
        assert validation.info_count == 1
1✔
485
        assert "100.0%" in validation.issues[0].message
1✔
486

487
    def test_partial_coverage_experimental(self) -> None:
1✔
488
        """Test partial coverage with experimental flag (INFO)."""
489
        # 1 match (xref), 1 missing
490
        manual = self._make_manual(
1✔
491
            [{"xref": 1}, {"xref": 2}],
492
            [{"xref": 1}],
493
        )
494
        validation = ValidationResult()
1✔
495
        validate_catalog_coverage(validation, manual, experimental=True)
1✔
496

497
        # 1 INFO for coverage stat, 1 INFO for missing parts (experimental)
498
        assert validation.info_count == 2
1✔
499
        assert validation.warning_count == 0
1✔
500
        assert any(i.rule == "missing_from_catalog" for i in validation.issues)
1✔
501
        missing_issue = next(
1✔
502
            i for i in validation.issues if i.rule == "missing_from_catalog"
503
        )
504
        assert missing_issue.severity == ValidationSeverity.INFO
1✔
505
        assert "[EXPERIMENTAL]" in missing_issue.message
1✔
506
        assert missing_issue.details is not None
1✔
507
        assert "xref:2" in missing_issue.details
1✔
508

509
    def test_partial_coverage_strict(self) -> None:
1✔
510
        """Test partial coverage without experimental flag (WARNING)."""
511
        # 1 match, 1 missing
512
        manual = self._make_manual(
1✔
513
            [{"digest": b"a"}, {"digest": b"b"}],
514
            [{"digest": b"a"}],
515
        )
516
        validation = ValidationResult()
1✔
517
        validate_catalog_coverage(validation, manual, experimental=False)
1✔
518

519
        # 1 INFO for coverage stat, 1 WARNING for missing parts
520
        assert validation.info_count == 1
1✔
521
        assert validation.warning_count == 1
1✔
522
        assert any(i.rule == "missing_from_catalog" for i in validation.issues)
1✔
523
        missing_issue = next(
1✔
524
            i for i in validation.issues if i.rule == "missing_from_catalog"
525
        )
526
        assert missing_issue.severity == ValidationSeverity.WARNING
1✔
527
        assert "[EXPERIMENTAL]" not in missing_issue.message
1✔
528
        assert missing_issue.details is not None
1✔
529
        assert "digest:" in missing_issue.details  # Hex representation of b"b"
1✔
530

531
    def test_zero_coverage(self) -> None:
1✔
532
        """Test zero coverage (should not warn, assumes no image reuse)."""
533
        manual = self._make_manual([{"xref": 1}], [{"xref": 2}])
1✔
534
        validation = ValidationResult()
1✔
535
        validate_catalog_coverage(validation, manual)
1✔
536

537
        # Only stats info, no warning because coverage is 0%
538
        assert validation.info_count == 1
1✔
539
        assert validation.warning_count == 0
1✔
540
        assert "0.0%" in validation.issues[0].message
1✔
541

542

543
class TestValidateStepsHaveParts:
1✔
544
    """Tests for validate_steps_have_parts rule."""
545

546
    def test_all_steps_have_parts(self) -> None:
1✔
547
        """Test when all steps have parts."""
548
        validation = ValidationResult()
1✔
549
        validate_steps_have_parts(validation, [])
1✔
550
        assert not validation.has_issues()
1✔
551

552
    def test_some_steps_missing_parts(self) -> None:
1✔
553
        """Test some steps missing parts."""
554
        validation = ValidationResult()
1✔
555
        # (page, step_number) tuples
556
        validate_steps_have_parts(validation, [(1, 1), (3, 5), (5, 10)])
1✔
557
        assert validation.info_count == 1
1✔
558
        issue = validation.issues[0]
1✔
559
        assert issue.rule == "steps_without_parts"
1✔
560
        assert issue.pages == [1, 3, 5]
1✔
561
        assert issue.details is not None
1✔
562
        assert "step 1 (p.1)" in issue.details
1✔
563
        assert "step 5 (p.3)" in issue.details
1✔
564
        assert "step 10 (p.5)" in issue.details
1✔
565

566

567
def _make_page_data(page_num: int) -> PageData:
1✔
568
    """Create a minimal PageData for testing."""
569
    return PageData(
1✔
570
        page_number=page_num,
571
        bbox=BBox(0, 0, 100, 100),
572
        blocks=[],
573
    )
574

575

576
def _make_classification_result(
1✔
577
    page_data: PageData,
578
    page_number_val: int | None = None,
579
    step_numbers: list[int] | None = None,
580
    include_parts: bool = True,
581
) -> ClassificationResult:
582
    """Create a ClassificationResult with a Page for testing.
583

584
    Args:
585
        page_data: The PageData to associate
586
        page_number_val: The LEGO page number value (None for no page number)
587
        step_numbers: List of step numbers to include
588
        include_parts: Whether to include parts lists in steps
589
    """
590
    result = ClassificationResult(page_data=page_data)
1✔
591

592
    # Build the Page object
593
    page_num_elem = (
1✔
594
        PageNumber(bbox=BBox(0, 90, 10, 100), value=page_number_val)
595
        if page_number_val is not None
596
        else None
597
    )
598

599
    step_elems: list[Step] = []
1✔
600
    if step_numbers:
1✔
601
        for step_num in step_numbers:
1✔
602
            parts_list = None
1✔
603
            if include_parts:
1✔
604
                parts_list = PartsList(
1✔
605
                    bbox=BBox(0, 0, 20, 10),
606
                    parts=[
607
                        Part(
608
                            bbox=BBox(0, 0, 10, 10),
609
                            count=PartCount(bbox=BBox(0, 0, 5, 5), count=1),
610
                        )
611
                    ],
612
                )
613
            step_elems.append(
1✔
614
                Step(
615
                    bbox=BBox(0, 0, 80, 80),
616
                    step_number=StepNumber(bbox=BBox(0, 10, 10, 20), value=step_num),
617
                    parts_list=parts_list,
618
                )
619
            )
620

621
    page = Page(
1✔
622
        bbox=BBox(0, 0, 100, 100),
623
        pdf_page_number=page_data.page_number,
624
        page_number=page_num_elem,
625
        instruction=InstructionContent(steps=step_elems) if step_elems else None,
626
    )
627

628
    # Add a candidate for the page
629
    candidate = Candidate(
1✔
630
        label="page",
631
        source_blocks=[],
632
        bbox=page.bbox,
633
        score=1.0,
634
        score_details=TestScore(),
635
        constructed=page,
636
    )
637
    result.add_candidate(candidate)
1✔
638

639
    return result
1✔
640

641

642
class TestValidateResults:
1✔
643
    """Tests for the main validate_results function."""
644

645
    def test_perfect_document(self) -> None:
1✔
646
        """Test document with no issues."""
647
        pages = [_make_page_data(i) for i in range(1, 4)]
1✔
648
        results = [
1✔
649
            _make_classification_result(pages[0], page_number_val=1, step_numbers=[1]),
650
            _make_classification_result(pages[1], page_number_val=2, step_numbers=[2]),
651
            _make_classification_result(pages[2], page_number_val=3, step_numbers=[3]),
652
        ]
653
        batch_result = BatchClassificationResult(
1✔
654
            results=results, histogram=TextHistogram.empty()
655
        )
656

657
        validation = validate_results(batch_result)
1✔
658
        # No errors or warnings expected
659
        assert validation.error_count == 0
1✔
660
        assert validation.warning_count == 0
1✔
661

662
    def test_missing_page_numbers(self) -> None:
1✔
663
        """Test detection of missing page numbers."""
664
        pages = [_make_page_data(i) for i in range(1, 4)]
1✔
665
        results = [
1✔
666
            _make_classification_result(
667
                pages[0], page_number_val=None, step_numbers=[1]
668
            ),
669
            _make_classification_result(pages[1], page_number_val=2, step_numbers=[2]),
670
            _make_classification_result(
671
                pages[2], page_number_val=None, step_numbers=[3]
672
            ),
673
        ]
674
        batch_result = BatchClassificationResult(
1✔
675
            results=results, histogram=TextHistogram.empty()
676
        )
677

678
        validation = validate_results(batch_result)
1✔
679
        assert any(i.rule == "missing_page_numbers" for i in validation.issues)
1✔
680

681
    def test_step_sequence_issues(self) -> None:
1✔
682
        """Test detection of step sequence issues."""
683
        pages = [_make_page_data(i) for i in range(1, 4)]
1✔
684
        results = [
1✔
685
            _make_classification_result(pages[0], page_number_val=1, step_numbers=[1]),
686
            _make_classification_result(
687
                pages[1], page_number_val=2, step_numbers=[3]
688
            ),  # Skipped step 2
689
            _make_classification_result(pages[2], page_number_val=3, step_numbers=[4]),
690
        ]
691
        batch_result = BatchClassificationResult(
1✔
692
            results=results, histogram=TextHistogram.empty()
693
        )
694

695
        validation = validate_results(batch_result)
1✔
696
        assert any(i.rule == "step_gaps" for i in validation.issues)
1✔
697

698

699
class TestPrintValidation:
1✔
700
    """Tests for print_validation function."""
701

702
    def test_print_no_issues(self, capsys: object) -> None:
1✔
703
        """Test printing when no issues."""
704
        validation = ValidationResult()
1✔
705
        print_validation(validation)
1✔
706
        # Check output contains success message
707
        captured = capsys.readouterr()  # type: ignore[union-attr]
1✔
708
        assert "passed" in captured.out
1✔
709

710
    def test_print_with_issues(self, capsys: object) -> None:
1✔
711
        """Test printing with various issues."""
712
        validation = ValidationResult()
1✔
713
        validation.add(
1✔
714
            ValidationIssue(
715
                severity=ValidationSeverity.ERROR,
716
                rule="test_error",
717
                message="Test error message",
718
                pages=[1, 2, 3],
719
            )
720
        )
721
        validation.add(
1✔
722
            ValidationIssue(
723
                severity=ValidationSeverity.WARNING,
724
                rule="test_warning",
725
                message="Test warning message",
726
                details="Some details",
727
            )
728
        )
729

730
        print_validation(validation, use_color=False)
1✔
731
        captured = capsys.readouterr()  # type: ignore[union-attr]
1✔
732

733
        assert "test_error" in captured.out
1✔
734
        assert "Test error message" in captured.out
1✔
735
        assert "test_warning" in captured.out
1✔
736
        assert "Some details" in captured.out
1✔
737

738

739
# =============================================================================
740
# Domain Invariant Validation Rules Tests
741
# =============================================================================
742

743

744
def _make_page_with_steps(
1✔
745
    step_data: list[tuple[int, BBox, BBox | None]],  # (step_num, step_bbox, pl_bbox)
746
    page_number_val: int = 1,
747
    page_bbox: BBox | None = None,
748
) -> tuple[Page, PageData]:
749
    """Create a Page with steps for testing domain invariants.
750

751
    Args:
752
        step_data: List of (step_number, step_bbox, parts_list_bbox) tuples.
753
            If parts_list_bbox is None, no parts list is added.
754
        page_number_val: The page number value
755
        page_bbox: The page bounding box (default 0,0,100,100)
756

757
    Returns:
758
        Tuple of (Page, PageData)
759
    """
760
    if page_bbox is None:
1✔
761
        page_bbox = BBox(0, 0, 100, 100)
1✔
762

763
    page_data = PageData(
1✔
764
        page_number=1,
765
        bbox=page_bbox,
766
        blocks=[],
767
    )
768

769
    steps = []
1✔
770
    for step_num, step_bbox, pl_bbox in step_data:
1✔
771
        parts_list = None
1✔
772
        if pl_bbox is not None:
1✔
773
            # Create a parts list with one part
774
            part = Part(
1✔
775
                bbox=BBox(pl_bbox.x0, pl_bbox.y0, pl_bbox.x1, pl_bbox.y1 - 5),
776
                count=PartCount(
777
                    bbox=BBox(pl_bbox.x0, pl_bbox.y1 - 5, pl_bbox.x1, pl_bbox.y1),
778
                    count=1,
779
                ),
780
            )
781
            parts_list = PartsList(bbox=pl_bbox, parts=[part])
1✔
782

783
        step = Step(
1✔
784
            bbox=step_bbox,
785
            step_number=StepNumber(
786
                bbox=BBox(
787
                    step_bbox.x0, step_bbox.y0, step_bbox.x0 + 10, step_bbox.y0 + 10
788
                ),
789
                value=step_num,
790
            ),
791
            parts_list=parts_list,
792
        )
793
        steps.append(step)
1✔
794

795
    page = Page(
1✔
796
        bbox=page_bbox,
797
        pdf_page_number=1,
798
        page_number=PageNumber(bbox=BBox(90, 90, 100, 100), value=page_number_val),
799
        instruction=InstructionContent(steps=steps) if steps else None,
800
    )
801

802
    return page, page_data
1✔
803

804

805
class TestValidatePartsListHasParts:
1✔
806
    """Tests for validate_parts_list_has_parts rule."""
807

808
    def test_no_empty_parts_lists(self) -> None:
1✔
809
        """Test page with all parts lists having parts."""
810
        page, page_data = _make_page_with_steps(
1✔
811
            [
812
                (1, BBox(0, 0, 50, 50), BBox(40, 0, 50, 20)),
813
            ]
814
        )
815
        validation = ValidationResult()
1✔
816
        validate_parts_list_has_parts(validation, page, page_data)
1✔
817
        assert not validation.has_issues()
1✔
818

819
    def test_empty_parts_list(self) -> None:
1✔
820
        """Test detection of empty parts list."""
821
        page, page_data = _make_page_with_steps(
1✔
822
            [
823
                (1, BBox(0, 0, 50, 50), BBox(40, 0, 50, 20)),
824
            ]
825
        )
826
        # Manually empty the parts list
827
        assert page.instruction is not None
1✔
828
        page.instruction.steps[0].parts_list.parts = []  # type: ignore[union-attr]
1✔
829

830
        validation = ValidationResult()
1✔
831
        validate_parts_list_has_parts(validation, page, page_data)
1✔
832
        assert validation.warning_count == 1
1✔
833
        assert validation.issues[0].rule == "empty_parts_list"
1✔
834

835

836
class TestValidatePartsListsNoOverlap:
1✔
837
    """Tests for validate_parts_lists_no_overlap rule."""
838

839
    def test_non_overlapping_parts_lists(self) -> None:
1✔
840
        """Test page with non-overlapping parts lists."""
841
        page, page_data = _make_page_with_steps(
1✔
842
            [
843
                (1, BBox(0, 0, 45, 50), BBox(35, 0, 45, 20)),
844
                (2, BBox(55, 0, 100, 50), BBox(90, 0, 100, 20)),
845
            ]
846
        )
847
        validation = ValidationResult()
1✔
848
        validate_parts_lists_no_overlap(validation, page, page_data)
1✔
849
        assert not validation.has_issues()
1✔
850

851
    def test_overlapping_parts_lists(self) -> None:
1✔
852
        """Test detection of overlapping parts lists."""
853
        page, page_data = _make_page_with_steps(
1✔
854
            [
855
                (1, BBox(0, 0, 60, 50), BBox(40, 0, 60, 20)),
856
                (2, BBox(40, 0, 100, 50), BBox(40, 0, 60, 20)),  # Same bbox!
857
            ]
858
        )
859
        validation = ValidationResult()
1✔
860
        validate_parts_lists_no_overlap(validation, page, page_data)
1✔
861
        assert validation.error_count == 1
1✔
862
        assert validation.issues[0].rule == "overlapping_parts_lists"
1✔
863

864

865
class TestValidateStepsNoSignificantOverlap:
1✔
866
    """Tests for validate_steps_no_significant_overlap rule."""
867

868
    def test_non_overlapping_steps(self) -> None:
1✔
869
        """Test page with non-overlapping steps."""
870
        page, page_data = _make_page_with_steps(
1✔
871
            [
872
                (1, BBox(0, 0, 45, 50), None),
873
                (2, BBox(55, 0, 100, 50), None),
874
            ]
875
        )
876
        validation = ValidationResult()
1✔
877
        validate_steps_no_significant_overlap(validation, page, page_data)
1✔
878
        assert not validation.has_issues()
1✔
879

880
    def test_significantly_overlapping_steps(self) -> None:
1✔
881
        """Test detection of significantly overlapping steps."""
882
        page, page_data = _make_page_with_steps(
1✔
883
            [
884
                (1, BBox(0, 0, 80, 50), None),
885
                (2, BBox(20, 0, 100, 50), None),  # 60% overlap
886
            ]
887
        )
888
        validation = ValidationResult()
1✔
889
        validate_steps_no_significant_overlap(
1✔
890
            validation, page, page_data, overlap_threshold=0.05
891
        )
892
        assert validation.warning_count == 1
1✔
893
        assert validation.issues[0].rule == "overlapping_steps"
1✔
894

895
    def test_minor_overlap_allowed(self) -> None:
1✔
896
        """Test that minor overlap below threshold is allowed."""
897
        page, page_data = _make_page_with_steps(
1✔
898
            [
899
                (1, BBox(0, 0, 51, 50), None),
900
                (2, BBox(50, 0, 100, 50), None),  # 1px overlap
901
            ]
902
        )
903
        validation = ValidationResult()
1✔
904
        validate_steps_no_significant_overlap(
1✔
905
            validation, page, page_data, overlap_threshold=0.05
906
        )
907
        assert not validation.has_issues()
1✔
908

909

910
class TestValidateElementsWithinPage:
1✔
911
    """Tests for validate_elements_within_page rule."""
912

913
    def test_elements_within_bounds(self) -> None:
1✔
914
        """Test page with all elements within bounds."""
915
        page, page_data = _make_page_with_steps(
1✔
916
            [
917
                (1, BBox(10, 10, 90, 90), BBox(70, 10, 90, 30)),
918
            ]
919
        )
920
        validation = ValidationResult()
1✔
921
        validate_elements_within_page(validation, page, page_data)
1✔
922
        assert not validation.has_issues()
1✔
923

924
    def test_element_outside_bounds(self) -> None:
1✔
925
        """Test detection of element outside page bounds."""
926
        page, page_data = _make_page_with_steps(
1✔
927
            [
928
                (1, BBox(10, 10, 110, 90), None),  # Extends past right edge
929
            ]
930
        )
931
        validation = ValidationResult()
1✔
932
        validate_elements_within_page(validation, page, page_data)
1✔
933
        assert validation.error_count >= 1
1✔
934
        assert any(i.rule == "element_outside_page" for i in validation.issues)
1✔
935

936

937
class TestValidateNoDividerIntersection:
1✔
938
    """Tests for validate_no_divider_intersection rule."""
939

940
    def _make_page_with_divider(
1✔
941
        self,
942
        divider_bbox: BBox,
943
        element_bbox: BBox,
944
        element_type: str = "Step",
945
    ) -> tuple[Page, PageData]:
946
        """Create a page with a divider and one other element."""
947

948
        page_bbox = BBox(0, 0, 100, 100)
1✔
949
        page_data = PageData(page_number=1, bbox=page_bbox, blocks=[])
1✔
950

951
        divider = Divider(bbox=divider_bbox, orientation=Divider.Orientation.VERTICAL)
1✔
952

953
        element: Any
954
        if element_type == "Step":
1✔
955
            element = Step(
1✔
956
                bbox=element_bbox,
957
                step_number=StepNumber(bbox=element_bbox, value=1),
958
            )
959
            steps = [element]
1✔
960
            background = None
1✔
961
            progress_bar = None
1✔
962
        elif element_type == "Background":
1✔
963
            element = Background(bbox=element_bbox)
1✔
964
            steps = []
1✔
965
            background = element
1✔
966
            progress_bar = None
1✔
967
        elif element_type == "ProgressBar":
1✔
968
            element = ProgressBar(bbox=element_bbox, full_width=100)
1✔
969
            steps = []
1✔
970
            background = None
1✔
971
            progress_bar = element
1✔
972
        else:
UNCOV
973
            raise ValueError(f"Unknown element type: {element_type}")
×
974

975
        page = Page(
1✔
976
            bbox=page_bbox,
977
            pdf_page_number=1,
978
            dividers=[divider],
979
            instruction=InstructionContent(steps=steps) if steps else None,
980
            background=background,
981
            progress_bar=progress_bar,
982
        )
983

984
        return page, page_data
1✔
985

986
    def test_no_dividers(self) -> None:
1✔
987
        """Test checking a page with no dividers."""
988
        page, page_data = _make_page_with_steps([(1, BBox(0, 0, 10, 10), None)])
1✔
989
        validation = ValidationResult()
1✔
990
        validate_no_divider_intersection(validation, page, page_data)
1✔
991
        assert not validation.has_issues()
1✔
992

993
    def test_no_intersection(self) -> None:
1✔
994
        """Test element not intersecting divider."""
995
        page, page_data = self._make_page_with_divider(
1✔
996
            divider_bbox=BBox(50, 0, 51, 100),  # Vertical line at x=50
997
            element_bbox=BBox(0, 0, 40, 40),  # Left side
998
        )
999
        validation = ValidationResult()
1✔
1000
        validate_no_divider_intersection(validation, page, page_data)
1✔
1001
        assert not validation.has_issues()
1✔
1002

1003
    def test_intersection(self) -> None:
1✔
1004
        """Test element intersecting divider."""
1005
        page, page_data = self._make_page_with_divider(
1✔
1006
            divider_bbox=BBox(50, 0, 51, 100),  # Vertical line at x=50
1007
            element_bbox=BBox(40, 0, 60, 40),  # Crosses x=50
1008
        )
1009
        validation = ValidationResult()
1✔
1010
        validate_no_divider_intersection(validation, page, page_data)
1✔
1011
        assert validation.warning_count >= 1
1✔
1012
        assert any(i.rule == "divider_intersection" for i in validation.issues)
1✔
1013

1014
    def test_excluded_elements_ignored(self) -> None:
1✔
1015
        """Test that excluded elements (Background, ProgressBar) are ignored."""
1016
        # Test Background intersection
1017
        page, page_data = self._make_page_with_divider(
1✔
1018
            divider_bbox=BBox(50, 0, 51, 100),
1019
            element_bbox=BBox(0, 0, 100, 100),  # Full page background
1020
            element_type="Background",
1021
        )
1022
        validation = ValidationResult()
1✔
1023
        validate_no_divider_intersection(validation, page, page_data)
1✔
1024
        assert not validation.has_issues()
1✔
1025

1026
        # Test ProgressBar intersection
1027
        page, page_data = self._make_page_with_divider(
1✔
1028
            divider_bbox=BBox(50, 0, 51, 100),
1029
            element_bbox=BBox(0, 90, 100, 100),  # Bottom bar crossing divider
1030
            element_type="ProgressBar",
1031
        )
1032
        validate_no_divider_intersection(validation, page, page_data)
1✔
1033
        assert not validation.has_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