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

bramp / build-along / 20472199734

23 Dec 2025 09:39PM UTC coverage: 88.693% (+0.2%) from 88.542%
20472199734

push

github

bramp
Add no-orphan constraints for Step child elements

- Add no-orphan constraints to StepClassifier.declare_constraints() for:
  - arrows (point from subassembly callouts to main diagram)
  - rotation_symbols (indicate model rotation)
  - subassemblies (callout boxes within steps)
  - substeps (mini-steps within a main step)
  - diagrams (the main instruction graphic)

- If any of these elements are selected, at least one step must also be
  selected, preventing orphaned elements

- Add unit tests for no-orphan constraint declaration

- Update architecture docs with no-orphan constraint documentation

- Add TODO for potential future centralization in SchemaConstraintGenerator

66 of 67 new or added lines in 2 files covered. (98.51%)

151 existing lines in 8 files now uncovered.

14786 of 16671 relevant lines covered (88.69%)

0.89 hits per line

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

98.82
/src/build_a_long/pdf_extract/classifier/steps/step_classifier_test.py
1
from collections.abc import Callable
1✔
2

3
import pytest
1✔
4

5
from build_a_long.pdf_extract.classifier import (
1✔
6
    ClassificationResult,
7
    ClassifierConfig,
8
)
9
from build_a_long.pdf_extract.classifier.candidate import Candidate
1✔
10
from build_a_long.pdf_extract.classifier.conftest import CandidateFactory
1✔
11
from build_a_long.pdf_extract.classifier.constraint_model import ConstraintModel
1✔
12
from build_a_long.pdf_extract.classifier.schema_constraint_generator import (
1✔
13
    SchemaConstraintGenerator,
14
)
15
from build_a_long.pdf_extract.classifier.score import Score
1✔
16
from build_a_long.pdf_extract.classifier.steps.arrow_classifier import (
1✔
17
    ArrowClassifier,
18
    _ArrowHeadData,
19
    _ArrowScore,
20
)
21
from build_a_long.pdf_extract.classifier.steps.step_classifier import (
1✔
22
    StepClassifier,
23
    _StepScore,
24
    filter_subassembly_values,
25
)
26
from build_a_long.pdf_extract.extractor import PageData
1✔
27
from build_a_long.pdf_extract.extractor.bbox import BBox
1✔
28
from build_a_long.pdf_extract.extractor.lego_page_elements import (
1✔
29
    Step,
30
)
31
from build_a_long.pdf_extract.extractor.page_blocks import Drawing, Image, Text
1✔
32

33

34
def run_solver(
1✔
35
    result: ClassificationResult,
36
    classifier: StepClassifier,
37
    labels_to_solve: set[str],
38
) -> None:
39
    """Run the constraint solver on the classification result.
40

41
    This helper mimics what Classifier._solve_constraints() does, allowing
42
    unit tests to test solver behavior without going through the full
43
    classification pipeline.
44

45
    Args:
46
        result: The classification result with scored candidates
47
        classifier: The step classifier to declare constraints
48
        labels_to_solve: Set of labels to include in the solver
49
    """
50
    model = ConstraintModel()
1✔
51

52
    # Add all candidates for the specified labels
53
    all_candidates: list[Candidate] = []
1✔
54
    for label, candidates in result.candidates.items():
1✔
55
        if label in labels_to_solve:
1✔
56
            for candidate in candidates:
1✔
57
                model.add_candidate(candidate)
1✔
58
                all_candidates.append(candidate)
1✔
59

60
    # Add block exclusivity constraints
61
    model.add_block_exclusivity_constraints(all_candidates)
1✔
62

63
    # Let the classifier declare custom constraints
64
    classifier.declare_constraints(model, result)
1✔
65

66
    # Auto-generate schema-based constraints
67
    generator = SchemaConstraintGenerator()
1✔
68
    generator.generate_for_classifier(classifier, model, result)
1✔
69

70
    # Add child uniqueness constraints (each child has at most one parent)
71
    generator.add_child_uniqueness_constraints(model)
1✔
72

73
    # Maximize total score
74
    model.maximize([(cand, int(cand.score * 1000)) for cand in all_candidates])
1✔
75

76
    # Solve
77
    solved, selection = model.solve()
1✔
78

79
    if not solved:
1✔
UNCOV
80
        result.set_solver_selection([], labels_to_solve)
