"""Tests for assurance_inventory.py -- Component 1 of the Assessment Assurance Audit.

Covers:
- Golden grade-weight tests for both weighting modes
- Weight sum assertion (~100%)
- Exclusion of omit_from_final_grade, unpublished, and zero-point items
- Object normalization: graded discussion, New Quizzes opaque-degrade, external-tool
- Proctoring signal detection
- No import of criterion_evaluator
- No zip/imscc handling
"""

import os
import sys
from unittest.mock import MagicMock, patch

import pytest

sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))

import assurance_inventory


# ---------------------------------------------------------------------------
# Fixture helpers
# ---------------------------------------------------------------------------

def _mock_config(course_id="12345"):
    return {
        "token": "fake",
        "domain": "canvas.test.edu",
        "course_id": str(course_id),
        "base_url": "https://canvas.test.edu/api/v1",
        "course_url": f"https://canvas.test.edu/api/v1/courses/{course_id}",
        "headers": {"Authorization": "Bearer fake"},
    }


def _assignment(
    id,
    name="Assignment",
    points=100.0,
    group_id=1,
    submission_types=None,
    omit=False,
    published=True,
    is_quiz=False,
    quiz_id=None,
    peer_reviews=False,
    rubric=None,
    external_tool_tag=None,
    discussion_topic=None,
    description=None,
):
    """Build a minimal assignment dict like Canvas returns."""
    a = {
        "id": id,
        "name": name,
        "points_possible": points,
        "assignment_group_id": group_id,
        "submission_types": submission_types or ["online_upload"],
        "omit_from_final_grade": omit,
        "workflow_state": "published" if published else "unpublished",
        "published": published,
        "peer_reviews": peer_reviews,
        "is_quiz_assignment": is_quiz,
    }
    if quiz_id is not None:
        a["quiz_id"] = quiz_id
    if rubric is not None:
        a["rubric"] = rubric
    if external_tool_tag is not None:
        a["external_tool_tag"] = external_tool_tag
    if discussion_topic is not None:
        a["discussion_topic"] = discussion_topic
    if description is not None:
        a["description"] = description
    return a


def _group(id, name, weight, assignments, rules=None):
    g = {
        "id": id,
        "name": name,
        "group_weight": weight,
        "assignments": assignments,
    }
    if rules is not None:
        g["rules"] = rules
    return g


def _mock_course_obj(weighted=True):
    return {"apply_assignment_group_weights": weighted, "name": "Test Course"}


def _noop_module_map(*args, **kwargs):
    return {}


# ---------------------------------------------------------------------------
# Helper to run build_inventory with controlled mocks
# ---------------------------------------------------------------------------

def _run_inventory(
    groups,
    quizzes=None,
    discussions=None,
    weighted=True,
    course_id="12345",
):
    """Run build_inventory with all Canvas API calls mocked."""
    cfg = _mock_config(course_id)
    quizzes = quizzes or []
    discussions = discussions or []

    import requests

    course_resp = MagicMock()
    course_resp.status_code = 200
    course_resp.json.return_value = _mock_course_obj(weighted)

    with (
        patch("assurance_inventory.canvas_api._request_with_retry", return_value=course_resp),
        patch("assurance_inventory.canvas_api.paginated_get", return_value=groups),
        patch("assurance_inventory.canvas_api.list_quizzes", return_value=quizzes),
        patch("assurance_inventory.canvas_api.list_discussions", return_value=discussions),
        patch("assurance_inventory._build_module_map", return_value={}),
    ):
        return assurance_inventory.build_inventory(cfg)


# ===========================================================================
# GOLDEN GRADE-WEIGHT TESTS
# ===========================================================================

class TestGradeWeightWeightedGroups:
    """Weighted groups mode: group_weight * (item_pts / group_counted_pts)."""

    def test_two_groups_equal_points_correct_weight(self):
        """Two groups at 50% each, one assignment per group at 100 pts."""
        groups = [
            _group(1, "Quizzes", 50.0, [
                _assignment(101, "Quiz 1", points=100.0, group_id=1),
            ]),
            _group(2, "Papers", 50.0, [
                _assignment(201, "Paper 1", points=100.0, group_id=2),
            ]),
        ]
        result = _run_inventory(groups, weighted=True)
        assessments = {a["id"]: a for a in result["assessments"]}

        assert abs(assessments[101]["grade_weight_pct"] - 50.0) < 0.01
        assert abs(assessments[201]["grade_weight_pct"] - 50.0) < 0.01
        assert abs(result["totals"]["grade_weight_pct_sum"] - 100.0) < 0.1
        assert result["course_weighting_mode"] == "weighted_groups"

    def test_unequal_weights_distributed_correctly(self):
        """60/40 split: group 1 at 60%, group 2 at 40%."""
        groups = [
            _group(1, "Exams", 60.0, [
                _assignment(101, "Midterm", points=200.0, group_id=1),
                _assignment(102, "Final", points=200.0, group_id=1),
            ]),
            _group(2, "HW", 40.0, [
                _assignment(201, "HW 1", points=100.0, group_id=2),
            ]),
        ]
        result = _run_inventory(groups, weighted=True)
        assessments = {a["id"]: a for a in result["assessments"]}

        # Midterm: 60 * (200/400) = 30
        assert abs(assessments[101]["grade_weight_pct"] - 30.0) < 0.01
        # Final: 60 * (200/400) = 30
        assert abs(assessments[102]["grade_weight_pct"] - 30.0) < 0.01
        # HW 1: 40 * (100/100) = 40
        assert abs(assessments[201]["grade_weight_pct"] - 40.0) < 0.01
        assert abs(result["totals"]["grade_weight_pct_sum"] - 100.0) < 0.1

    def test_unequal_points_within_group(self):
        """Group at 50%: one 200pt item and one 100pt item (2:1 split)."""
        groups = [
            _group(1, "Assignments", 50.0, [
                _assignment(101, "Big Paper", points=200.0, group_id=1),
                _assignment(102, "Small Paper", points=100.0, group_id=1),
            ]),
            _group(2, "Quizzes", 50.0, [
                _assignment(201, "Quiz", points=50.0, group_id=2),
            ]),
        ]
        result = _run_inventory(groups, weighted=True)
        assessments = {a["id"]: a for a in result["assessments"]}

        # Big Paper: 50 * (200/300) = 33.33...
        assert abs(assessments[101]["grade_weight_pct"] - 50 * (200 / 300)) < 0.01
        # Small Paper: 50 * (100/300) = 16.67
        assert abs(assessments[102]["grade_weight_pct"] - 50 * (100 / 300)) < 0.01
        # Quiz: 50 * (50/50) = 50
        assert abs(assessments[201]["grade_weight_pct"] - 50.0) < 0.01


class TestGradeWeightPointsOnly:
    """Points-only mode: (item_pts / total_counted_pts) * 100."""

    def test_two_items_equal_points(self):
        """200 pts total, two items at 100 pts each: 50% each."""
        groups = [
            _group(1, "All", 0.0, [
                _assignment(101, "A", points=100.0, group_id=1),
                _assignment(102, "B", points=100.0, group_id=1),
            ]),
        ]
        result = _run_inventory(groups, weighted=False)
        assessments = {a["id"]: a for a in result["assessments"]}

        assert abs(assessments[101]["grade_weight_pct"] - 50.0) < 0.01
        assert abs(assessments[102]["grade_weight_pct"] - 50.0) < 0.01
        assert abs(result["totals"]["grade_weight_pct_sum"] - 100.0) < 0.1
        assert result["course_weighting_mode"] == "points_only"

    def test_three_items_unequal_points(self):
        """300 pts total: 100, 150, 50 -- weights should be 33.33, 50, 16.67."""
        groups = [
            _group(1, "All", 0.0, [
                _assignment(101, "A", points=100.0, group_id=1),
                _assignment(102, "B", points=150.0, group_id=1),
                _assignment(103, "C", points=50.0, group_id=1),
            ]),
        ]
        result = _run_inventory(groups, weighted=False)
        assessments = {a["id"]: a for a in result["assessments"]}

        assert abs(assessments[101]["grade_weight_pct"] - (100 / 300) * 100) < 0.01
        assert abs(assessments[102]["grade_weight_pct"] - (150 / 300) * 100) < 0.01
        assert abs(assessments[103]["grade_weight_pct"] - (50 / 300) * 100) < 0.01
        assert abs(result["totals"]["grade_weight_pct_sum"] - 100.0) < 0.1

    def test_produces_different_weights_than_weighted_mode(self):
        """Same assignments produce different per-item weights in the two modes."""
        # In weighted mode: group1=80%, group2=20%
        # Item A (200pts in group1) would get 80%
        # Item B (200pts in group2) would get 20%
        # In points-only mode: both 200pts -> each 50%
        groups = [
            _group(1, "G1", 80.0, [_assignment(101, "A", 200.0, group_id=1)]),
            _group(2, "G2", 20.0, [_assignment(102, "B", 200.0, group_id=2)]),
        ]

        weighted_result = _run_inventory(groups, weighted=True)
        points_result = _run_inventory(groups, weighted=False)

        w_ass = {a["id"]: a for a in weighted_result["assessments"]}
        p_ass = {a["id"]: a for a in points_result["assessments"]}

        # Weighted: A=80, B=20; Points-only: A=50, B=50
        assert abs(w_ass[101]["grade_weight_pct"] - 80.0) < 0.01
        assert abs(w_ass[102]["grade_weight_pct"] - 20.0) < 0.01
        assert abs(p_ass[101]["grade_weight_pct"] - 50.0) < 0.01
        assert abs(p_ass[102]["grade_weight_pct"] - 50.0) < 0.01


