import pytest from app.models.attachment_point import AttachmentPoint from app.models.attachment_point_name import AttachmentPointName from app.models.augmented_part import AugmentedPart from app.models.fit import Fit, FitLabel from app.models.part_type import PartType from app.models.scope_info import ScopeInfo from app.models.static_connection import StaticConnection from app.models.sub_part_type import SubPartType from app.services import scope_guide async def _seed_guide_data(db): scope_t = PartType(name="Scope") cover_t = PartType(name="Flip Cover") ard_t = PartType(name="ARD") db.add_all([scope_t, cover_t, ard_t]) await db.flush() rifle = SubPartType(name="Riflescope", part_type_id=scope_t.id) reddot = SubPartType(name="Red Dot", part_type_id=scope_t.id) db.add_all([rifle, reddot]) await db.flush() def part(desc, type_id, manufacturer_id=1, sub_part_type_id=None): p = AugmentedPart( description=desc, colour_id=1, manufacturer_id=manufacturer_id, part_type_id=type_id, sub_part_type_id=sub_part_type_id, ) db.add(p) return p s1 = part("Alpha 3-18x50", scope_t.id, manufacturer_id=10, sub_part_type_id=rifle.id) s2 = part("Zeta 1-6x24", scope_t.id, manufacturer_id=20, sub_part_type_id=reddot.id) fc1 = part("Flip cover 50mm", cover_t.id) a1 = part("ARD 50mm", ard_t.id) a2 = part("ARD 56mm", ard_t.id) await db.flush() db.add( ScopeInfo( augmented_part_id=s1.id, magnification_min=3, magnification_max=18, objective_thread_pitch="M52x0.75", ) ) def fit(ap_a, ap_b, a, b, label): db.add( Fit( attachment_point_a_id=ap_a, attachment_point_b_id=ap_b, augmented_part_a_id=a.id, augmented_part_b_id=b.id, label=label, ) ) # S1 fits FC1 (exact) via two attachment points -> should dedup to one exact. fit(1, 2, s1, fc1, FitLabel.POSSIBLE) fit(3, 4, s1, fc1, FitLabel.EXACT) # S1 fits A1 (possible); an explicit NONE to A2 must be excluded. fit(5, 6, s1, a1, FitLabel.POSSIBLE) fit(7, 8, s1, a2, FitLabel.NONE) # S2 fits FC1 (possible); order reversed to exercise the b-side branch. fit(9, 10, fc1, s2, FitLabel.POSSIBLE) await db.commit() return { "scope": scope_t, "cover": cover_t, "ard": ard_t, "s1": s1, "s2": s2, "rifle": rifle, "reddot": reddot, } @pytest.mark.asyncio async def test_build_columns_exclude_scope_type(db_sessionmaker): async with db_sessionmaker() as db: await _seed_guide_data(db) guide = await scope_guide.build(db) names = [ct.name for ct in guide["column_types"]] assert names == ["ARD", "Flip Cover"] # sorted, Scope excluded @pytest.mark.asyncio async def test_build_rows_group_fits_by_type_and_dedup(db_sessionmaker): async with db_sessionmaker() as db: ids = await _seed_guide_data(db) guide = await scope_guide.build(db) cover_id, ard_id = ids["cover"].id, ids["ard"].id rows = {r["scope"].description: r for r in guide["rows"]} assert list(rows) == ["Alpha 3-18x50", "Zeta 1-6x24"] # ordered by description s1 = rows["Alpha 3-18x50"] # Two fits to the same cover collapse to one entry, keeping the exact label. cover_cell = s1["cells"][cover_id] assert [(e["part"].description, e["label"]) for e in cover_cell] == [ ("Flip cover 50mm", "exact") ] # Possible ARD present; the NONE-labelled ARD is excluded entirely. ard_cell = s1["cells"][ard_id] assert [(e["part"].description, e["label"]) for e in ard_cell] == [ ("ARD 50mm", "possible") ] assert s1["info"] is not None assert s1["info"].magnification_max == 18 s2 = rows["Zeta 1-6x24"] assert [e["part"].description for e in s2["cells"][cover_id]] == ["Flip cover 50mm"] assert ard_id not in s2["cells"] # no ARD fits for S2 assert s2["info"] is None @pytest.mark.asyncio async def test_build_no_scope_type_returns_empty_rows(db_sessionmaker): async with db_sessionmaker() as db: db.add(PartType(name="Flip Cover")) await db.commit() guide = await scope_guide.build(db) assert guide["rows"] == [] assert [ct.name for ct in guide["column_types"]] == ["Flip Cover"] def _descriptions(guide): return [r["scope"].description for r in guide["rows"]] @pytest.mark.asyncio async def test_build_filters_by_description(db_sessionmaker): async with db_sessionmaker() as db: await _seed_guide_data(db) guide = await scope_guide.build(db, description="alpha") assert _descriptions(guide) == ["Alpha 3-18x50"] @pytest.mark.asyncio async def test_build_filters_by_manufacturer(db_sessionmaker): async with db_sessionmaker() as db: await _seed_guide_data(db) guide = await scope_guide.build(db, manufacturer_id=20) assert _descriptions(guide) == ["Zeta 1-6x24"] @pytest.mark.asyncio async def test_build_filters_by_sub_part_type(db_sessionmaker): async with db_sessionmaker() as db: ids = await _seed_guide_data(db) guide = await scope_guide.build(db, sub_part_type_id=ids["rifle"].id) assert _descriptions(guide) == ["Alpha 3-18x50"] @pytest.mark.asyncio async def test_build_filters_by_required_fit_type(db_sessionmaker): async with db_sessionmaker() as db: ids = await _seed_guide_data(db) # Only Alpha has an ARD fit; both scopes fit a flip cover. ard_only = await scope_guide.build(db, fits_part_type_id=ids["ard"].id) cover = await scope_guide.build(db, fits_part_type_id=ids["cover"].id) assert _descriptions(ard_only) == ["Alpha 3-18x50"] assert _descriptions(cover) == ["Alpha 3-18x50", "Zeta 1-6x24"] async def _web_login(client, email, password="secret123"): resp = await client.post( "/login", data={"email": email, "password": password} ) assert resp.status_code == 303 @pytest.mark.asyncio async def test_scopes_page_renders_for_reader(client, seeded, make_user): await make_user("reader@example.com", role_ids=[seeded["roles"]["user"].id]) await _web_login(client, "reader@example.com") resp = await client.get("/scopes") assert resp.status_code == 200 assert "Scopes" in resp.text @pytest.mark.asyncio async def test_scopes_page_renders_rows_and_fits( client, seeded, make_user, db_sessionmaker ): async with db_sessionmaker() as db: await _seed_guide_data(db) await make_user("reader2@example.com", role_ids=[seeded["roles"]["user"].id]) await _web_login(client, "reader2@example.com") resp = await client.get("/scopes") assert resp.status_code == 200 body = resp.text assert "Alpha 3-18x50" in body # scope row assert "Flip cover 50mm" in body # a matching part in a fit column assert "fit-badge-exact" in body # the exact fit badge assert "M52x0.75" in body # scope info: objective thread assert "3-18" in body # magnification range, integral values un-padded @pytest.mark.asyncio async def test_build_includes_objective_diameter(db_sessionmaker): async with db_sessionmaker() as db: ids = await _seed_guide_data(db) apn = AttachmentPointName(name="Objective") db.add(apn) await db.flush() db.add( AttachmentPoint( augmented_part_id=ids["s1"].id, attachment_point_name_id=apn.id, static_connection=StaticConnection(od_mm=50, od_in=1.9685), ) ) await db.commit() guide = await scope_guide.build(db) rows = {r["scope"].description: r for r in guide["rows"]} assert rows["Alpha 3-18x50"]["objective_diameter_mm"] == 50 assert rows["Zeta 1-6x24"]["objective_diameter_mm"] is None @pytest.mark.asyncio async def test_scopes_page_renders_objective_diameter_in_magnification( client, seeded, make_user, db_sessionmaker ): async with db_sessionmaker() as db: ids = await _seed_guide_data(db) apn = AttachmentPointName(name="Objective") db.add(apn) await db.flush() db.add( AttachmentPoint( augmented_part_id=ids["s1"].id, attachment_point_name_id=apn.id, static_connection=StaticConnection(od_mm=50, od_in=1.9685), ) ) await db.commit() await make_user("reader4@example.com", role_ids=[seeded["roles"]["user"].id]) await _web_login(client, "reader4@example.com") resp = await client.get("/scopes") assert resp.status_code == 200 assert "3-18x50" in resp.text @pytest.mark.asyncio async def test_scopes_filter_endpoint_narrows_rows( client, seeded, make_user, db_sessionmaker ): async with db_sessionmaker() as db: await _seed_guide_data(db) await make_user("reader3@example.com", role_ids=[seeded["roles"]["user"].id]) await _web_login(client, "reader3@example.com") resp = await client.get("/scopes/filter", params={"description": "Alpha"}) assert resp.status_code == 200 assert "Alpha 3-18x50" in resp.text assert "Zeta 1-6x24" not in resp.text @pytest.mark.asyncio async def test_scopes_filter_endpoint_forbidden_without_permission( client, seeded, make_user ): await make_user("plain2@example.com") # no roles, not a superuser await _web_login(client, "plain2@example.com") resp = await client.get("/scopes/filter") assert resp.status_code == 403 @pytest.mark.asyncio async def test_scopes_page_forbidden_without_permission(client, seeded, make_user): await make_user("plain@example.com") # no roles, not a superuser await _web_login(client, "plain@example.com") resp = await client.get("/scopes") assert resp.status_code == 403 @pytest.mark.asyncio async def test_scopes_page_redirects_when_anonymous(client): resp = await client.get("/scopes") assert resp.status_code == 302 # Deep link is preserved so the user lands back here after logging in. assert resp.headers["location"] == "/login?next=%2Fscopes"