×
UNCOV
81
        return
×
82

83
    # Mark selected candidates
84
    selected_candidates = [
1✔
85
        cand for cand in all_candidates if selection.get(cand.id, False)
86
    ]
87
    result.set_solver_selection(selected_candidates, labels_to_solve)
1✔
88

89

90
def score_solve_and_build(
1✔
91
    result: ClassificationResult,
92
    classifier: StepClassifier,
93
    labels_to_solve: set[str] | None = None,
94
) -> None:
95
    """Score candidates, run the solver, and build all steps.
96

97
    This is a convenience helper that combines the three-step process:
98
    1. classifier.score(result) - Score all step candidates
99
    2. run_solver() - Run constraint solver to select best candidates
100
    3. classifier.build_all() - Build the selected step elements
101

102
    Args:
103
        result: The classification result with dependency candidates already added
104
        classifier: The step classifier to use
105
        labels_to_solve: Set of labels to include in solver. Defaults to {"step"}.
106
    """
107
    if labels_to_solve is None:
1✔
108
        labels_to_solve = {"step"}
1✔
109

110
    classifier.score(result)
1✔
111
    run_solver(result, classifier, labels_to_solve)
1✔
112
    classifier.build_all(result)
1✔
113

114

115
@pytest.fixture
1✔
116
def classifier() -> StepClassifier:
1✔
117
    return StepClassifier(config=ClassifierConfig())
1✔
118

119

120
class TestStepClassification:
1✔
121
    """Tests for detecting complete Step structures."""
122

123
    def test_step_with_parts_list(
1✔
124
        self,
125
        classifier: StepClassifier,
126
        candidate_factory: Callable[[ClassificationResult], CandidateFactory],
127
    ) -> None:
128
        """Test a step that has an associated parts list."""
129
        page_bbox = BBox(0, 0, 200, 300)
1✔
130
        step_text = Text(id=1, bbox=BBox(50, 180, 70, 210), text="10")
1✔
131
        d1 = Drawing(id=2, bbox=BBox(30, 100, 170, 160))
1✔
132
        pc1_text = Text(id=3, bbox=BBox(40, 110, 55, 120), text="2x")
1✔
133
        pc2_text = Text(id=4, bbox=BBox(100, 130, 115, 140), text="5x")
1✔
134
        img1 = Image(id=5, bbox=BBox(40, 90, 55, 105))
1✔
135
        img2 = Image(id=6, bbox=BBox(100, 115, 115, 125))
1✔
136

137
        page_data = PageData(
1✔
138
            page_number=6,
139
            blocks=[step_text, d1, pc1_text, pc2_text, img1, img2],
140
            bbox=page_bbox,
141
        )
142

143
        result = ClassificationResult(page_data=page_data)
1✔
144
        # Register all relevant classifiers
145

146
        factory = candidate_factory(result)
1✔
147

148
        # Manually score dependencies
149
        factory.add_step_number(step_text)
1✔
150

151
        pc1_candidate = factory.add_part_count(pc1_text)
1✔
152
        pc2_candidate = factory.add_part_count(pc2_text)
1✔
153

154
        part1_candidate = factory.add_part(pc1_candidate, img1)
1✔
155
        part2_candidate = factory.add_part(pc2_candidate, img2)
1✔
156

157
        factory.add_parts_list(d1, [part1_candidate, part2_candidate])
1✔
158

159
        score_solve_and_build(result, classifier)
1✔
160

161
        # Get constructed steps
162
        steps = [
1✔
163
            result.get_constructed(c)
164
            for c in result.get_candidates("step")
165
            if isinstance(result.get_constructed(c), Step)
166
        ]
167
        assert len(steps) == 1
1✔
168
        constructed_step = steps[0]
1✔
169
        assert isinstance(constructed_step, Step)
1✔
170

171
        assert constructed_step.step_number.value == 10
1✔
172
        assert constructed_step.parts_list is not None
1✔
173
        assert len(constructed_step.parts_list.parts) == 2
1✔
174
        # Diagram is None when DiagramClassifier doesn't find any diagrams
175
        assert constructed_step.diagram is None
1✔
176

177
    def test_step_without_parts_list(
1✔
178
        self,
179
        classifier: StepClassifier,
180
        candidate_factory: Callable[[ClassificationResult], CandidateFactory],
181
    ) -> None:
182
        """Test a step that has no associated parts list."""
183
        page_bbox = BBox(0, 0, 200, 300)
1✔
184
        step_text = Text(id=1, bbox=BBox(50, 180, 70, 210), text="5")
1✔
185

186
        page_data = PageData(
1✔
187
            page_number=6,
188
            blocks=[step_text],
189
            bbox=page_bbox,
190
        )
191

192
        result = ClassificationResult(page_data=page_data)
1✔
193
        # Register all relevant classifiers
194

195
        factory = candidate_factory(result)
1✔
196

197
        # Manually score step number candidate
198
        factory.add_step_number(step_text)
1✔
199

200
        score_solve_and_build(result, classifier)
1✔
201

202
        # Get constructed steps
203
        steps = [
1✔
204
            result.get_constructed(c)
205
            for c in result.get_candidates("step")
206
            if isinstance(result.get_constructed(c), Step)
207
        ]
208
        assert len(steps) == 1
1✔
209
        constructed_step = steps[0]
1✔
210
        assert isinstance(constructed_step, Step)
1✔
211

212
        assert constructed_step.step_number.value == 5
1✔
213
        assert constructed_step.parts_list is None  # No parts list candidate
1✔
214
        # Diagram is None when DiagramClassifier doesn't find any diagrams
215
        assert constructed_step.diagram is None
1✔
216

217
    def test_multiple_steps_on_page(
1✔
218
        self,
219
        classifier: StepClassifier,
220
        candidate_factory: Callable[[ClassificationResult], CandidateFactory],
221
    ) -> None:
222
        """Test a page with multiple steps."""
223
        page_bbox = BBox(0, 0, 400, 300)
1✔
224

225
        # First step components
226
        step1_text = Text(id=1, bbox=BBox(50, 180, 70, 210), text="1")
1✔
227
        d1 = Drawing(id=2, bbox=BBox(30, 100, 170, 160))
1✔
228
        pc1_text = Text(id=3, bbox=BBox(40, 110, 55, 120), text="2x")
1✔
229
        img1 = Image(id=7, bbox=BBox(40, 90, 55, 105))
1✔
230

231
        # Second step components
232
        step2_text = Text(id=4, bbox=BBox(250, 180, 270, 210), text="2")
1✔
233
        d2 = Drawing(id=5, bbox=BBox(230, 100, 370, 160))
1✔
234
        pc2_text = Text(id=6, bbox=BBox(240, 110, 255, 120), text="3x")
1✔
235
        img2 = Image(id=8, bbox=BBox(240, 90, 255, 105))
1✔
236

237
        page_data = PageData(
1✔
238
            page_number=6,
239
            blocks=[step1_text, d1, pc1_text, img1, step2_text, d2, pc2_text, img2],
240
            bbox=page_bbox,
241
        )
242

243
        result = ClassificationResult(page_data=page_data)
1✔
244
        # Register all relevant classifiers
245

246
        factory = candidate_factory(result)
1✔
247

248
        # Score dependencies
249
        factory.add_step_number(step1_text)
1✔
250
        factory.add_step_number(step2_text)
1✔
251

252
        pc1_candidate = factory.add_part_count(pc1_text)
1✔
253
        pc2_candidate = factory.add_part_count(pc2_text)
1✔
254

255
        part1_candidate = factory.add_part(pc1_candidate, img1)
1✔
256
        part2_candidate = factory.add_part(pc2_candidate, img2)
1✔
257

258
        factory.add_parts_list(d1, [part1_candidate])
1✔
259
        factory.add_parts_list(d2, [part2_candidate])
1✔
260

261
        score_solve_and_build(result, classifier)
1✔
262

263
        # Get constructed steps
264
        steps = [
1✔
265
            result.get_constructed(c)
266
            for c in result.get_candidates("step")
267
            if isinstance(result.get_constructed(c), Step)
268
        ]
269
        assert len(steps) == 2
1✔
270

271
        # Check that steps are in order by value
272
        # Filter to Steps only for type safety
273
        step_objs = [s for s in steps if isinstance(s, Step)]
1✔
274
        steps_sorted = sorted(step_objs, key=lambda s: s.step_number.value)
1✔
275
        assert steps_sorted[0].step_number.value == 1
1✔
276
        assert steps_sorted[1].step_number.value == 2
1✔
277