# ===========================================================================
# EXCLUSION RULES
# ===========================================================================

class TestExclusions:
    """omit_from_final_grade, unpublished, and zero-point items are excluded
    from the counted pool and receive grade_weight_pct=0."""

    def test_omit_from_final_grade_gets_zero_weight(self):
        groups = [
            _group(1, "G", 100.0, [
                _assignment(101, "Counted", points=100.0, omit=False),
                _assignment(102, "Omitted", points=100.0, omit=True),
            ]),
        ]
        result = _run_inventory(groups, weighted=True)
        assessments = {a["id"]: a for a in result["assessments"]}

        # Omitted item must carry 0 weight but still appear in output
        assert assessments[102]["grade_weight_pct"] == 0.0
        assert assessments[102]["omit_from_final_grade"] is True
        # Counted item absorbs the full 100%
        assert abs(assessments[101]["grade_weight_pct"] - 100.0) < 0.01

    def test_unpublished_gets_zero_weight(self):
        groups = [
            _group(1, "G", 100.0, [
                _assignment(101, "Published", points=100.0, published=True),
                _assignment(102, "Unpublished", points=100.0, published=False),
            ]),
        ]
        result = _run_inventory(groups, weighted=True)
        assessments = {a["id"]: a for a in result["assessments"]}

        assert assessments[102]["grade_weight_pct"] == 0.0
        assert assessments[102]["published"] is False
        assert abs(assessments[101]["grade_weight_pct"] - 100.0) < 0.01

    def test_zero_points_gets_zero_weight(self):
        groups = [
            _group(1, "G", 0.0, [
                _assignment(101, "Real", points=100.0),
                _assignment(102, "Zero Pts", points=0.0),
            ]),
        ]
        result = _run_inventory(groups, weighted=False)
        assessments = {a["id"]: a for a in result["assessments"]}

        assert assessments[102]["grade_weight_pct"] == 0.0
        # Both records still present
        assert len(result["assessments"]) == 2

    def test_excluded_items_still_appear_in_output(self):
        """Uncounted items appear in output so callers can audit them."""
        groups = [
            _group(1, "G", 100.0, [
                _assignment(101, "Good", points=100.0),
                _assignment(102, "Omitted", points=50.0, omit=True),
                _assignment(103, "Unpub", points=50.0, published=False),
                _assignment(104, "Zero", points=0.0),
            ]),
        ]
        result = _run_inventory(groups, weighted=True)
        ids = {a["id"] for a in result["assessments"]}
        assert {101, 102, 103, 104} == ids

    def test_weight_sum_is_100_despite_exclusions(self):
        """The weight sum of ALL records should still be ~100 even with exclusions."""
        groups = [
            _group(1, "G", 100.0, [
                _assignment(101, "A", points=200.0),
                _assignment(102, "B", points=100.0),
                _assignment(103, "Omitted", points=50.0, omit=True),
            ]),
        ]
        result = _run_inventory(groups, weighted=True)
        assert abs(result["totals"]["grade_weight_pct_sum"] - 100.0) < 0.5


# ===========================================================================
# OBJECT NORMALIZATION
# ===========================================================================

class TestObjectNormalization:
    """Records from quizzes, discussions, and LTI assignments normalize correctly."""

    def test_classic_quiz_normalization(self):
        quiz_detail = {"id": 77, "access_code": None, "ip_filter": None}
        groups = [
            _group(1, "Quizzes", 100.0, [
                _assignment(
                    101, "Midterm Quiz",
                    points=50.0,
                    is_quiz=True,
                    quiz_id=77,
                    submission_types=["online_quiz"],
                ),
            ]),
        ]
        quizzes = [{"id": 77, "access_code": None, "ip_filter": None}]
        result = _run_inventory(groups, quizzes=quizzes, weighted=False)
        rec = result["assessments"][0]

        assert rec["canvas_type"] == "quiz"
        assert rec["engine"] == "classic_quiz"
        assert rec["is_external_tool"] is False
        assert rec["proctoring_signal"] is False

    def test_classic_quiz_proctoring_signal_access_code(self):
        """Classic quiz with access_code raises proctoring_signal."""
        quiz_detail = {"id": 88, "access_code": "supersecret", "ip_filter": None}
        groups = [
            _group(1, "Quizzes", 100.0, [
                _assignment(101, "Locked Quiz", points=100.0, is_quiz=True, quiz_id=88,
                             submission_types=["online_quiz"]),
            ]),
        ]
        quizzes = [{"id": 88, "access_code": "supersecret", "ip_filter": None}]
        result = _run_inventory(groups, quizzes=quizzes, weighted=False)
        rec = result["assessments"][0]

        assert rec["proctoring_signal"] is True

    def test_classic_quiz_proctoring_signal_lockdown_browser(self):
        """Classic quiz requiring Respondus LockDown Browser raises proctoring_signal,
        with or without webcam monitor (the ASU-standard exam lock)."""
        for monitor in (False, True):
            groups = [
                _group(1, "Test", 100.0, [
                    _assignment(101, "Unit Exam - Requires Respondus LockDown Browser",
                                points=28.0, is_quiz=True, quiz_id=88,
                                submission_types=["online_quiz"]),
                ]),
            ]
            quizzes = [{"id": 88, "access_code": None, "ip_filter": None,
                        "require_lockdown_browser": True,
                        "require_lockdown_browser_monitor": monitor}]
            result = _run_inventory(groups, quizzes=quizzes, weighted=False)
            rec = result["assessments"][0]
            assert rec["proctoring_signal"] is True, f"monitor={monitor}"

    def test_graded_discussion_joins_assignment(self):
        """Graded discussion: canvas_type=discussion, engine=graded_discussion.

        The backing assignment carries points and weight; the discussion topic
        carries the prompt text (not the weight).
        """
        discussion_topic = {
            "id": 55,
            "title": "Week 1 Discussion",
            "assignment_id": 201,
            "message": "Discuss X.",
        }
        groups = [
            _group(1, "Discussions", 100.0, [
                _assignment(
                    201, "Week 1 Discussion",
                    points=30.0,
                    submission_types=["discussion_topic"],
                    discussion_topic={"id": 55},
                ),
            ]),
        ]
        discussions = [discussion_topic]
        result = _run_inventory(groups, discussions=discussions, weighted=False)
        rec = result["assessments"][0]

        assert rec["canvas_type"] == "discussion"
        assert rec["engine"] == "graded_discussion"
        assert rec["points"] == 30.0
        assert abs(rec["grade_weight_pct"] - 100.0) < 0.01

    def test_new_quizzes_detected_and_opaque_degrade(self):
        """New Quizzes assignments degrade to engine=new_quizzes_opaque on API failure.

        The inventory must NOT crash.  The record must still be emitted.
        """
        nq_assignment = _assignment(
            301, "New Quiz Final",
            points=100.0,
            is_quiz=True,
            submission_types=["external_tool"],
            external_tool_tag={"url": "https://canvas.test.edu/quiz-lti/launch"},
        )
        groups = [_group(1, "Quizzes", 100.0, [nq_assignment])]

        cfg = _mock_config()
        course_resp = MagicMock()
        course_resp.status_code = 200
        course_resp.json.return_value = _mock_course_obj(False)

        # Simulate New Quizzes API returning 403
        api_resp_403 = MagicMock()
        api_resp_403.status_code = 403

        with (
            patch("assurance_inventory.canvas_api._request_with_retry") as mock_retry,
            patch("assurance_inventory.canvas_api.paginated_get", return_value=groups),
            patch("assurance_inventory.canvas_api.list_quizzes", return_value=[]),
            patch("assurance_inventory.canvas_api.list_discussions", return_value=[]),
            patch("assurance_inventory._build_module_map", return_value={}),
        ):
            # First call: course object; subsequent calls: New Quizzes detail (fails)
            mock_retry.side_effect = [course_resp, api_resp_403]
            result = assurance_inventory.build_inventory(cfg)

        assert len(result["assessments"]) == 1
        rec = result["assessments"][0]
        assert rec["engine"] == "new_quizzes_opaque"
        assert rec["id"] == 301
        assert rec["canvas_type"] == "quiz"
        assert rec["is_external_tool"] is True

    def test_external_tool_lti_assignment(self):
        """Non-quiz LTI assignment is classified as external_tool."""
        groups = [
            _group(1, "LTI", 100.0, [
                _assignment(
                    401, "Respondus Exam",
                    points=100.0,
                    submission_types=["external_tool"],
                    external_tool_tag={"url": "https://lms.respondus.com/lockdown/launch"},
                ),
            ]),
        ]
        result = _run_inventory(groups, weighted=False)
        rec = result["assessments"][0]

        assert rec["canvas_type"] == "external_tool"
        assert rec["engine"] == "external_tool"
        assert rec["is_external_tool"] is True
        assert rec["proctoring_signal"] is True

    def test_standard_assignment_normalization(self):
        groups = [
            _group(1, "Assignments", 100.0, [
                _assignment(501, "Essay", points=50.0, submission_types=["online_text_entry"]),
            ]),
        ]
        result = _run_inventory(groups, weighted=False)
        rec = result["assessments"][0]

        assert rec["canvas_type"] == "assignment"
        assert rec["engine"] == "assignment"
        assert rec["is_external_tool"] is False
        assert rec["proctoring_signal"] is False


# ===========================================================================
# RUBRIC DETECTION
# ===========================================================================

class TestRubricDetection:
    def test_rubric_on_assignment_detected(self):
        groups = [
            _group(1, "G", 100.0, [
                _assignment(601, "Rubric Essay", points=100.0,
                             rubric=[{"description": "Analysis"}]),
            ]),
        ]
        result = _run_inventory(groups, weighted=False)
        assert result["assessments"][0]["has_rubric"] is True

    def test_no_rubric_reported_false(self):
        groups = [
            _group(1, "G", 100.0, [
                _assignment(602, "Bare Essay", points=100.0),
            ]),
        ]
        result = _run_inventory(groups, weighted=False)
        assert result["assessments"][0]["has_rubric"] is False


# ===========================================================================
# RECORD SHAPE CONTRACT
# ===========================================================================

class TestRecordShape:
    """Every record must have all required keys."""

    REQUIRED_KEYS = {
        "id", "title", "canvas_type", "engine", "points", "grade_weight_pct",
        "submission_types", "has_rubric", "peer_reviews", "omit_from_final_grade",
        "published", "module", "group", "is_external_tool", "proctoring_signal",
    }

    def test_all_keys_present(self):
        groups = [
            _group(1, "G", 100.0, [
                _assignment(701, "A", points=100.0),
            ]),
        ]
        result = _run_inventory(groups, weighted=False)
        for rec in result["assessments"]:
            missing = self.REQUIRED_KEYS - set(rec.keys())
            assert not missing, f"Record missing keys: {missing}"

    def test_result_has_required_top_level_keys(self):
        groups = [_group(1, "G", 100.0, [_assignment(701, "A", points=100.0)])]
        result = _run_inventory(groups, weighted=False)
        assert "assessments" in result
        assert "course_weighting_mode" in result
        assert "totals" in result
        assert "assumptions" in result
        assert "counted_points" in result["totals"]
        assert "grade_weight_pct_sum" in result["totals"]


# ===========================================================================
# SEPARATION ASSERTIONS
# ===========================================================================

def test_no_criterion_evaluator_import():
    """assurance_inventory must NOT import criterion_evaluator."""
    import ast
    script_path = os.path.join(
        os.path.dirname(__file__), "..", "scripts", "assurance_inventory.py"
    )
    with open(script_path, "r", encoding="utf-8") as fh:
        tree = ast.parse(fh.read())
    imported = set()
    for node in ast.walk(tree):
        if isinstance(node, ast.Import):
            for alias in node.names:
                imported.add(alias.name.split(".")[0])
        elif isinstance(node, ast.ImportFrom):
            if node.module:
                imported.add(node.module.split(".")[0])
    assert "criterion_evaluator" not in imported, (
        "assurance_inventory must not import criterion_evaluator"
    )


def test_no_imscc_or_zip_handling():
    """assurance_inventory must NOT contain zip/imscc code paths."""
    script_path = os.path.join(
        os.path.dirname(__file__), "..", "scripts", "assurance_inventory.py"
    )
    with open(script_path, "r", encoding="utf-8") as fh:
        source = fh.read()
    for forbidden in ("zipfile", "imscc", ".imscc", ".zip", "ZipFile"):
        assert forbidden not in source, (
            f"assurance_inventory must not contain {forbidden!r}"
        )


# ===========================================================================
# EDGE CASES
# ===========================================================================

class TestEdgeCases:
    def test_empty_course_returns_empty_inventory(self):
        result = _run_inventory([], weighted=False)
        assert result["assessments"] == []
        assert result["totals"]["grade_weight_pct_sum"] == 0.0

    def test_all_items_excluded_returns_zero_weight_sum(self):
        groups = [
            _group(1, "G", 100.0, [
                _assignment(101, "Omitted", points=100.0, omit=True),
                _assignment(102, "Unpub", points=100.0, published=False),
            ]),
        ]
        result = _run_inventory(groups, weighted=True)
        assert result["totals"]["grade_weight_pct_sum"] == 0.0
        assert all(a["grade_weight_pct"] == 0.0 for a in result["assessments"])

    def test_multi_group_weighted_sum_is_100(self):
        """Three groups totaling 100% weight; sum of all item weights = 100."""
        groups = [
            _group(1, "A", 40.0, [
                _assignment(101, "A1", 50.0, group_id=1),
                _assignment(102, "A2", 50.0, group_id=1),
            ]),
            _group(2, "B", 35.0, [
                _assignment(201, "B1", 100.0, group_id=2),
            ]),
            _group(3, "C", 25.0, [
                _assignment(301, "C1", 30.0, group_id=3),
                _assignment(302, "C2", 70.0, group_id=3),
            ]),
        ]
        result = _run_inventory(groups, weighted=True)
        total = result["totals"]["grade_weight_pct_sum"]
        assert abs(total - 100.0) < 0.5, f"Weight sum {total} not close to 100"


# ===========================================================================
# GOLDEN TESTS: WEIGHTED-MODE NORMALIZATION (groups that do NOT sum to 100)
# ===========================================================================

class TestWeightNormalization:
    """Canvas normalizes group weights by the sum of active (non-empty) group
    weights.  These tests verify that _compute_weights() produces a
    grade_weight_pct sum of ~100 even when raw group weights do not sum to 100.
    Existing tests whose groups already sum to 100 are unaffected because
    normalization is a no-op when total_group_weight == 100."""

    def test_three_groups_summing_to_90_normalize_to_100(self):
        """Three groups at 30/30/30 (sum=90): each item gets 100/3 ~ 33.33%.

        Without normalization the bug returns 30% per item (sum=90).
        With normalization each group contributes (30/90)*100 = 33.33%.
        """
        groups = [
            _group(1, "Quizzes", 30.0, [
                _assignment(101, "Quiz 1", points=100.0, group_id=1),
            ]),
            _group(2, "Assignments", 30.0, [
                _assignment(201, "Assignment 1", points=100.0, group_id=2),
            ]),
            _group(3, "Discussions", 30.0, [
                _assignment(301, "Discussion 1", points=100.0, group_id=3),
            ]),
        ]
        result = _run_inventory(groups, weighted=True)
        assessments = {a["id"]: a for a in result["assessments"]}

        expected = 100.0 / 3.0
        assert abs(assessments[101]["grade_weight_pct"] - expected) < 0.01, (
            f"Expected {expected:.4f}, got {assessments[101]['grade_weight_pct']}"
        )
        assert abs(assessments[201]["grade_weight_pct"] - expected) < 0.01
        assert abs(assessments[301]["grade_weight_pct"] - expected) < 0.01
        total = result["totals"]["grade_weight_pct_sum"]
        assert abs(total - 100.0) < 0.5, f"Weight sum {total} not close to 100"

    def test_two_groups_summing_to_110_normalize_to_100(self):
        """Two groups at 60/50 (sum=110): normalized to 60/110 and 50/110 of 100.

        Group 1 (weight 60): item gets (60/110)*100 = 54.545...%
        Group 2 (weight 50): item gets (50/110)*100 = 45.454...%
        Sum must be ~100.
        """
        groups = [
            _group(1, "Exams", 60.0, [
                _assignment(101, "Midterm", points=100.0, group_id=1),
            ]),
            _group(2, "Papers", 50.0, [
                _assignment(201, "Paper 1", points=100.0, group_id=2),
            ]),
        ]
        result = _run_inventory(groups, weighted=True)
        assessments = {a["id"]: a for a in result["assessments"]}

        expected_101 = (60.0 / 110.0) * 100.0
        expected_201 = (50.0 / 110.0) * 100.0
        assert abs(assessments[101]["grade_weight_pct"] - expected_101) < 0.01, (
            f"Expected {expected_101:.4f}, got {assessments[101]['grade_weight_pct']}"
        )
        assert abs(assessments[201]["grade_weight_pct"] - expected_201) < 0.01
        total = result["totals"]["grade_weight_pct_sum"]
        assert abs(total - 100.0) < 0.5, f"Weight sum {total} not close to 100"

    def test_empty_weighted_group_excluded_from_denominator(self):
        """An empty group (all items unpublished) is excluded from the weight
        denominator; the remaining group's items sum to ~100.

        Groups: weight=40 (empty), weight=60 (one 100pt counted item).
        Active denominator = 60.  Sole item gets (60/60)*100 = 100%.
        """
        groups = [
            _group(1, "Empty Group", 40.0, [
                _assignment(101, "Unpublished Item", points=100.0, group_id=1,
                            published=False),
            ]),
            _group(2, "Real Group", 60.0, [
                _assignment(201, "Counted Item", points=100.0, group_id=2),
            ]),
        ]
        result = _run_inventory(groups, weighted=True)
        assessments = {a["id"]: a for a in result["assessments"]}

        # Empty group item must be zero (it is uncounted)
        assert assessments[101]["grade_weight_pct"] == 0.0
        # Real group's item must absorb 100% because it is the only active group
        assert abs(assessments[201]["grade_weight_pct"] - 100.0) < 0.01, (
            f"Expected 100.0, got {assessments[201]['grade_weight_pct']}"
        )
        total = result["totals"]["grade_weight_pct_sum"]
        assert abs(total - 100.0) < 0.5, f"Weight sum {total} not close to 100"

    def test_groups_already_summing_to_100_unchanged(self):
        """When group weights sum to 100, normalization is a no-op.

        This confirms existing passing tests are not broken: a group at 100%
        containing a single 100pt item still yields grade_weight_pct=100.0.
        """
        groups = [
            _group(1, "All", 100.0, [
                _assignment(101, "Final Exam", points=100.0, group_id=1),
            ]),
        ]
        result = _run_inventory(groups, weighted=True)
        rec = result["assessments"][0]
        assert abs(rec["grade_weight_pct"] - 100.0) < 0.01
        assert abs(result["totals"]["grade_weight_pct_sum"] - 100.0) < 0.1

    def test_five_groups_summing_to_110_common_pattern(self):
        """Five groups at 25/25/25/25/10 (sum=110): each counted item is
        normalized so total grade_weight_pct sums to ~100.

        Two groups (25/25 for the last pair) share two items each.
        """
        groups = [
            _group(1, "G1", 25.0, [_assignment(101, "A1", points=100.0, group_id=1)]),
            _group(2, "G2", 25.0, [_assignment(201, "B1", points=100.0, group_id=2)]),
            _group(3, "G3", 25.0, [_assignment(301, "C1", points=100.0, group_id=3)]),
            _group(4, "G4", 25.0, [_assignment(401, "D1", points=100.0, group_id=4)]),
            _group(5, "G5", 10.0, [_assignment(501, "E1", points=100.0, group_id=5)]),
        ]
        result = _run_inventory(groups, weighted=True)
        total = result["totals"]["grade_weight_pct_sum"]
        assert abs(total - 100.0) < 0.5, (
            f"Five groups summing to 110 produced weight sum {total}, expected ~100"
        )