278
    def test_step_score_ordering(
1✔
279
        self,
280
        classifier: StepClassifier,
281
        candidate_factory: Callable[[ClassificationResult], CandidateFactory],
282
    ) -> None:
283
        """Test that steps are ordered correctly by their score."""
284
        page_bbox = BBox(0, 0, 400, 300)
1✔
285

286
        # Step 2 appears first in the element list
287
        step2_text = Text(id=1, bbox=BBox(250, 180, 270, 210), text="2")
1✔
288

289
        # Step 1 appears second
290
        step1_text = Text(id=2, bbox=BBox(50, 180, 70, 210), text="1")
1✔
291

292
        page_data = PageData(
1✔
293
            page_number=6,
294
            blocks=[step2_text, step1_text],
295
            bbox=page_bbox,
296
        )
297

298
        result = ClassificationResult(page_data=page_data)
1✔
299
        # Register all relevant classifiers
300

301
        factory = candidate_factory(result)
1✔
302

303
        # Score dependencies - assign higher score to step1
304
        # to ensure it wins if scores are tie-broken
305
        factory.add_step_number(step2_text, score=0.8)  # Lower score for step2
1✔
306
        factory.add_step_number(step1_text, score=1.0)  # Higher score for step1
1✔
307

308
        classifier.score(result)
1✔
309

310
        # Get the candidates in sorted order
311
        step_candidates = result.get_candidates("step")
1✔
312
        sorted_candidates = sorted(
1✔
313
            step_candidates,
314
            key=lambda c: (
315
                c.score_details.sort_key()
316
                if isinstance(c.score_details, _StepScore)
317
                else (0.0, 0)
318
            ),
319
        )
320

321
        # Construct both steps
322
        assert len(sorted_candidates) >= 2
1✔
323
        constructed_step1 = result.build(sorted_candidates[0])
1✔
324
        constructed_step2 = result.build(sorted_candidates[1])
1✔
325

326
        assert isinstance(constructed_step1, Step)
1✔
327
        assert isinstance(constructed_step2, Step)
1✔
328
        assert constructed_step1.step_number.value == 1
1✔
329
        assert constructed_step2.step_number.value == 2
1✔
330

331
    def test_duplicate_step_numbers_only_match_one_step(
1✔
332
        self,
333
        classifier: StepClassifier,
334
        candidate_factory: Callable[[ClassificationResult], CandidateFactory],
335
    ) -> None:
336
        """When there are duplicate step numbers (same value), only one Step
337
        should be created. The StepClassifier should prefer the best-scoring
338
        StepNumber and skip subsequent ones with the same value.
339

340
        This test verifies that the uniqueness constraint is enforced at the
341
        Step level, not the PartsList level.
342
        """
343
        page_bbox = BBox(0, 0, 600, 400)
1✔
344

345
        # Two step numbers with the SAME value (both are "1")
346
        step1_text = Text(id=1, bbox=BBox(50, 150, 70, 180), text="1")
1✔
347
        step2_text = Text(
1✔
348
            id=2, bbox=BBox(50, 300, 70, 330), text="1"
349
        )  # Duplicate value
350

351
        # Two drawings, each above one of the step numbers
352
        d1 = Drawing(id=3, bbox=BBox(30, 80, 170, 140))  # Above step1
1✔
353
        d2 = Drawing(id=4, bbox=BBox(30, 230, 170, 290))  # Above step2
1✔
354

355
        # Part counts and images inside d1
356
        pc1_text = Text(id=5, bbox=BBox(40, 100, 55, 110), text="2x")
1✔
357
        img1 = Image(id=6, bbox=BBox(40, 85, 55, 95))
1✔
358

359
        # Part counts and images inside d2
360
        pc2_text = Text(id=7, bbox=BBox(40, 250, 55, 260), text="3x")
1✔
361
        img2 = Image(id=8, bbox=BBox(40, 235, 55, 245))
1✔
362

363
        page_data = PageData(
1✔
364
            page_number=1,
365
            blocks=[step1_text, step2_text, d1, d2, pc1_text, img1, pc2_text, img2],
366
            bbox=page_bbox,
367
        )
368

369
        result = ClassificationResult(page_data=page_data)
1✔
370
        # Register all relevant classifiers
371

372
        factory = candidate_factory(result)
1✔
373

374
        # Score dependencies