# ===========================================================================
# GOLDEN TEST: WEIGHTED-MODE, ALL GROUP WEIGHTS ZERO (#219 REGRESSION)
# ===========================================================================

class TestGroupWeightAllZeroFallback:
    """apply_assignment_group_weights=True but every group_weight is still 0
    (the mid-build Canvas state right after toggling "weight final grade by
    group" on, before percentages are entered) must NOT zero every
    assessment's weight. That false 0%-coverage / no-anchor reading is wrong
    even when the course has a real proctored final. _compute_weights()
    should fall back to points-only weighting instead, and record the
    fallback in the inventory's assumptions so it is visible, not silent."""

    def test_all_group_weights_zero_falls_back_to_points_only(self):
        """Two groups, both group_weight=0, each with real counted points.

        One item is a proctored classic quiz (Respondus LockDown Browser).
        The bug zeroes grade_weight_pct for both items -- including the
        proctored final -- producing a false zero-coverage/no-anchor read.
        The fix falls back to points-only: 100/(100+50)=66.67%% and
        50/(100+50)=33.33%%.
        """
        groups = [
            _group(1, "Exams", 0.0, [
                _assignment(
                    101, "Proctored Final - Requires Respondus LockDown Browser",
                    points=100.0, group_id=1, is_quiz=True, quiz_id=88,
                    submission_types=["online_quiz"],
                ),
            ]),
            _group(2, "Homework", 0.0, [
                _assignment(201, "HW 1", points=50.0, group_id=2),
            ]),
        ]
        quizzes = [{
            "id": 88, "access_code": None, "ip_filter": None,
            "require_lockdown_browser": True,
            "require_lockdown_browser_monitor": False,
        }]
        result = _run_inventory(groups, quizzes=quizzes, weighted=True)
        assessments = {a["id"]: a for a in result["assessments"]}

        # Neither item's weight may be silently zeroed.
        assert assessments[101]["grade_weight_pct"] > 0.0, (
            "Proctored final got zero weight when all group weights are 0"
        )
        assert assessments[201]["grade_weight_pct"] > 0.0
        # Points-only fallback: 100/150 and 50/150 of 100.
        assert abs(assessments[101]["grade_weight_pct"] - (100 / 150) * 100) < 0.01
        assert abs(assessments[201]["grade_weight_pct"] - (50 / 150) * 100) < 0.01
        assert abs(result["totals"]["grade_weight_pct_sum"] - 100.0) < 0.1
        # The proctored quiz keeps its proctoring signal regardless of weighting.
        assert assessments[101]["proctoring_signal"] is True
        # The fallback must be visible in assumptions, not silent.
        assert any(
            a.startswith("weighting_fallback=points_only")
            for a in result["assumptions"]
        ), f"No weighting_fallback assumption recorded: {result['assumptions']}"

    def test_normal_weighted_course_unaffected_by_fallback(self):
        """Control: group weights summing >0 must be completely unchanged --
        no fallback should fire and no fallback assumption should appear."""
        groups = [
            _group(1, "Exams", 60.0, [
                _assignment(101, "Midterm", points=200.0, group_id=1),
            ]),
            _group(2, "HW", 40.0, [
                _assignment(201, "HW 1", points=100.0, group_id=2),
            ]),
        ]
        result = _run_inventory(groups, weighted=True)
        assessments = {a["id"]: a for a in result["assessments"]}

        assert abs(assessments[101]["grade_weight_pct"] - 60.0) < 0.01
        assert abs(assessments[201]["grade_weight_pct"] - 40.0) < 0.01
        assert not any(
            a.startswith("weighting_fallback=") for a in result["assumptions"]
        ), f"Unexpected fallback assumption on a normal weighted course: {result['assumptions']}"

    def test_truly_empty_course_group_weights_zero_stays_zero(self):
        """Control: group weights all 0 AND no counted points anywhere (every
        item omitted/unpublished) must still yield all-zero weights -- there
        is nothing to fall back to, so the fallback must not fire either."""
        groups = [
            _group(1, "Exams", 0.0, [
                _assignment(101, "Omitted", points=100.0, group_id=1, omit=True),
            ]),
            _group(2, "Homework", 0.0, [
                _assignment(201, "Unpublished", points=50.0, group_id=2, published=False),
            ]),
        ]
        result = _run_inventory(groups, weighted=True)
        assessments = {a["id"]: a for a in result["assessments"]}

        assert assessments[101]["grade_weight_pct"] == 0.0
        assert assessments[201]["grade_weight_pct"] == 0.0
        assert result["totals"]["grade_weight_pct_sum"] == 0.0
        assert not any(
            a.startswith("weighting_fallback=") for a in result["assumptions"]
        ), f"Fallback should not fire when there are no counted points: {result['assumptions']}"


# ===========================================================================
# CLO/MLO BLOOM GROUNDING
# ===========================================================================

class TestAttachBloom:
    """_attach_bloom: fill-Unknown (title verb wins, module MLO fills)."""

    def test_title_verb_wins_over_module_mlo(self):
        rec = {"title": "Analyze the Regression Case", "module": "Module 1"}
        mlos = {"Module 1": {"bloom_index": 6, "bloom_level": "Create"}}
        assurance_inventory._attach_bloom(rec, mlos)
        assert rec["bloom_level"] == "Analyze"
        assert rec["bloom_source"] == "title"

    def test_module_mlo_fills_when_title_unknown(self):
        rec = {"title": "Module 1 Homework", "module": "Module 1"}
        mlos = {"Module 1": {"bloom_index": 6, "bloom_level": "Create"}}
        assurance_inventory._attach_bloom(rec, mlos)
        assert rec["bloom_level"] == "Create"
        assert rec["bloom_index"] == 6
        assert rec["bloom_source"] == "mlo"

    def test_unknown_when_no_title_verb_and_no_module(self):
        rec = {"title": "Module 3 Homework", "module": None}
        assurance_inventory._attach_bloom(rec, {})
        assert rec["bloom_level"] == "Unknown"
        assert rec["bloom_source"] == "unknown"

    def test_unknown_when_module_has_no_mlo_entry(self):
        rec = {"title": "Weekly Task", "module": "Module 9"}
        assurance_inventory._attach_bloom(rec, {"Module 1": {"bloom_index": 3, "bloom_level": "Apply"}})
        assert rec["bloom_source"] == "unknown"

    def test_zero_index_mlo_does_not_fill(self):
        # A module whose MLOs are all Unknown (bloom_index 0) must NOT fill.
        rec = {"title": "Homework", "module": "Module 1"}
        assurance_inventory._attach_bloom(rec, {"Module 1": {"bloom_index": 0, "bloom_level": "Unknown"}})
        assert rec["bloom_source"] == "unknown"

    # -- Criterion B: Bloom inheritance cap --

    def test_objective_quiz_mlo_analyze_capped_at_understand(self):
        """Criterion B true-positive: auto-graded quiz with Analyze MLO caps at Understand."""
        rec = {
            "title": "Chapter 1 Quiz",
            "module": "Module 1",
            "engine": "classic_quiz",
            "has_rubric": False,
        }
        mlos = {"Module 1": {"bloom_index": 4, "bloom_level": "Analyze"}}
        assurance_inventory._attach_bloom(rec, mlos)
        assert rec["bloom_level"] == "Understand", "cap must apply: Analyze MLO → Understand on objective quiz"
        assert rec["bloom_source"] == "mlo"
        assert rec["bloom_mlo_capped"] is True
        assert rec["bloom_mlo_original_level"] == "Analyze"
        assert rec["bloom_mlo_original_index"] == 4

    def test_objective_quiz_mlo_create_capped_at_understand(self):
        """Criterion B true-positive: auto-graded quiz with Create MLO caps at Understand."""
        rec = {
            "title": "Module Quiz",
            "module": "Module 1",
            "engine": "new_quizzes",
            "has_rubric": False,
        }
        mlos = {"Module 1": {"bloom_index": 6, "bloom_level": "Create"}}
        assurance_inventory._attach_bloom(rec, mlos)
        assert rec["bloom_level"] == "Understand"
        assert rec["bloom_mlo_capped"] is True
        assert rec["bloom_mlo_original_level"] == "Create"

    def test_graded_discussion_mlo_create_capped_at_understand(self):
        """A graded discussion in a Create-objective module caps at Understand, no review item."""
        rec = {
            "title": "Module 1: Personal Introduction Discussion Post",
            "module": "Module 1",
            "canvas_type": "discussion",
            "engine": "graded_discussion",
            "has_rubric": False,
        }
        mlos = {"Module 1": {"bloom_index": 6, "bloom_level": "Create"}}
        assurance_inventory._attach_bloom(rec, mlos)
        assert rec["bloom_level"] == "Understand", "readings discussion must not inherit Create"
        assert rec["bloom_source"] == "mlo"
        assert rec["bloom_mlo_capped"] is False, "discussion cap is expected level, not a counterfeit signal"

    def test_rubric_scored_upload_mlo_create_unaffected(self):
        """Criterion B false-positive guard: rubric-scored upload in same module is NOT capped."""
        rec = {
            "title": "Final Reflection",
            "module": "Module 1",
            "engine": "assignment",
            "has_rubric": True,
        }
        mlos = {"Module 1": {"bloom_index": 6, "bloom_level": "Create"}}
        assurance_inventory._attach_bloom(rec, mlos)
        assert rec["bloom_level"] == "Create", "rubric-scored upload must inherit full MLO level"
        assert rec["bloom_mlo_capped"] is False

    def test_objective_quiz_mlo_apply_not_capped(self):
        """Criterion B false-positive guard: Apply (index 3) < 4, so no cap."""
        rec = {
            "title": "Comprehension Quiz",
            "module": "Module 1",
            "engine": "classic_quiz",
            "has_rubric": False,
        }
        mlos = {"Module 1": {"bloom_index": 3, "bloom_level": "Apply"}}
        assurance_inventory._attach_bloom(rec, mlos)
        assert rec["bloom_level"] == "Apply", "Apply MLO (index 3) must not be capped"
        assert rec["bloom_mlo_capped"] is False

    def test_title_verb_wins_even_on_objective_quiz_high_mlo(self):
        """Criterion B: title verb (Analyze) wins over MLO inheritance on objective quiz (no cap applies)."""
        rec = {
            "title": "Analyze the Case Study Quiz",
            "module": "Module 1",
            "engine": "classic_quiz",
            "has_rubric": False,
        }
        mlos = {"Module 1": {"bloom_index": 6, "bloom_level": "Create"}}
        assurance_inventory._attach_bloom(rec, mlos)
        # Title verb wins; cap only applies to MLO inheritance path
        assert rec["bloom_level"] == "Analyze"
        assert rec["bloom_source"] == "title"
        assert rec["bloom_mlo_capped"] is False


def _run_inventory_with_module_map(groups, module_map, quizzes=None):
    """Run build_inventory with a custom module_map and stubbed objective extraction."""
    cfg = _mock_config("12345")
    course_resp = MagicMock()
    course_resp.status_code = 200
    course_resp.json.return_value = _mock_course_obj(False)  # points-only
    empty_clos = {"clos": [], "bloom_index": 0, "bloom_level": "Unknown", "source": "none"}
    with (
        patch("assurance_inventory.canvas_api._request_with_retry", return_value=course_resp),
        patch("assurance_inventory.canvas_api.paginated_get", return_value=groups),
        patch("assurance_inventory.canvas_api.list_quizzes", return_value=quizzes or []),
        patch("assurance_inventory.canvas_api.list_discussions", return_value=[]),
        patch("assurance_inventory._build_module_map", return_value=module_map),
        patch("assurance_inventory.objective_extractor.extract_course_clos", return_value=empty_clos),
        patch("assurance_inventory.objective_extractor.extract_module_mlos", return_value={}),
    ):
        return assurance_inventory.build_inventory(cfg)


class TestQuizModuleAttribution:
    """A quiz module item carries the quiz_id as content_id; the record keys on
    the assignment id, so module lookup must fall back to quiz_id."""

    def test_quiz_maps_to_module_via_quiz_id(self):
        # Classic-quiz assignment: id=7001, quiz_id=999. Module map keyed by quiz_id.
        quiz_assignment = _assignment(7001, "Module 5 Quiz", points=10.0,
                                      submission_types=["online_quiz"], is_quiz=True, quiz_id=999)
        groups = [_group(1, "G1", 100.0, [quiz_assignment])]
        result = _run_inventory_with_module_map(groups, {("Quiz", 999): "Module 5: Diagnostics"})
        rec = next(r for r in result["assessments"] if r["id"] == 7001)
        assert rec["module"] == "Module 5: Diagnostics"

    def test_assignment_id_hit_wins_over_quiz_id(self):
        # If the assignment id IS in the map, it must win (no quiz_id override).
        quiz_assignment = _assignment(7001, "Module 5 Quiz", points=10.0,
                                      submission_types=["online_quiz"], is_quiz=True, quiz_id=999)
        groups = [_group(1, "G1", 100.0, [quiz_assignment])]
        result = _run_inventory_with_module_map(
            groups, {("Assignment", 7001): "Module 5: Direct", ("Quiz", 999): "Module 9: Wrong"})
        rec = next(r for r in result["assessments"] if r["id"] == 7001)
        assert rec["module"] == "Module 5: Direct"

    def test_quiz_id_collision_with_unrelated_assignment_id(self):
        # quiz_id (555) numerically equals an UNRELATED assignment's module-item
        # content_id. A merged int keyspace would mis-attribute the quiz to that
        # assignment's module; namespaced keys keep the spaces apart -> None.
        quiz_assignment = _assignment(7001, "Orphan Quiz", points=10.0,
                                      submission_types=["online_quiz"], is_quiz=True, quiz_id=555)
        groups = [_group(1, "G1", 100.0, [quiz_assignment])]
        module_map = {("Assignment", 555): "Module 1: Unrelated"}
        result = _run_inventory_with_module_map(groups, module_map)
        rec = next(r for r in result["assessments"] if r["id"] == 7001)
        assert rec["module"] is None

    def test_build_module_map_namespaces_by_type(self):
        # Same content_id int in two type spaces must produce two distinct keys.
        import course_navigator
        tree = [{
            "name": "Module 1",
            "items": [
                {"type": "Assignment", "content_id": 100},
                {"type": "Quiz", "content_id": 100},
                {"type": "Discussion", "content_id": 200},
            ],
        }]
        with patch.object(course_navigator, "get_tree", return_value=tree):
            m = assurance_inventory._build_module_map(_mock_config("1"), None)
        assert m[("Assignment", 100)] == "Module 1"
        assert m[("Quiz", 100)] == "Module 1"
        assert m[("Discussion", 200)] == "Module 1"
        assert len(m) == 3  # the two id-100 items did not collide

    def test_inventory_exposes_clos_and_mlos_keys(self):
        groups = [_group(1, "G1", 100.0, [_assignment(1, "HW", points=10.0)])]
        result = _run_inventory_with_module_map(groups, {})
        assert "course_clos" in result and "module_mlos" in result
        # bloom fields attached to every record
        assert "bloom_source" in result["assessments"][0]


# ===========================================================================
# DISCUSSION MODULE MEMBERSHIP (Fix B-1)
# ===========================================================================

class TestDiscussionModuleMembership:
    """Graded discussion: module_map keyed by ("Discussion", topic_id) resolves."""

    def test_discussion_maps_to_module_via_content_id(self):
        """Graded discussion whose Canvas module item has content_id=topic_id resolves to module."""
        discussion_topic = {
            "id": 55,
            "title": "Module 1 Discussion",
            "assignment_id": 201,
            "message": "Discuss X.",
        }
        groups = [
            _group(1, "Discussions", 100.0, [
                _assignment(201, "Module 1 Discussion", points=15.0,
                            submission_types=["discussion_topic"],
                            discussion_topic={"id": 55}),
            ]),
        ]
        # Module map from Canvas: Discussion item with content_id=55 in Module 1
        module_map = {("Discussion", 55): "Module 1: Introduction"}
        cfg = _mock_config("12345")
        course_resp = MagicMock()
        course_resp.status_code = 200
        course_resp.json.return_value = _mock_course_obj(False)
        empty_clos = {"clos": [], "bloom_index": 0, "bloom_level": "Unknown", "source": "none"}
        with (
            patch("assurance_inventory.canvas_api._request_with_retry", return_value=course_resp),
            patch("assurance_inventory.canvas_api.paginated_get", return_value=groups),
            patch("assurance_inventory.canvas_api.list_quizzes", return_value=[]),
            patch("assurance_inventory.canvas_api.list_discussions", return_value=[discussion_topic]),
            patch("assurance_inventory._build_module_map", return_value=module_map),
            patch("assurance_inventory.objective_extractor.extract_course_clos", return_value=empty_clos),
            patch("assurance_inventory.objective_extractor.extract_module_mlos", return_value={}),
        ):
            result = assurance_inventory.build_inventory(cfg)
        rec = next(r for r in result["assessments"] if r["id"] == 201)
        assert rec["module"] == "Module 1: Introduction", (
            f"Expected 'Module 1: Introduction' got {rec['module']!r}"
        )

    def test_discussion_module_none_when_not_in_map(self):
        """Graded discussion with no matching module item stays module=None."""
        discussion_topic = {
            "id": 66,
            "title": "Orphan Discussion",
            "assignment_id": 202,
        }
        groups = [
            _group(1, "Discussions", 100.0, [
                _assignment(202, "Orphan Discussion", points=10.0,
                            submission_types=["discussion_topic"],
                            discussion_topic={"id": 66}),
            ]),
        ]
        cfg = _mock_config("12345")
        course_resp = MagicMock()
        course_resp.status_code = 200
        course_resp.json.return_value = _mock_course_obj(False)
        empty_clos = {"clos": [], "bloom_index": 0, "bloom_level": "Unknown", "source": "none"}
        with (
            patch("assurance_inventory.canvas_api._request_with_retry", return_value=course_resp),
            patch("assurance_inventory.canvas_api.paginated_get", return_value=groups),
            patch("assurance_inventory.canvas_api.list_quizzes", return_value=[]),
            patch("assurance_inventory.canvas_api.list_discussions", return_value=[discussion_topic]),
            patch("assurance_inventory._build_module_map", return_value={}),
            patch("assurance_inventory.objective_extractor.extract_course_clos", return_value=empty_clos),
            patch("assurance_inventory.objective_extractor.extract_module_mlos", return_value={}),
        ):
            result = assurance_inventory.build_inventory(cfg)
        rec = next(r for r in result["assessments"] if r["id"] == 202)
        assert rec["module"] is None