375
        factory.add_step_number(step1_text, score=1.0)  # Best scoring step number
1✔
376
        factory.add_step_number(step2_text, score=0.9)  # Lower scoring duplicate
1✔
377

378
        pc1_candidate = factory.add_part_count(pc1_text)
1✔
379
        pc2_candidate = factory.add_part_count(pc2_text)
1✔
380

381
        part1_candidate = factory.add_part(pc1_candidate, img1)
1✔
382
        part2_candidate = factory.add_part(pc2_candidate, img2)
1✔
383

384
        factory.add_parts_list(d1, [part1_candidate])
1✔
385
        factory.add_parts_list(d2, [part2_candidate])
1✔
386

387
        score_solve_and_build(result, classifier)
1✔
388

389
        # Get constructed steps
390
        steps = [
1✔
391
            result.get_constructed(c)
392
            for c in result.get_candidates("step")
393
            if isinstance(result.get_constructed(c), Step)
394
        ]
395

396
        # Only ONE step should be created (uniqueness enforced at Step level)
397
        assert len(steps) == 1
1✔
398
        step = steps[0]
1✔
399
        assert isinstance(step, Step)
1✔
400
        assert step.step_number.value == 1
1✔
401

402

403
class TestFilterSubassemblyValues:
1✔
404
    """Tests for filter_subassembly_values function.
405

406
    This function filters out items with values likely to be subassembly steps
407
    (e.g., 1, 2) when the list also contains higher-numbered page-level values
408
    (e.g., 15, 16).
409
    """
410

411
    def test_empty_list_returns_empty(self) -> None:
1✔
412
        """An empty list should return empty."""
413
        assert filter_subassembly_values([]) == []
1✔
414

415
    def test_single_item_returns_unchanged(self) -> None:
1✔
416
        """A single item should be returned unchanged."""
417
        items = [(5, "a")]
1✔
418
        assert filter_subassembly_values(items) == items
1✔
419

420
    def test_consecutive_values_no_gap_returns_unchanged(self) -> None:
1✔
421
        """Values with no significant gap (e.g., 15, 16, 17) return unchanged."""
422
        items = [(15, "a"), (16, "b"), (17, "c")]
1✔
423
        assert filter_subassembly_values(items) == items
1✔
424

425
    def test_gap_exactly_3_does_not_filter(self) -> None:
1✔
426
        """A gap of exactly 3 (not > 3) should not filter."""
427
        # Gap of 3: 1 -> 4 (4 - 1 = 3, not > 3)
428
        items = [(1, "a"), (4, "b")]
1✔
429
        assert filter_subassembly_values(items) == items
1✔
430

431
    def test_gap_exactly_4_filters(self) -> None:
1✔
432
        """A gap of exactly 4 (> 3) should filter when min_value <= 3."""
433
        # Gap of 4: 1 -> 5 (5 - 1 = 4, which is > 3)
434
        items = [(1, "a"), (5, "b")]
1✔
435
        assert filter_subassembly_values(items) == [(5, "b")]
1✔
436

437
    def test_gap_but_min_value_greater_than_3_no_filter(self) -> None:
1✔
438
        """Gap > 3 but min_value > 3 should not filter (e.g., 5, 6, 15, 16)."""
439
        # Gap of 9 (15 - 6 = 9) but min is 5 (> 3)
440
        items = [(5, "a"), (6, "b"), (15, "c"), (16, "d")]
1✔
441
        assert filter_subassembly_values(items) == items
1✔
442

443
    def test_min_value_exactly_3_filters(self) -> None:
1✔
444
        """Gap > 3 and min_value == 3 should filter (3 <= 3)."""
445
        # Gap of 12 (15 - 3 = 12) and min is 3 (<= 3)
446
        items = [(3, "a"), (15, "b"), (16, "c")]
1✔
447
        result = filter_subassembly_values(items)
1✔
448
        assert result == [(15, "b"), (16, "c")]
1✔
449

450
    def test_min_value_exactly_4_no_filter(self) -> None:
1✔
451
        """Gap > 3 but min_value == 4 should NOT filter (4 > 3)."""
452
        # Gap of 11 (15 - 4 = 11) but min is 4 (> 3)
453
        items = [(4, "a"), (15, "b"), (16, "c")]
1✔
454
        assert filter_subassembly_values(items) == items
1✔
455

456
    def test_typical_subassembly_case(self) -> None:
1✔
457
        """Typical case: steps 1, 2 (subassembly) + 15, 16 (page-level)."""
458
        items = [(1, "a"), (2, "b"), (15, "c"), (16, "d")]
1✔
459
        result = filter_subassembly_values(items)
1✔
460
        assert result == [(15, "c"), (16, "d")]
1✔
461

462
    def test_multiple_gaps_uses_largest(self) -> None:
1✔
463
        """When there are multiple gaps, the largest one determines filtering."""
464
        # Values: 1, 2, 5, 20, 21
465
        # Gaps: 2->5 = 3, 5->20 = 15 (largest)
466
        items = [(1, "a"), (2, "b"), (5, "c"), (20, "d"), (21, "e")]
1✔
467
        result = filter_subassembly_values(items)
1✔
468
        # Largest gap is 5->20, threshold = 20
469
        assert result == [(20, "d"), (21, "e")]
1✔
470

471
    def test_unordered_input_handled_correctly(self) -> None:
1✔
472
        """Items passed in non-sorted order should be handled correctly."""
473
        # Pass in non-sorted order
474
        items = [(16, "d"), (1, "a"), (15, "c"), (2, "b")]
1✔
475
        result = filter_subassembly_values(items)
1✔
476
        # Should filter and return sorted
477
        assert result == [(15, "c"), (16, "d")]
1✔
478

479
    def test_preserves_associated_data(self) -> None:
1✔
480
        """The associated data (second element of tuple) should be preserved."""
481
        items = [
1✔
482
            (1, {"name": "step1"}),
483
            (2, {"name": "step2"}),
484
            (15, {"name": "step15"}),
485
        ]
486
        result = filter_subassembly_values(items)
1✔
487
        assert result == [(15, {"name": "step15"})]
1✔
488

489
    def test_custom_min_gap_parameter(self) -> None:
1✔
490
        """Custom min_gap parameter should be respected."""
491
        items = [(1, "a"), (3, "b")]  # Gap of 2
1✔
492
        # Default min_gap=3, so gap of 2 doesn't filter
493
        assert filter_subassembly_values(items) == items
1✔
494
        # With min_gap=1, gap of 2 > 1 should filter
495
        assert filter_subassembly_values(items, min_gap=1) == [(3, "b")]
1✔
496

497
    def test_custom_max_subassembly_start_parameter(self) -> None:
1✔
498
        """Custom max_subassembly_start parameter should be respected."""
499
        items = [(4, "a"), (15, "b")]  # Gap of 11, min=4
1✔
500
        # Default max_subassembly_start=3, so min=4 doesn't filter
501
        assert filter_subassembly_values(items) == items
1✔
502
        # With max_subassembly_start=4, min=4 should filter
503
        assert filter_subassembly_values(items, max_subassembly_start=4) == [(15, "b")]
1✔
504

505

506
class TestNoOrphanConstraints:
1✔
507
    """Tests for no-orphan constraints in StepClassifier."""
508

509
    def test_no_orphan_constraint_declared_for_arrows(
1✔
510
        self,
511
        classifier: StepClassifier,
512
        candidate_factory: Callable[[ClassificationResult], CandidateFactory],
513
    ) -> None:
514
        """Verify no-orphan constraint is declared for arrows.
515

516
        When arrows exist, the constraint solver must ensure at least one
517
        step is selected to prevent orphaned arrows.
518
        """
519
        page_bbox = BBox(0, 0, 600, 400)
1✔
520
        step_text = Text(id=1, bbox=BBox(50, 50, 70, 70), text="1")
1✔
521
        arrow_drawing = Drawing(id=2, bbox=BBox(100, 100, 200, 110))
1✔
522

523
        page_data = PageData(
1✔
524
            page_number=1,
525
            blocks=[step_text, arrow_drawing],
526
            bbox=page_bbox,
527
        )
528

529
        result = ClassificationResult(page_data=page_data)
1✔
530
        factory = candidate_factory(result)
1✔
531

532
        # Add step_number and step candidates
533
        factory.add_step_number(step_text)
1✔
534
        classifier.score(result)
1✔
535

536
        # Add an arrow candidate manually
537
        result._register_classifier("arrow", ArrowClassifier(config=ClassifierConfig()))