# ===========================================================================
# OBJECTIVE FLOOR (Fix B-2)
# ===========================================================================

class TestObjectiveFloor:
    """Auto-graded objective quiz with no resolvable MLO -> Understand via objective_floor."""

    def test_quiz_with_no_module_classifies_understand(self):
        """Objective quiz with no module (no MLO) -> Understand via objective_floor."""
        rec = {
            "title": "Syllabus Quiz",
            "module": None,
            "engine": "classic_quiz",
            "has_rubric": False,
        }
        assurance_inventory._attach_bloom(rec, {})
        assert rec["bloom_level"] == "Understand"
        assert rec["bloom_source"] == "objective_floor"
        assert rec["bloom_mlo_capped"] is False

    def test_quiz_with_no_mlo_module_classifies_understand(self):
        """Objective quiz whose module has bloom_index=0 (no MLO) -> Understand via objective_floor."""
        rec = {
            "title": "Exam 1",
            "module": "Exams",
            "engine": "classic_quiz",
            "has_rubric": False,
        }
        mlos = {"Exams": {"bloom_index": 0, "bloom_level": "Unknown"}}
        assurance_inventory._attach_bloom(rec, mlos)
        assert rec["bloom_level"] == "Understand"
        assert rec["bloom_source"] == "objective_floor"

    def test_non_quiz_with_no_module_stays_unknown(self):
        """A paper assignment with no module -> Unknown (objective_floor only applies to quizzes)."""
        rec = {
            "title": "Final Paper",
            "module": None,
            "engine": "assignment",
            "has_rubric": False,
        }
        assurance_inventory._attach_bloom(rec, {})
        assert rec["bloom_level"] == "Unknown"
        assert rec["bloom_source"] == "unknown"

    def test_new_quizzes_with_no_module_classifies_understand(self):
        """new_quizzes engine without module also gets objective_floor."""
        rec = {
            "title": "Unit Exam",
            "module": None,
            "engine": "new_quizzes",
            "has_rubric": False,
        }
        assurance_inventory._attach_bloom(rec, {})
        assert rec["bloom_level"] == "Understand"
        assert rec["bloom_source"] == "objective_floor"

    def test_quiz_with_clear_title_verb_wins_over_floor(self):
        """If the quiz title has a clear Bloom verb, title wins over objective_floor."""
        rec = {
            "title": "Analyze Case Briefs Quiz",
            "module": None,
            "engine": "classic_quiz",
            "has_rubric": False,
        }
        assurance_inventory._attach_bloom(rec, {})
        assert rec["bloom_source"] == "title"
        # The title verb should win (whatever classify_blooms returns for "Analyze")
        assert rec["bloom_level"] != "Unknown"


# ===========================================================================
# DISCUSSION MODULE MEMBERSHIP (content_id == discussion topic id)
# ===========================================================================

class TestDiscussionModuleMembership:
    """Graded discussions are module items of type "Discussion" whose content_id
    is the discussion topic id (not the backing assignment id). The inventory
    must resolve the module via that topic-id lookup."""

    def _run(self, groups, module_map, discussions):
        cfg = _mock_config("12345")
        course_resp = MagicMock()
        course_resp.status_code = 200
        course_resp.json.return_value = _mock_course_obj(False)
        empty_clos = {"clos": [], "bloom_index": 0, "bloom_level": "Unknown", "source": "none"}
        with (
            patch("assurance_inventory.canvas_api._request_with_retry", return_value=course_resp),
            patch("assurance_inventory.canvas_api.paginated_get", return_value=groups),
            patch("assurance_inventory.canvas_api.list_quizzes", return_value=[]),
            patch("assurance_inventory.canvas_api.list_discussions", return_value=discussions),
            patch("assurance_inventory._build_module_map", return_value=module_map),
            patch("assurance_inventory.objective_extractor.extract_course_clos", return_value=empty_clos),
            patch("assurance_inventory.objective_extractor.extract_module_mlos", return_value={}),
        ):
            return assurance_inventory.build_inventory(cfg)

    def test_discussion_maps_to_module_via_content_id(self):
        # Module item type="Discussion", content_id=55 (the topic id). Assignment
        # id=201, discussion topic id=55. Lookup must resolve via the topic id.
        discussion_topic = {
            "id": 55,
            "title": "Module 1: Discussion",
            "assignment_id": 201,
            "message": "Discuss X.",
        }
        groups = [
            _group(1, "Discussions", 100.0, [
                _assignment(201, "Module 1: Discussion", points=15.0,
                            submission_types=["discussion_topic"],
                            discussion_topic={"id": 55}),
            ]),
        ]
        module_map = {("Discussion", 55): "Module 1: Chapters 1 & 3"}
        result = self._run(groups, module_map, [discussion_topic])
        rec = next(r for r in result["assessments"] if r["id"] == 201)
        assert rec["module"] == "Module 1: Chapters 1 & 3", (
            f"Expected module from topic-id lookup, got {rec['module']!r}"
        )

    def test_discussion_inherits_module_mlo_bloom(self):
        # With the module resolved, the discussion should inherit the module's MLO
        # Bloom level (Understand here) rather than landing Unknown.
        discussion_topic = {"id": 77, "title": "Module 2: Discussion", "assignment_id": 202}
        groups = [
            _group(1, "Discussions", 100.0, [
                _assignment(202, "Module 2: Discussion", points=10.0,
                            submission_types=["discussion_topic"],
                            discussion_topic={"id": 77}),
            ]),
        ]
        module_map = {("Discussion", 77): "Module 2"}
        cfg = _mock_config("12345")
        course_resp = MagicMock()
        course_resp.status_code = 200
        course_resp.json.return_value = _mock_course_obj(False)
        empty_clos = {"clos": [], "bloom_index": 0, "bloom_level": "Unknown", "source": "none"}
        mlos = {"Module 2": {"bloom_index": 2, "bloom_level": "Understand"}}
        with (
            patch("assurance_inventory.canvas_api._request_with_retry", return_value=course_resp),
            patch("assurance_inventory.canvas_api.paginated_get", return_value=groups),
            patch("assurance_inventory.canvas_api.list_quizzes", return_value=[]),
            patch("assurance_inventory.canvas_api.list_discussions", return_value=[discussion_topic]),
            patch("assurance_inventory._build_module_map", return_value=module_map),
            patch("assurance_inventory.objective_extractor.extract_course_clos", return_value=empty_clos),
            patch("assurance_inventory.objective_extractor.extract_module_mlos", return_value=mlos),
        ):
            result = assurance_inventory.build_inventory(cfg)
        rec = next(r for r in result["assessments"] if r["id"] == 202)
        assert rec["module"] == "Module 2"
        assert rec["bloom_level"] == "Understand"
        assert rec["bloom_source"] == "mlo"

    def test_discussion_module_none_when_not_in_map(self):
        discussion_topic = {"id": 66, "title": "Orphan Discussion", "assignment_id": 203}
        groups = [
            _group(1, "Discussions", 100.0, [
                _assignment(203, "Orphan Discussion", points=10.0,
                            submission_types=["discussion_topic"],
                            discussion_topic={"id": 66}),
            ]),
        ]
        result = self._run(groups, {}, [discussion_topic])
        rec = next(r for r in result["assessments"] if r["id"] == 203)
        assert rec["module"] is None


# ===========================================================================
# OBJECTIVE FLOOR (auto-graded objective instrument, no MLO -> Understand)
# ===========================================================================

class TestObjectiveFloor:
    """An auto-graded objective quiz/exam whose module carries no MLOs resolves
    to Understand deterministically (bloom_source="objective_floor")."""

    def test_quiz_with_no_module_classifies_understand(self):
        rec = {
            "title": "Syllabus Quiz",
            "module": None,
            "engine": "classic_quiz",
            "has_rubric": False,
        }
        assurance_inventory._attach_bloom(rec, {})
        assert rec["bloom_level"] == "Understand"
        assert rec["bloom_source"] == "objective_floor"
        assert rec["bloom_mlo_capped"] is False

    def test_quiz_with_no_mlo_module_classifies_understand(self):
        rec = {
            "title": "Exam 1",
            "module": "Exams",
            "engine": "classic_quiz",
            "has_rubric": False,
        }
        mlos = {"Exams": {"bloom_index": 0, "bloom_level": "Unknown"}}
        assurance_inventory._attach_bloom(rec, mlos)
        assert rec["bloom_level"] == "Understand"
        assert rec["bloom_source"] == "objective_floor"

    def test_new_quizzes_with_no_module_classifies_understand(self):
        rec = {
            "title": "Unit Exam",
            "module": None,
            "engine": "new_quizzes",
            "has_rubric": False,
        }
        assurance_inventory._attach_bloom(rec, {})
        assert rec["bloom_level"] == "Understand"
        assert rec["bloom_source"] == "objective_floor"

    def test_non_quiz_with_no_module_stays_unknown(self):
        # The objective floor only applies to auto-graded objective items; a paper
        # assignment with no module remains Unknown.
        rec = {
            "title": "Final Paper",
            "module": None,
            "engine": "assignment",
            "has_rubric": False,
        }
        assurance_inventory._attach_bloom(rec, {})
        assert rec["bloom_level"] == "Unknown"
        assert rec["bloom_source"] == "unknown"

    def test_rubric_quiz_with_no_module_stays_unknown(self):
        # A quiz WITH a rubric is not an objective item; no floor applies.
        rec = {
            "title": "Project Quiz",
            "module": None,
            "engine": "classic_quiz",
            "has_rubric": True,
        }
        assurance_inventory._attach_bloom(rec, {})
        assert rec["bloom_level"] == "Unknown"
        assert rec["bloom_source"] == "unknown"

    def test_title_verb_still_wins_over_objective_floor(self):
        rec = {
            "title": "Analyze the Dataset Quiz",
            "module": None,
            "engine": "classic_quiz",
            "has_rubric": False,
        }
        assurance_inventory._attach_bloom(rec, {})
        assert rec["bloom_level"] == "Analyze"
        assert rec["bloom_source"] == "title"