1✔
538
        arrow_score = _ArrowScore(
1✔
539
            heads=[
540
                _ArrowHeadData(
541
                    tip=(150, 105),
542
                    direction=0,
543
                    shape_score=1.0,
544
                    size_score=1.0,
545
                    block=arrow_drawing,
546
                )
547
            ]
548
        )
549
        arrow_cand = Candidate(
1✔
550
            bbox=arrow_drawing.bbox,
551
            label="arrow",
552
            score=1.0,
553
            score_details=arrow_score,
554
            source_blocks=[arrow_drawing],
555
        )
556
        result.add_candidate(arrow_cand)
1✔
557

558
        # Create constraint model
559
        model = ConstraintModel()
1✔
560
        for cand in result.get_candidates("step"):
1✔
561
            model.add_candidate(cand)
1✔
562
        model.add_candidate(arrow_cand)
1✔
563

564
        # Declare constraints
565
        classifier.declare_constraints(model, result)
1✔
566

567
        # Verify constraints were added by checking model internals
568
        # (if_any_selected_then_one_of creates indicator variables)
569
        assert model._constraint_counts.get("if_any_selected_then_one_of", 0) >= 1
1✔
570

571
    def test_no_orphan_constraints_for_all_child_elements(
1✔
572
        self,
573
        classifier: StepClassifier,
574
        candidate_factory: Callable[[ClassificationResult], CandidateFactory],
575
    ) -> None:
576
        """Verify no-orphan constraints are declared for all relevant element types."""
577
        page_bbox = BBox(0, 0, 600, 400)
1✔
578
        step_text = Text(id=1, bbox=BBox(50, 50, 70, 70), text="1")
1✔
579

580
        # Create dummy blocks for simple orphan-able element types
581
        # Note: substep is a composite element with empty source_blocks
582
        simple_orphan_labels = ["arrow", "rotation_symbol", "subassembly", "diagram"]
1✔
583
        dummy_blocks = [
1✔
584
            Drawing(id=100 + i, bbox=BBox(200 + i * 50, 100, 250 + i * 50, 150))
585
            for i in range(len(simple_orphan_labels))
586
        ]
587

588
        page_data = PageData(
1✔
589
            page_number=1,
590
            blocks=[step_text, *dummy_blocks],
591
            bbox=page_bbox,
592
        )
593

594
        result = ClassificationResult(page_data=page_data)
1✔
595
        factory = candidate_factory(result)
1✔
596

597
        # Add step_number and step candidates
598
        factory.add_step_number(step_text)
1✔
599
        classifier.score(result)
1✔
600

601
        # Create a concrete Score subclass for testing
602
        class _DummyScore(Score):
1✔
603
            def score(self) -> float:
1✔
NEW
604
                return 1.0
×
605

606
        # Add simple element candidates (with source_blocks)
607
        for i, label in enumerate(simple_orphan_labels):
1✔
608
            dummy_cand = Candidate(
1✔
609
                bbox=dummy_blocks[i].bbox,
610
                label=label,
611
                score=1.0,
612
                score_details=_DummyScore(),
613
                source_blocks=[dummy_blocks[i]],
614
            )
615
            result.add_candidate(dummy_cand)
1✔
616

617
        # Add substep candidate (composite, empty source_blocks)
618
        substep_cand = Candidate(
1✔
619
            bbox=BBox(300, 200, 400, 300),
620
            label="substep",
621
            score=1.0,
622
            score_details=_DummyScore(),
623
            source_blocks=[],  # Composite elements have empty source_blocks
624
        )
625
        result.add_candidate(substep_cand)
1✔
626

627
        # All orphan labels we expect constraints for
628
        all_orphan_labels = [
1✔
629
            "arrow",
630
            "rotation_symbol",
631
            "subassembly",
632
            "substep",
633
            "diagram",
634
        ]
635

636
        # Create constraint model
637
        model = ConstraintModel()
1✔
638
        for cand in result.get_candidates("step"):
1✔
639
            model.add_candidate(cand)
1✔
640
        for label in all_orphan_labels:
1✔
641
            for cand in result.get_candidates(label):
1✔
642
                model.add_candidate(cand)
1✔
643

644
        # Declare constraints
645
        classifier.declare_constraints(model, result)
1✔
646

647
        # Should have 5 no-orphan constraints (one per orphan label)
648
        assert model._constraint_counts.get("if_any_selected_then_one_of", 0) == 5
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