# ===========================================================================
# DROP RULES (assurance accuracy v2, workstream 1)
# ===========================================================================

def _group_meta_by_id(result: dict) -> dict:
    return {g["id"]: g for g in result["groups"]}


class TestDropRules:
    """drop_lowest/drop_highest reduce the group's counted-points denominator
    and zero the dropped items' weight; see docs/plan-assurance-accuracy-v2.md
    workstream 1."""

    def test_twelve_quiz_drop_two_com100_fixture(self):
        """COM 100 acceptance check: 12 quizzes @10, drop_lowest 2 -> group max
        100 pts, 10 counted, each retained quiz weight computed against 100,
        not 120."""
        quizzes = [_assignment(100 + i, f"Quiz {i}", points=10.0, group_id=1) for i in range(1, 13)]
        groups = [_group(1, "Quizzes", 100.0, quizzes, rules={"drop_lowest": 2})]
        result = _run_inventory(groups, weighted=True)
        assessments = {a["id"]: a for a in result["assessments"]}

        dropped = [a for a in assessments.values() if a["dropped_by_rule"]]
        retained = [a for a in assessments.values() if not a["dropped_by_rule"]]
        assert len(dropped) == 2
        assert len(retained) == 10
        # Tiebreak: all quizzes tie at 10 pts, so the two LOWEST ids drop.
        assert {a["id"] for a in dropped} == {101, 102}
        for a in dropped:
            assert a["grade_weight_pct"] == 0.0
        for a in retained:
            # 100% group weight / 10 retained quizzes @ equal points = 10% each.
            assert abs(a["grade_weight_pct"] - 10.0) < 0.01
        assert abs(result["totals"]["grade_weight_pct_sum"] - 100.0) < 0.1

        gm = _group_meta_by_id(result)[1]
        assert gm["counted_count"] == 12
        assert gm["counted_after_drops"] == 10
        assert gm["dropped_count"] == 2
        assert gm["counted_points_before_drops"] == 120.0
        assert gm["counted_points_after_drops"] == 100.0
        assert gm["rules"] == {"drop_lowest": 2, "drop_highest": 0, "never_drop": []}
        assert gm["dropped_ids"] == [101, 102]

    def test_never_drop_protects_lowest_item(self):
        """An item in never_drop is never selected, even if it has the fewest
        points; the rule falls through to the next-lowest droppable item."""
        items = [
            _assignment(201, "Cheapest", points=5.0, group_id=1),
            _assignment(202, "A", points=10.0, group_id=1),
            _assignment(203, "B", points=10.0, group_id=1),
            _assignment(204, "C", points=10.0, group_id=1),
        ]
        groups = [_group(1, "G", 100.0, items, rules={"drop_lowest": 1, "never_drop": [201]})]
        result = _run_inventory(groups, weighted=True)
        assessments = {a["id"]: a for a in result["assessments"]}

        assert assessments[201]["dropped_by_rule"] is False
        assert assessments[201]["grade_weight_pct"] > 0.0
        # Among the tied 10-pt items (202/203/204), the lowest id drops.
        assert assessments[202]["dropped_by_rule"] is True
        assert assessments[203]["dropped_by_rule"] is False
        assert assessments[204]["dropped_by_rule"] is False

    def test_drop_highest_removes_highest_point_items(self):
        items = [
            _assignment(301, "Small", points=5.0, group_id=1),
            _assignment(302, "Mid", points=10.0, group_id=1),
            _assignment(303, "Big", points=15.0, group_id=1),
        ]
        groups = [_group(1, "G", 100.0, items, rules={"drop_highest": 1})]
        result = _run_inventory(groups, weighted=True)
        assessments = {a["id"]: a for a in result["assessments"]}

        assert assessments[303]["dropped_by_rule"] is True
        assert assessments[301]["dropped_by_rule"] is False
        assert assessments[302]["dropped_by_rule"] is False
        gm = _group_meta_by_id(result)[1]
        assert gm["dropped_count"] == 1
        assert gm["counted_after_drops"] == 2
        assert gm["counted_points_after_drops"] == 15.0  # 5 + 10

    def test_drop_lowest_and_drop_highest_combined(self):
        """Both rules on one group: drop_lowest applies first, drop_highest
        applies to what remains -- the same item is never double-dropped."""
        items = [
            _assignment(401, "Lowest", points=5.0, group_id=1),
            _assignment(402, "Mid A", points=10.0, group_id=1),
            _assignment(403, "Mid B", points=10.0, group_id=1),
            _assignment(404, "Highest", points=20.0, group_id=1),
        ]
        groups = [_group(1, "G", 100.0, items, rules={"drop_lowest": 1, "drop_highest": 1})]
        result = _run_inventory(groups, weighted=True)
        assessments = {a["id"]: a for a in result["assessments"]}

        assert assessments[401]["dropped_by_rule"] is True
        assert assessments[404]["dropped_by_rule"] is True
        assert assessments[402]["dropped_by_rule"] is False
        assert assessments[403]["dropped_by_rule"] is False
        gm = _group_meta_by_id(result)[1]
        assert gm["dropped_count"] == 2
        assert gm["counted_after_drops"] == 2
        assert gm["counted_points_after_drops"] == 20.0  # 10 + 10

    def test_tiebreak_lowest_id_first(self):
        """Among items tied on points_possible, the LOWEST assignment id is
        the one dropped -- deterministic without student scores."""
        items = [
            _assignment(305, "C", points=10.0, group_id=1),
            _assignment(301, "A", points=10.0, group_id=1),
            _assignment(303, "B", points=10.0, group_id=1),
        ]
        groups = [_group(1, "G", 100.0, items, rules={"drop_lowest": 1})]
        result = _run_inventory(groups, weighted=True)
        assessments = {a["id"]: a for a in result["assessments"]}
        assert assessments[301]["dropped_by_rule"] is True
        assert assessments[303]["dropped_by_rule"] is False
        assert assessments[305]["dropped_by_rule"] is False

    def test_points_only_mode_honors_drops(self):
        """Drop rules apply in points-only weighting mode too, not just
        weighted_groups mode."""
        items = [
            _assignment(501, "Q1", points=10.0, group_id=1),
            _assignment(502, "Q2", points=10.0, group_id=1),
            _assignment(503, "Q3", points=10.0, group_id=1),
        ]
        groups = [_group(1, "Quizzes", 0.0, items, rules={"drop_lowest": 1})]
        result = _run_inventory(groups, weighted=False)
        assessments = {a["id"]: a for a in result["assessments"]}

        assert assessments[501]["dropped_by_rule"] is True
        assert assessments[501]["grade_weight_pct"] == 0.0
        # Remaining 2 quizzes split 100% of the points-only denominator (20 pts).
        assert abs(assessments[502]["grade_weight_pct"] - 50.0) < 0.01
        assert abs(assessments[503]["grade_weight_pct"] - 50.0) < 0.01
        assert result["course_weighting_mode"] == "points_only"

    def test_points_only_mode_with_all_zero_weight_fallback_honors_drops(self):
        """The all-group-weights-zero fallback path (weighted mode, but every
        group_weight is 0) also routes through _points_only_weight_map() and
        must honor drops there too."""
        items = [
            _assignment(601, "Q1", points=10.0, group_id=1),
            _assignment(602, "Q2", points=10.0, group_id=1),
            _assignment(603, "Q3", points=10.0, group_id=1),
        ]
        groups = [_group(1, "Quizzes", 0.0, items, rules={"drop_lowest": 1})]
        result = _run_inventory(groups, weighted=True)
        assessments = {a["id"]: a for a in result["assessments"]}

        assert assessments[601]["dropped_by_rule"] is True
        assert assessments[601]["grade_weight_pct"] == 0.0
        assert abs(assessments[602]["grade_weight_pct"] - 50.0) < 0.01
        assert abs(assessments[603]["grade_weight_pct"] - 50.0) < 0.01

    def test_no_rules_group_behavior_unchanged(self):
        """A group with an explicit empty rules dict behaves identically to a
        group with no rules key at all (regression safety)."""
        items = [
            _assignment(701, "A", points=200.0, group_id=1),
            _assignment(702, "B", points=200.0, group_id=2),
        ]
        groups_no_rules = [
            _group(1, "G1", 80.0, [items[0]]),
            _group(2, "G2", 20.0, [items[1]]),
        ]
        groups_empty_rules = [
            _group(1, "G1", 80.0, [_assignment(701, "A", points=200.0, group_id=1)], rules={}),
            _group(2, "G2", 20.0, [_assignment(702, "B", points=200.0, group_id=2)], rules={}),
        ]
        r1 = _run_inventory(groups_no_rules, weighted=True)
        r2 = _run_inventory(groups_empty_rules, weighted=True)
        a1 = {a["id"]: a for a in r1["assessments"]}
        a2 = {a["id"]: a for a in r2["assessments"]}
        assert a1[701]["grade_weight_pct"] == a2[701]["grade_weight_pct"] == 80.0
        assert a1[702]["grade_weight_pct"] == a2[702]["grade_weight_pct"] == 20.0
        assert a2[701]["dropped_by_rule"] is False
        assert a2[702]["dropped_by_rule"] is False

    def test_group_meta_present_with_no_rules(self):
        groups = [_group(1, "G", 100.0, [_assignment(801, "A", points=50.0)])]
        result = _run_inventory(groups, weighted=True)
        gm = _group_meta_by_id(result)[1]
        assert gm["counted_count"] == 1
        assert gm["counted_after_drops"] == 1
        assert gm["dropped_count"] == 0
        assert gm["dropped_ids"] == []
        assert gm["rules"] == {"drop_lowest": 0, "drop_highest": 0, "never_drop": []}
        assert gm["possible_choice_group"] is False
        assert gm["choice_signal"] is None

    def test_malformed_rules_degrade_gracefully(self):
        """A non-dict rules value, or non-numeric drop counts, must not raise
        and must degrade to "no rules" rather than crash the audit."""
        items = [_assignment(901, "A", points=10.0, group_id=1),
                 _assignment(902, "B", points=10.0, group_id=1)]
        groups = [_group(1, "G", 100.0, items, rules="not-a-dict")]
        result = _run_inventory(groups, weighted=True)
        assessments = {a["id"]: a for a in result["assessments"]}
        assert assessments[901]["dropped_by_rule"] is False
        assert assessments[902]["dropped_by_rule"] is False

        groups2 = [_group(1, "G", 100.0, items,
                           rules={"drop_lowest": "not-a-number", "never_drop": "not-a-list"})]
        result2 = _run_inventory(groups2, weighted=True)
        assessments2 = {a["id"]: a for a in result2["assessments"]}
        assert assessments2[901]["dropped_by_rule"] is False
        assert assessments2[902]["dropped_by_rule"] is False

    def test_drop_more_than_available_caps_gracefully(self):
        """drop_lowest exceeding the counted pool drops everything in the
        group without raising; the zero-denominator guard zeroes the group."""
        items = [_assignment(1001, "A", points=10.0, group_id=1),
                 _assignment(1002, "B", points=10.0, group_id=1)]
        groups = [_group(1, "G", 100.0, items, rules={"drop_lowest": 5})]
        result = _run_inventory(groups, weighted=True)
        assessments = {a["id"]: a for a in result["assessments"]}
        assert assessments[1001]["dropped_by_rule"] is True
        assert assessments[1002]["dropped_by_rule"] is True
        assert assessments[1001]["grade_weight_pct"] == 0.0
        assert assessments[1002]["grade_weight_pct"] == 0.0
        gm = _group_meta_by_id(result)[1]
        assert gm["dropped_count"] == 2
        assert gm["counted_after_drops"] == 0

    def test_drop_reduces_denominator_in_partial_weight_group(self):
        """Drops apply correctly when the group is not the whole course:
        Quizzes at 40% (5 items @20, drop_lowest 1) beside Papers at 60%."""
        quizzes = [_assignment(1100 + i, f"Q{i}", points=20.0, group_id=1) for i in range(1, 6)]
        groups = [
            _group(1, "Quizzes", 40.0, quizzes, rules={"drop_lowest": 1}),
            _group(2, "Papers", 60.0, [_assignment(1201, "Paper", points=100.0, group_id=2)]),
        ]
        result = _run_inventory(groups, weighted=True)
        assessments = {a["id"]: a for a in result["assessments"]}

        dropped = [a for a in assessments.values() if a["dropped_by_rule"]]
        assert len(dropped) == 1
        assert dropped[0]["id"] == 1101  # lowest id among the equal-point ties
        # 4 retained quizzes split 40% against an 80-pt denominator (4*20):
        # 40 * (20/80) = 10% each.
        for a in assessments.values():
            if a["id"] in (1102, 1103, 1104, 1105):
                assert abs(a["grade_weight_pct"] - 10.0) < 0.01
        assert abs(assessments[1201]["grade_weight_pct"] - 60.0) < 0.01
        assert abs(result["totals"]["grade_weight_pct_sum"] - 100.0) < 0.1


# ===========================================================================
# CHOICE-GROUP DETECTION (assurance accuracy v2, workstream 2)
# ===========================================================================

class TestChoiceGroupDetection:
    """possible_choice_group flags a likely "choose one of N" menu; it is
    flag-only and must never change counts or weights. See
    docs/plan-assurance-accuracy-v2.md workstream 2."""

    def test_ten_papers_with_sign_up_sibling_flags(self):
        """COM 100 acceptance check: Papers = 10 @50 plus a Sign Up sibling
        must trigger the flag."""
        papers = [_assignment(200 + i, f"Paper {i}", points=50.0, group_id=1) for i in range(1, 11)]
        sign_up = _assignment(299, "Sign Up for Your Paper Topic", points=0.0, group_id=1)
        groups = [_group(1, "Papers", 100.0, papers + [sign_up])]
        result = _run_inventory(groups, weighted=True)

        gm = _group_meta_by_id(result)[1]
        assert gm["possible_choice_group"] is True
        assert gm["choice_signal"] is not None
        assert "sign" in gm["choice_signal"].lower() and "up" in gm["choice_signal"].lower()

    def test_counts_and_weights_unchanged_when_flagged(self):
        """Counts/weights are UNCHANGED (still counts all N) -- the flag never
        recomputes anything."""
        papers = [_assignment(200 + i, f"Paper {i}", points=50.0, group_id=1) for i in range(1, 11)]
        sign_up = _assignment(299, "Sign Up for Your Paper Topic", points=0.0, group_id=1)
        groups = [_group(1, "Papers", 100.0, papers + [sign_up])]
        result = _run_inventory(groups, weighted=True)
        assessments = {a["id"]: a for a in result["assessments"]}

        assert len(result["assessments"]) == 11  # 10 papers + the sign-up item, all still present
        counted_papers = [assessments[200 + i] for i in range(1, 11)]
        for a in counted_papers:
            assert abs(a["grade_weight_pct"] - 10.0) < 0.01  # 100% / 10, unchanged by the flag
        assert assessments[299]["grade_weight_pct"] == 0.0  # 0-pt sign-up stays uncounted
        assert abs(result["totals"]["grade_weight_pct_sum"] - 100.0) < 0.1

    def test_no_flag_without_text_signal(self):
        """Ten equally-weighted papers with no textual choice signal anywhere
        in the group must NOT be flagged -- the structural tie alone is not
        enough."""
        papers = [_assignment(200 + i, f"Paper {i}", points=50.0, group_id=1) for i in range(1, 11)]
        groups = [_group(1, "Papers", 100.0, papers)]
        result = _run_inventory(groups, weighted=True)
        gm = _group_meta_by_id(result)[1]
        assert gm["possible_choice_group"] is False
        assert gm["choice_signal"] is None

    def test_no_flag_with_fewer_than_three_tied_siblings(self):
        """Only two siblings tied on points, even with a Sign Up sibling
        present, does not meet the >=3 structural threshold."""
        items = [
            _assignment(1, "Paper 1", points=50.0, group_id=1),
            _assignment(2, "Paper 2", points=50.0, group_id=1),
            _assignment(3, "Sign Up for Your Topic", points=0.0, group_id=1),
        ]
        groups = [_group(1, "Papers", 100.0, items)]
        result = _run_inventory(groups, weighted=True)
        gm = _group_meta_by_id(result)[1]
        assert gm["possible_choice_group"] is False

    def test_choice_signal_from_description_of_your_choice(self):
        """The text signal may appear in a sibling's description, not just
        its name."""
        items = [_assignment(10 + i, f"Item {i}", points=25.0, group_id=1) for i in range(3)]
        items[0]["description"] = "Submit one of your choice from the list below."
        groups = [_group(1, "Menu", 100.0, items)]
        result = _run_inventory(groups, weighted=True)
        gm = _group_meta_by_id(result)[1]
        assert gm["possible_choice_group"] is True
        assert "of your choice" in gm["choice_signal"].lower()

    def test_choice_signal_case_insensitive_and_hyphenated(self):
        items = [_assignment(20 + i, f"Item {i}", points=30.0, group_id=1) for i in range(3)]
        signup = _assignment(29, "SIGN-UP Sheet", points=0.0, group_id=1)
        groups = [_group(1, "Menu", 100.0, items + [signup])]
        result = _run_inventory(groups, weighted=True)
        gm = _group_meta_by_id(result)[1]
        assert gm["possible_choice_group"] is True
        assert gm["choice_signal"].lower() == "sign-up"

    def test_uncounted_sibling_still_supplies_text_signal(self):
        """A 0-point (uncounted) Sign Up sibling still supplies the text
        signal even though it never appears in the counted-siblings tally."""
        items = [_assignment(30 + i, f"Item {i}", points=40.0, group_id=1) for i in range(3)]
        # Sign-up assignment is unpublished AND 0-point -- definitely uncounted.
        signup = _assignment(39, "Sign Up Here", points=0.0, group_id=1, published=False)
        groups = [_group(1, "Menu", 100.0, items + [signup])]
        result = _run_inventory(groups, weighted=True)
        gm = _group_meta_by_id(result)[1]
        assert gm["possible_choice_group"] is True

    def test_choose_one_and_pick_one_phrases_match(self):
        for phrase in ("Choose 1 of the following", "Pick one option below", "choose one project"):
            items = [_assignment(40 + i, f"Item {i}", points=35.0, group_id=1) for i in range(3)]
            items[0]["description"] = phrase
            groups = [_group(1, "Menu", 100.0, items)]
            result = _run_inventory(groups, weighted=True)
            gm = _group_meta_by_id(result)[1]
            assert gm["possible_choice_group"] is True, f"phrase {phrase!r} did not match"
