From f48cc878aaf347084a674526ba680c89456db7f8 Mon Sep 17 00:00:00 2001 From: JB Date: Fri, 31 Jul 2026 21:54:47 -0400 Subject: [PATCH] feat: classify gear and weapon mods --- tools/game-data-capture/contracts/README.md | 16 ++- .../contracts/domain-contract-v1.json | 12 ++ .../evidence-archive-manifest-v1.schema.json | 6 +- .../classification/gear_mod.invalid.json | 8 ++ .../classification/gear_mod.valid.json | 8 ++ .../classification/weapon_mod.invalid.json | 8 ++ .../classification/weapon_mod.valid.json | 8 ++ .../contracts/isac-handoff-v1.schema.json | 2 +- .../screenshot-classification.schema.json | 20 ++- .../src/game_data_capture/domain/models.py | 43 ++++++- .../src/game_data_capture/domain/taxonomy.py | 8 ++ .../game_data_capture/domain/validation.py | 10 +- .../src/game_data_capture/runtime.py | 4 +- .../game_data_capture/web/static/review.css | 18 ++- .../game_data_capture/web/static/review.js | 70 +++++++++-- .../web/templates/index.html | 16 ++- .../tests/contract/test_contracts.py | 118 +++++++++++++++++- .../tests/integration/storage/test_corpus.py | 97 ++++++++++++++ .../integration/test_capture_labeling.py | 42 +++++++ .../tests/integration/test_review_ui.py | 88 ++++++++++++- .../tests/unit/pipeline/test_runtime.py | 32 +++++ 21 files changed, 595 insertions(+), 39 deletions(-) create mode 100644 tools/game-data-capture/contracts/examples/classification/gear_mod.invalid.json create mode 100644 tools/game-data-capture/contracts/examples/classification/gear_mod.valid.json create mode 100644 tools/game-data-capture/contracts/examples/classification/weapon_mod.invalid.json create mode 100644 tools/game-data-capture/contracts/examples/classification/weapon_mod.valid.json diff --git a/tools/game-data-capture/contracts/README.md b/tools/game-data-capture/contracts/README.md index c94bc48..02ab64f 100644 --- a/tools/game-data-capture/contracts/README.md +++ b/tools/game-data-capture/contracts/README.md @@ -1,6 +1,6 @@ # Game Data Capture Contracts -RG-1 freezes contract version 1 for the taxonomy, workflow states, evidence model, archive manifest, and ISAC handoff boundary. +RG-1 freezes contract version 1 for the taxonomy, workflow states, evidence model, archive manifest, and ISAC handoff boundary. The approved GDC-001 mod-family change extends version 1 with `weapon_mod` and `gear_mod`. ## Files @@ -8,11 +8,23 @@ RG-1 freezes contract version 1 for the taxonomy, workflow states, evidence mode - `screenshot-classification.schema.json` validates one hierarchical screen classification. - `evidence-archive-manifest-v1.schema.json` validates a private evidence archive manifest. - `isac-handoff-v1.schema.json` validates a bounded draft handoff for authenticated ISAC intake. -- `examples/classification/` contains one valid and one invalid classification for each family, including explicit `unknown` abstention. +- `examples/classification/` contains one valid and one invalid classification for each family, including Weapon Mod, Gear Mod, and explicit `unknown` abstention. - `examples/archive/` and `examples/handoff/` exercise each versioned document schema. The schemas use JSON Schema Draft 2020-12, internal `$ref` values, standard formats, and no implementation-specific keywords. Python `jsonschema` and TypeScript validators such as Ajv can compile the files without model conversion. Consumers must reject a document unless `schema_version` equals the schema's `const` value. Consumers must also run semantic reference validation because JSON Schema cannot prove that an ID points to an item in another array. +## Mod Screen Semantics + +Repository source data and editors establish `weapon_mod` and `gear_mod` as separate canonical aggregates. Each family currently supports only the `detail` view and its same-named record type. A Mod classification never sets the six-slot `gear_slot` field. The Weapon Mod slot, quality, compatibility, secondary-option, Mod Effect, exotic-attribute, and weapon-lock data and the Gear Mod quality and secondary-option data remain evidence-backed fields or relationships on the owning aggregate. They are not separate screen views or record types. + +The `record_types` array still supports composite screens. No repository evidence establishes another independent canonical record type on a Weapon Mod or Gear Mod detail screen, so these two mappings each contain one record type. A classifier must use `unknown` with an abstention reason for another Mod view, an ambiguous family, or unsupported content. + +## Version Decision + +The approved additive taxonomy change remains schema version 1. The document shapes, trust boundary, bounds, and meanings of every existing value are unchanged, and all existing version 1 labels and documents remain valid. This utility and its payloads are prototype formats; no production-stable archive or handoff version has been declared. A version increment would force a document migration without representing a structural or semantic break. + +Consumers must update their closed version 1 vocabularies before they accept Mod labels. Older consumers reject the new values, which keeps the boundary fail closed. Existing stored labels need validation against the revised vocabulary but no value rewrite. The capture-label UI, storage regression coverage, classifier, extraction dispatch, evidence export, and ISAC adapter mappings must consume both families and their exact `detail`/record-type pairs before their respective gates can pass. This contract change does not alter those downstream implementations. + ## Evidence Rules Capture workers store source text, a normalized value, confidence, engine identity, an image digest, a bounded region, and append-only correction history for each evidence value. A null normalized value requires a `missing_reason`. A present normalized value requires a null `missing_reason`. Each correction's `before` state must match the preceding `after` state. Timestamps must increase, and the final `after` state must match the current evidence value. diff --git a/tools/game-data-capture/contracts/domain-contract-v1.json b/tools/game-data-capture/contracts/domain-contract-v1.json index fae0b1b..9ce55c6 100644 --- a/tools/game-data-capture/contracts/domain-contract-v1.json +++ b/tools/game-data-capture/contracts/domain-contract-v1.json @@ -48,6 +48,18 @@ "rank_details": ["specialization_tree_rank"] } }, + "weapon_mod": { + "gear_slot": false, + "views": { + "detail": ["weapon_mod"] + } + }, + "gear_mod": { + "gear_slot": false, + "views": { + "detail": ["gear_mod"] + } + }, "unknown": { "gear_slot": false, "views": {"unknown": []} diff --git a/tools/game-data-capture/contracts/evidence-archive-manifest-v1.schema.json b/tools/game-data-capture/contracts/evidence-archive-manifest-v1.schema.json index 61accce..92460f0 100644 --- a/tools/game-data-capture/contracts/evidence-archive-manifest-v1.schema.json +++ b/tools/game-data-capture/contracts/evidence-archive-manifest-v1.schema.json @@ -40,13 +40,13 @@ "id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"}, "sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"}, "timestamp": {"type": "string", "format": "date-time", "maxLength": 40}, - "family": {"enum": ["weapon", "gear", "skill_chip", "os_protocol", "specialization", "unknown"]}, + "family": {"enum": ["weapon", "gear", "skill_chip", "os_protocol", "specialization", "weapon_mod", "gear_mod", "unknown"]}, "recordType": {"enum": [ "weapon", "weapon_talent", "weapon_augmentation_profile", "gear", "brand_set", "gear_talent", "gear_augmentation_profile", "skill_chip_set", "skill_chip_set_bonus", "skill_chip_roll_pool", "skill_chip_talent", "os_protocol", "os_protocol_secondary_profile", "os_protocol_talent", "specialization", "specialization_focus", "specialization_ability", - "specialization_tree_node", "specialization_tree_rank" + "specialization_tree_node", "specialization_tree_rank", "weapon_mod", "gear_mod" ]}, "missingReason": {"enum": [ "not_visible", "occluded", "unreadable", "not_present_on_screen", "unsupported", @@ -177,6 +177,8 @@ {"properties": {"family": {"const": "skill_chip"}, "view": {"enum": ["detail", "set_bonus", "talent"]}, "gear_slot": {"const": null}, "record_types": {"minItems": 1, "items": {"enum": ["skill_chip_set", "skill_chip_set_bonus", "skill_chip_roll_pool", "skill_chip_talent"]}}, "abstention_reason": {"const": null}}}, {"properties": {"family": {"const": "os_protocol"}, "view": {"enum": ["detail", "secondary_profile", "talent"]}, "gear_slot": {"const": null}, "record_types": {"minItems": 1, "items": {"enum": ["os_protocol", "os_protocol_secondary_profile", "os_protocol_talent"]}}, "abstention_reason": {"const": null}}}, {"properties": {"family": {"const": "specialization"}, "view": {"enum": ["overview", "details", "ability", "focus_details", "focus_tree", "rank_details"]}, "gear_slot": {"const": null}, "record_types": {"minItems": 1, "items": {"enum": ["specialization", "specialization_focus", "specialization_ability", "specialization_tree_node", "specialization_tree_rank"]}}, "abstention_reason": {"const": null}}}, + {"properties": {"family": {"const": "weapon_mod"}, "view": {"const": "detail"}, "gear_slot": {"const": null}, "record_types": {"minItems": 1, "maxItems": 1, "items": {"const": "weapon_mod"}}, "abstention_reason": {"const": null}}}, + {"properties": {"family": {"const": "gear_mod"}, "view": {"const": "detail"}, "gear_slot": {"const": null}, "record_types": {"minItems": 1, "maxItems": 1, "items": {"const": "gear_mod"}}, "abstention_reason": {"const": null}}}, {"properties": {"family": {"const": "unknown"}, "view": {"const": "unknown"}, "gear_slot": {"const": null}, "record_types": {"maxItems": 0}, "abstention_reason": {"enum": ["low_confidence", "ambiguous_family", "ambiguous_view", "unsupported_family", "unsupported_view", "insufficient_visual_evidence", "occluded", "corrupt_image", "resource_limit", "cancelled"]}}} ] }, diff --git a/tools/game-data-capture/contracts/examples/classification/gear_mod.invalid.json b/tools/game-data-capture/contracts/examples/classification/gear_mod.invalid.json new file mode 100644 index 0000000..f89e767 --- /dev/null +++ b/tools/game-data-capture/contracts/examples/classification/gear_mod.invalid.json @@ -0,0 +1,8 @@ +{ + "family": "gear_mod", + "view": "detail", + "gear_slot": "Mask", + "record_types": ["gear_mod"], + "confidence": 0.95, + "abstention_reason": null +} diff --git a/tools/game-data-capture/contracts/examples/classification/gear_mod.valid.json b/tools/game-data-capture/contracts/examples/classification/gear_mod.valid.json new file mode 100644 index 0000000..77a7d0e --- /dev/null +++ b/tools/game-data-capture/contracts/examples/classification/gear_mod.valid.json @@ -0,0 +1,8 @@ +{ + "family": "gear_mod", + "view": "detail", + "gear_slot": null, + "record_types": ["gear_mod"], + "confidence": 0.95, + "abstention_reason": null +} diff --git a/tools/game-data-capture/contracts/examples/classification/weapon_mod.invalid.json b/tools/game-data-capture/contracts/examples/classification/weapon_mod.invalid.json new file mode 100644 index 0000000..583cab2 --- /dev/null +++ b/tools/game-data-capture/contracts/examples/classification/weapon_mod.invalid.json @@ -0,0 +1,8 @@ +{ + "family": "weapon_mod", + "view": "effect", + "gear_slot": null, + "record_types": ["weapon_mod"], + "confidence": 0.96, + "abstention_reason": null +} diff --git a/tools/game-data-capture/contracts/examples/classification/weapon_mod.valid.json b/tools/game-data-capture/contracts/examples/classification/weapon_mod.valid.json new file mode 100644 index 0000000..f496081 --- /dev/null +++ b/tools/game-data-capture/contracts/examples/classification/weapon_mod.valid.json @@ -0,0 +1,8 @@ +{ + "family": "weapon_mod", + "view": "detail", + "gear_slot": null, + "record_types": ["weapon_mod"], + "confidence": 0.96, + "abstention_reason": null +} diff --git a/tools/game-data-capture/contracts/isac-handoff-v1.schema.json b/tools/game-data-capture/contracts/isac-handoff-v1.schema.json index 8ce6b9a..16b0c97 100644 --- a/tools/game-data-capture/contracts/isac-handoff-v1.schema.json +++ b/tools/game-data-capture/contracts/isac-handoff-v1.schema.json @@ -44,7 +44,7 @@ "gear_talent", "gear_augmentation_profile", "skill_chip_set", "skill_chip_set_bonus", "skill_chip_roll_pool", "skill_chip_talent", "os_protocol", "os_protocol_secondary_profile", "os_protocol_talent", "specialization", "specialization_focus", "specialization_ability", - "specialization_tree_node", "specialization_tree_rank" + "specialization_tree_node", "specialization_tree_rank", "weapon_mod", "gear_mod" ]}, "missingReason": {"enum": [ "not_visible", "occluded", "unreadable", "not_present_on_screen", "unsupported", diff --git a/tools/game-data-capture/contracts/screenshot-classification.schema.json b/tools/game-data-capture/contracts/screenshot-classification.schema.json index 20281c5..e84d204 100644 --- a/tools/game-data-capture/contracts/screenshot-classification.schema.json +++ b/tools/game-data-capture/contracts/screenshot-classification.schema.json @@ -6,7 +6,7 @@ "additionalProperties": false, "required": ["family", "view", "gear_slot", "record_types", "confidence", "abstention_reason"], "properties": { - "family": {"enum": ["weapon", "gear", "skill_chip", "os_protocol", "specialization", "unknown"]}, + "family": {"enum": ["weapon", "gear", "skill_chip", "os_protocol", "specialization", "weapon_mod", "gear_mod", "unknown"]}, "view": {"type": "string"}, "gear_slot": {"type": ["string", "null"]}, "record_types": {"type": "array", "uniqueItems": true, "maxItems": 8, "items": {"type": "string"}}, @@ -59,6 +59,24 @@ "abstention_reason": {"const": null} } }, + { + "properties": { + "family": {"const": "weapon_mod"}, + "view": {"const": "detail"}, + "gear_slot": {"const": null}, + "record_types": {"minItems": 1, "maxItems": 1, "items": {"const": "weapon_mod"}}, + "abstention_reason": {"const": null} + } + }, + { + "properties": { + "family": {"const": "gear_mod"}, + "view": {"const": "detail"}, + "gear_slot": {"const": null}, + "record_types": {"minItems": 1, "maxItems": 1, "items": {"const": "gear_mod"}}, + "abstention_reason": {"const": null} + } + }, { "properties": { "family": {"const": "unknown"}, diff --git a/tools/game-data-capture/src/game_data_capture/domain/models.py b/tools/game-data-capture/src/game_data_capture/domain/models.py index a9ac71a..2dd7392 100644 --- a/tools/game-data-capture/src/game_data_capture/domain/models.py +++ b/tools/game-data-capture/src/game_data_capture/domain/models.py @@ -14,6 +14,39 @@ type MissingReason = typing.Literal[ "not_applicable", "redacted", ] +type ScreenshotFamily = typing.Literal[ + "weapon", + "gear", + "skill_chip", + "os_protocol", + "specialization", + "weapon_mod", + "gear_mod", + "unknown", +] +type CanonicalRecordType = typing.Literal[ + "weapon", + "weapon_talent", + "weapon_augmentation_profile", + "gear", + "brand_set", + "gear_talent", + "gear_augmentation_profile", + "skill_chip_set", + "skill_chip_set_bonus", + "skill_chip_roll_pool", + "skill_chip_talent", + "os_protocol", + "os_protocol_secondary_profile", + "os_protocol_talent", + "specialization", + "specialization_focus", + "specialization_ability", + "specialization_tree_node", + "specialization_tree_rank", + "weapon_mod", + "gear_mod", +] class SourceEngine(typing.TypedDict): @@ -76,14 +109,12 @@ class FieldClaim(typing.TypedDict): class ScreenshotClassification(typing.TypedDict): - family: typing.Literal[ - "weapon", "gear", "skill_chip", "os_protocol", "specialization", "unknown" - ] + family: ScreenshotFamily view: str gear_slot: ( typing.Literal["Mask", "Gloves", "Backpack", "Body Armor", "Holster", "Knee Pads"] | None ) - record_types: list[str] + record_types: list[CanonicalRecordType] confidence: float abstention_reason: str | None @@ -99,7 +130,7 @@ class ObservedItemInstance(typing.TypedDict): """Values shown for one configured item; they are not reusable definitions.""" instance_id: str - family: str + family: ScreenshotFamily source_screen_ids: list[str] observed_context: ObservedContext fields: list[FieldClaim] @@ -109,7 +140,7 @@ class CanonicalDefinitionCandidate(typing.TypedDict): """Evidence-backed reusable claims, kept separate from the observed item.""" candidate_id: str - record_type: str + record_type: CanonicalRecordType source_instance_ids: list[str] evidence_refs: list[str] fields: list[FieldClaim] diff --git a/tools/game-data-capture/src/game_data_capture/domain/taxonomy.py b/tools/game-data-capture/src/game_data_capture/domain/taxonomy.py index a56c743..2cd8007 100644 --- a/tools/game-data-capture/src/game_data_capture/domain/taxonomy.py +++ b/tools/game-data-capture/src/game_data_capture/domain/taxonomy.py @@ -12,6 +12,8 @@ class Family(StrEnum): SKILL_CHIP = "skill_chip" OS_PROTOCOL = "os_protocol" SPECIALIZATION = "specialization" + WEAPON_MOD = "weapon_mod" + GEAR_MOD = "gear_mod" UNKNOWN = "unknown" @@ -57,6 +59,8 @@ class RecordType(StrEnum): SPECIALIZATION_ABILITY = "specialization_ability" SPECIALIZATION_TREE_NODE = "specialization_tree_node" SPECIALIZATION_TREE_RANK = "specialization_tree_rank" + WEAPON_MOD = "weapon_mod" + GEAR_MOD = "gear_mod" SCREEN_VIEWS: Final[Mapping[Family, tuple[str, ...]]] = MappingProxyType( @@ -80,6 +84,8 @@ SCREEN_VIEWS: Final[Mapping[Family, tuple[str, ...]]] = MappingProxyType( "focus_tree", "rank_details", ), + Family.WEAPON_MOD: ("detail",), + Family.GEAR_MOD: ("detail",), Family.UNKNOWN: ("unknown",), } ) @@ -117,6 +123,8 @@ RECORD_TYPES_BY_VIEW: Final[Mapping[tuple[Family, str], tuple[RecordType, ...]]] RecordType.SPECIALIZATION_TREE_NODE, ), (Family.SPECIALIZATION, "rank_details"): (RecordType.SPECIALIZATION_TREE_RANK,), + (Family.WEAPON_MOD, "detail"): (RecordType.WEAPON_MOD,), + (Family.GEAR_MOD, "detail"): (RecordType.GEAR_MOD,), (Family.UNKNOWN, "unknown"): (), } ) diff --git a/tools/game-data-capture/src/game_data_capture/domain/validation.py b/tools/game-data-capture/src/game_data_capture/domain/validation.py index 9c7c629..9e09e05 100644 --- a/tools/game-data-capture/src/game_data_capture/domain/validation.py +++ b/tools/game-data-capture/src/game_data_capture/domain/validation.py @@ -86,7 +86,13 @@ def validate_classification(classification: Mapping[str, object]) -> None: raise ContractValidationError("record_types must contain strings") if len(record_types) != len(set(record_types)): raise ContractValidationError("record_types must be unique") - allowed = {record_type.value for record_type in taxonomy.RECORD_TYPES_BY_VIEW[(family, view)]} + if len(record_types) > 8: + raise ContractValidationError("record_types cannot contain more than 8 values") + try: + normalized_record_types = {taxonomy.RecordType(record_type) for record_type in record_types} + except ValueError as error: + raise ContractValidationError("Unknown canonical record type") from error + allowed = set(taxonomy.RECORD_TYPES_BY_VIEW[(family, view)]) if family is taxonomy.Family.UNKNOWN: if record_types: raise ContractValidationError("Unknown screens cannot claim canonical record types") @@ -94,7 +100,7 @@ def validate_classification(classification: Mapping[str, object]) -> None: if reason not in taxonomy.ABSTENTION_REASONS: raise ContractValidationError("Unknown screens require an abstention reason") else: - if not record_types or not set(record_types).issubset(allowed): + if not record_types or not normalized_record_types.issubset(allowed): raise ContractValidationError( f"record_types are incompatible with {family.value}/{view}" ) diff --git a/tools/game-data-capture/src/game_data_capture/runtime.py b/tools/game-data-capture/src/game_data_capture/runtime.py index c20c1f9..4bd32ac 100644 --- a/tools/game-data-capture/src/game_data_capture/runtime.py +++ b/tools/game-data-capture/src/game_data_capture/runtime.py @@ -40,6 +40,8 @@ from game_data_capture.web.security import new_bootstrap_token _POLICY_VERSION = "capture-v1" _PROCESSING_VERSION = "capture-v1" _POLL_SECONDS = 0.1 +# This is a bounded latest-history projection, not a corpus total or pagination. +_MAX_CAPTURE_PROJECTIONS = 256 class RuntimeFailure(RuntimeError): @@ -462,7 +464,7 @@ class CaptureRuntime: app = create_app( config=WebConfig( server=self.config.server, - max_capture_items=min(64, self.config.limits.max_queue_items), + max_capture_items=_MAX_CAPTURE_PROJECTIONS, max_image_bytes=self.config.limits.max_image_bytes, ), capture_control=control, diff --git a/tools/game-data-capture/src/game_data_capture/web/static/review.css b/tools/game-data-capture/src/game_data_capture/web/static/review.css index 0bb082f..69f1bd9 100644 --- a/tools/game-data-capture/src/game_data_capture/web/static/review.css +++ b/tools/game-data-capture/src/game_data_capture/web/static/review.css @@ -22,7 +22,7 @@ * { box-sizing: border-box; } -body { margin: 0; min-width: 20rem; background: var(--canvas); color: var(--text); } +body { margin: 0; background: var(--canvas); color: var(--text); } button, input { font: inherit; } @@ -67,15 +67,20 @@ h2 { margin-bottom: 0; font-size: 1.15rem; } .control-actions, .form-actions, .dialog-actions { display: flex; gap: .5rem; flex-wrap: wrap; } .workspace-grid { display: grid; grid-template-columns: minmax(17rem, 24rem) minmax(0, 1fr); border: 1px solid var(--line); background: var(--surface); } -.capture-panel { border-right: 1px solid var(--line); min-height: 42rem; } +.capture-panel { border-right: 1px solid var(--line); display: flex; flex-direction: column; height: min(42rem, calc(100vh - 10rem)); min-height: 24rem; min-width: 0; } .capture-panel, .review-panel { padding: 1rem; } .panel-heading { border-bottom: 1px solid var(--line); padding-bottom: .85rem; } +.queue-filter { display: flex; gap: .55rem; align-items: center; min-height: 44px; color: var(--muted); font-size: .88rem; } +.queue-filter input { inline-size: 1.1rem; block-size: 1.1rem; flex: 0 0 auto; } +.capture-scroll { min-height: 0; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; padding-right: .25rem; scrollbar-gutter: stable; } .empty-copy, .review-empty { color: var(--muted); padding: 1rem 0; line-height: 1.5; } .capture-list { margin: 0; padding: 0; list-style: none; } -.capture-list button { width: 100%; display: grid; grid-template-columns: 1fr auto; gap: .25rem .6rem; margin-top: .55rem; text-align: left; background: var(--well); } +.capture-list button { width: 100%; min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .25rem .6rem; margin-top: .55rem; text-align: left; background: var(--well); } .capture-list button[aria-current="true"] { border-color: var(--orange); color: var(--text); } -.capture-list .capture-id { overflow-wrap: anywhere; } -.capture-list .capture-meta { color: var(--muted); font-size: .8rem; } +.capture-list .capture-id { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.capture-list .capture-meta { display: grid; gap: .15rem; color: var(--muted); font-size: .72rem; line-height: 1.2; text-align: right; white-space: nowrap; } +.capture-list .capture-review { color: var(--text); font-size: .75rem; font-weight: 800; letter-spacing: .04em; } +.capture-list .capture-time { overflow: hidden; text-overflow: ellipsis; } .review-content { padding-top: 1rem; } .evidence-frame { margin: 0 0 1rem; border: 1px solid var(--line); background: var(--well); padding: .65rem; } @@ -103,7 +108,8 @@ dialog p { line-height: 1.5; } .command-header, .control-strip { align-items: flex-start; flex-direction: column; } .header-state { text-align: left; } .workspace-grid { grid-template-columns: 1fr; } - .capture-panel { min-height: auto; border-right: 0; border-bottom: 1px solid var(--line); } + .capture-panel { height: auto; min-height: 0; border-right: 0; border-bottom: 1px solid var(--line); } + .capture-scroll { overflow: visible; padding-right: 0; scrollbar-gutter: auto; } .capture-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); gap: .55rem; } .capture-list button { margin-top: 0; } } diff --git a/tools/game-data-capture/src/game_data_capture/web/static/review.js b/tools/game-data-capture/src/game_data_capture/web/static/review.js index 87e099a..9b1c9a3 100644 --- a/tools/game-data-capture/src/game_data_capture/web/static/review.js +++ b/tools/game-data-capture/src/game_data_capture/web/static/review.js @@ -4,6 +4,8 @@ const taxonomy = Object.freeze({ skill_chip: { views: ["detail", "set_bonus", "talent"], records: { detail: ["skill_chip_set", "skill_chip_roll_pool"], set_bonus: ["skill_chip_set_bonus"], talent: ["skill_chip_talent"] } }, os_protocol: { views: ["detail", "secondary_profile", "talent"], records: { detail: ["os_protocol"], secondary_profile: ["os_protocol_secondary_profile"], talent: ["os_protocol_talent"] } }, specialization: { views: ["overview", "details", "ability", "focus_details", "focus_tree", "rank_details"], records: { overview: ["specialization"], details: ["specialization"], ability: ["specialization_ability"], focus_details: ["specialization_focus"], focus_tree: ["specialization_focus", "specialization_tree_node"], rank_details: ["specialization_tree_rank"] } }, + weapon_mod: { views: ["detail"], records: { detail: ["weapon_mod"] } }, + gear_mod: { views: ["detail"], records: { detail: ["gear_mod"] } }, unknown: { views: ["unknown"], records: { unknown: [] } } }); const families = Object.keys(taxonomy); @@ -13,7 +15,7 @@ const csrfPattern = /^[A-Za-z0-9_-]{32,128}$/; const $ = (id) => document.getElementById(id); const els = Object.freeze({ - sessionState: $("session-state"), queueState: $("queue-state"), captureState: $("capture-state"), notice: $("notice"), noticeTitle: $("notice-title"), noticeCopy: $("notice-copy"), start: $("start-button"), stop: $("stop-button"), refresh: $("refresh-button"), list: $("capture-list"), listEmpty: $("capture-empty"), count: $("capture-count"), reviewTitle: $("review-title"), reviewState: $("review-state"), reviewEmpty: $("review-empty"), reviewContent: $("review-content"), media: $("capture-media"), meta: $("capture-meta"), labelForm: $("label-form"), validation: $("label-validation"), save: $("save-label-button"), reject: $("reject-button"), remove: $("delete-button"), family: $("family-options"), view: $("view-options"), gear: $("gear-slot-options"), records: $("record-type-options"), abstention: $("abstention-options"), viewFieldset: $("view-fieldset"), gearFieldset: $("gear-slot-fieldset"), recordFieldset: $("record-types-fieldset"), abstentionFieldset: $("abstention-fieldset"), dialog: $("confirmation-dialog"), dialogKicker: $("confirmation-kicker"), dialogTitle: $("confirmation-title"), dialogCopy: $("confirmation-copy"), dialogSubmit: $("confirmation-submit") + sessionState: $("session-state"), queueState: $("queue-state"), captureState: $("capture-state"), notice: $("notice"), noticeTitle: $("notice-title"), noticeCopy: $("notice-copy"), start: $("start-button"), stop: $("stop-button"), refresh: $("refresh-button"), filter: $("hide-reviewed"), list: $("capture-list"), listEmpty: $("capture-empty"), count: $("capture-count"), reviewTitle: $("review-title"), reviewState: $("review-state"), reviewEmpty: $("review-empty"), reviewContent: $("review-content"), media: $("capture-media"), meta: $("capture-meta"), labelForm: $("label-form"), validation: $("label-validation"), save: $("save-label-button"), reject: $("reject-button"), remove: $("delete-button"), family: $("family-options"), view: $("view-options"), gear: $("gear-slot-options"), records: $("record-type-options"), abstention: $("abstention-options"), viewFieldset: $("view-fieldset"), gearFieldset: $("gear-slot-fieldset"), recordFieldset: $("record-types-fieldset"), abstentionFieldset: $("abstention-fieldset"), dialog: $("confirmation-dialog"), dialogKicker: $("confirmation-kicker"), dialogTitle: $("confirmation-title"), dialogCopy: $("confirmation-copy"), dialogSubmit: $("confirmation-submit") }); let csrfToken = null; @@ -22,6 +24,7 @@ let captures = []; let armed = null; let pending = false; let returnFocus = null; +let hideReviewed = false; function setText(element, value) { element.textContent = value; } function clearNode(node) { while (node.firstChild) node.removeChild(node.firstChild); } @@ -32,12 +35,30 @@ function selected(name) { return document.querySelector(`input[name="${name}"]:c function selectedAll(name) { return [...document.querySelectorAll(`input[name="${name}"]:checked`)].map((input) => input.value); } function currentFamily() { return selected("family"); } function currentView() { return selected("view"); } +function isReviewedCapture(capture) { return capture?.classification !== null && capture?.classification !== undefined; } +function visibleCaptures() { return hideReviewed ? captures.filter((capture) => !isReviewedCapture(capture)) : captures; } +function abbreviateCaptureId(value) { return value.length <= 22 ? value : `${value.slice(0, 11)}…${value.slice(-8)}`; } +function formatCaptureTime(value) { + if (typeof value !== "string" || value.length > 64) return "TIME UNAVAILABLE"; + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp)) return "TIME UNAVAILABLE"; + const date = new Date(timestamp); + const parts = [date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate(), date.getUTCHours(), date.getUTCMinutes()]; + if (parts.some((part) => !Number.isInteger(part))) return "TIME UNAVAILABLE"; + const [year, month, day, hour, minute] = parts; + return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")} ${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")} UTC`; +} +function activeCaptureProjection() { + if (!activeCapture) return null; + return captures.find((capture) => capture.capture_id === activeCapture.capture_id) || activeCapture; +} function syncControls() { const locked = !csrfToken; els.start.disabled = pending || locked || armed === true; els.stop.disabled = pending || locked || armed !== true; els.refresh.disabled = pending || locked; + els.filter.disabled = pending || locked; els.save.disabled = pending || locked || !activeCapture || !isValidLabel(); els.reject.disabled = pending || locked || !activeCapture; els.remove.disabled = pending || locked || !activeCapture; @@ -71,7 +92,7 @@ function lock(message) { els.listEmpty.hidden = false; setText(els.listEmpty, message); clearNode(els.list); - els.count.textContent = "0 items"; + els.count.textContent = "0 shown / 0 loaded"; setBusy(false); } function validSession(payload) { @@ -178,24 +199,45 @@ function renderLabelForm(classification) { validateLabel(); } function renderCaptures() { - clearNode(els.list); els.count.textContent = `${captures.length} ${captures.length === 1 ? "item" : "items"}`; els.listEmpty.hidden = captures.length > 0; - if (!captures.length) { setText(els.listEmpty, "EMPTY QUEUE: capture evidence will appear after a manual refresh."); return; } - captures.forEach((capture) => { - const item = document.createElement("li"); const button = document.createElement("button"); button.type = "button"; button.setAttribute("aria-current", String(activeCapture?.capture_id === capture.capture_id)); - const id = document.createElement("span"); id.className = "capture-id"; id.textContent = capture.capture_id; - const meta = document.createElement("span"); meta.className = "capture-meta"; meta.textContent = `${capture.state} · ${capture.received_at}`; + const visible = visibleCaptures(); + clearNode(els.list); els.count.textContent = `${visible.length} shown / ${captures.length} loaded`; els.listEmpty.hidden = visible.length > 0; + if (!visible.length) { + setText(els.listEmpty, captures.length ? "ALL LOADED CAPTURES HIDDEN: clear Hide reviewed captures to inspect loaded captures." : "NO LOADED CAPTURES: refresh to check the latest local queue."); + return; + } + visible.forEach((capture) => { + const item = document.createElement("li"); const button = document.createElement("button"); button.type = "button"; + if (activeCapture?.capture_id === capture.capture_id) button.setAttribute("aria-current", "true"); + const reviewState = isReviewedCapture(capture) ? "REVIEWED" : "UNREVIEWED"; + const displayedTime = formatCaptureTime(capture.received_at); + button.setAttribute("aria-label", `Capture ${capture.capture_id}. ${reviewState}. ${displayedTime}.`); + const id = document.createElement("span"); id.className = "capture-id"; id.textContent = abbreviateCaptureId(capture.capture_id); + const meta = document.createElement("span"); meta.className = "capture-meta"; + const review = document.createElement("span"); review.className = "capture-review"; review.textContent = reviewState; + const time = document.createElement("span"); time.className = "capture-time"; time.textContent = displayedTime; + meta.append(review, time); button.append(id, meta); button.addEventListener("click", () => selectCapture(capture.capture_id)); item.append(button); els.list.append(item); }); syncControls(); } function focusIfAvailable(control) { if (control && !control.disabled) control.focus(); } +function focusVisibleCaptureOrFilter() { focusIfAvailable(els.list.querySelector("button:not(:disabled)") || els.filter); } +function applyReviewedFilter() { + hideReviewed = els.filter.checked; + const activeProjection = activeCaptureProjection(); + const activeIsHidden = hideReviewed && isReviewedCapture(activeProjection); + if (activeIsHidden) clearActiveDetail(); + renderCaptures(); + if (activeIsHidden) focusVisibleCaptureOrFilter(); +} async function refreshCaptures({ announce = true, fallbackFocus = null } = {}) { if (!csrfToken || pending) return false; setBusy(true); if (announce) notice("info", "LOADING", "Refreshing the local capture queue."); const payload = await request("/api/captures"); if (!payload || !Array.isArray(payload.captures)) { setBusy(false); if (announce) focusIfAvailable(fallbackFocus); return false; } captures = payload.captures; - if (activeCapture && !captures.some((capture) => capture.capture_id === activeCapture.capture_id)) clearActiveDetail(); + const activeProjection = activeCaptureProjection(); + if (activeCapture && (!activeProjection || (hideReviewed && isReviewedCapture(activeProjection)))) clearActiveDetail(); renderCaptures(); if (announce) notice("success", "QUEUE REFRESHED", "Local capture list updated."); setBusy(false); if (announce) focusIfAvailable(fallbackFocus); return true; } async function selectCapture(captureId, { announce = true } = {}) { @@ -238,6 +280,7 @@ async function confirmed(action, fallbackFocus) { focusIfAvailable(successFocus); } async function initialize() { + els.filter.checked = false; hideReviewed = false; renderLabelForm(null); let fragment = window.location.hash; let token = null; if (fragment.length > 1 && fragment.length <= 256) { const params = new URLSearchParams(fragment.slice(1)); const candidate = params.get("bootstrap"); if (candidate && csrfPattern.test(candidate)) token = candidate; } @@ -267,10 +310,15 @@ els.labelForm.addEventListener("submit", async (event) => { if (!payload || !acceptMutationCsrf(payload)) { setBusy(false); return; } setBusy(false); if (!(await refreshCaptures({ announce: false }))) { focusIfAvailable(els.save); return; } - if (captures.some((capture) => capture.capture_id === captureId)) await selectCapture(captureId, { announce: false }); + const savedCapture = captures.find((capture) => capture.capture_id === captureId); + const savedCaptureIsHidden = Boolean(savedCapture && hideReviewed && isReviewedCapture(savedCapture)); + if (savedCapture && !savedCaptureIsHidden) await selectCapture(captureId, { announce: false }); + else { clearActiveDetail(); renderCaptures(); } notice("success", "LABEL SAVED", "Manual reviewed label saved with confidence 1.0."); - focusIfAvailable(els.save); + if (savedCaptureIsHidden) focusVisibleCaptureOrFilter(); + else focusIfAvailable(els.save); }); +els.filter.addEventListener("change", applyReviewedFilter); els.reject.addEventListener("click", () => showConfirmation("reject", els.reject)); els.remove.addEventListener("click", () => showConfirmation("delete", els.remove)); els.dialog.addEventListener("close", () => { diff --git a/tools/game-data-capture/src/game_data_capture/web/templates/index.html b/tools/game-data-capture/src/game_data_capture/web/templates/index.html index 6b274a1..ef54664 100644 --- a/tools/game-data-capture/src/game_data_capture/web/templates/index.html +++ b/tools/game-data-capture/src/game_data_capture/web/templates/index.html @@ -41,15 +41,21 @@
diff --git a/tools/game-data-capture/tests/contract/test_contracts.py b/tools/game-data-capture/tests/contract/test_contracts.py index f09bed8..9026ade 100644 --- a/tools/game-data-capture/tests/contract/test_contracts.py +++ b/tools/game-data-capture/tests/contract/test_contracts.py @@ -17,7 +17,12 @@ from game_data_capture.domain.state_machines import ( InvalidStateTransition, require_transition, ) -from game_data_capture.domain.taxonomy import RECORD_TYPES_BY_VIEW, SCREEN_VIEWS, Family +from game_data_capture.domain.taxonomy import ( + RECORD_TYPES_BY_VIEW, + SCREEN_VIEWS, + Family, + RecordType, +) from game_data_capture.domain.validation import ( ContractValidationError, validate_archive_document, @@ -28,6 +33,10 @@ from game_data_capture.domain.validation import ( ROOT = Path(__file__).resolve().parents[2] CONTRACTS = ROOT / "contracts" CLASSIFICATION_EXAMPLES = CONTRACTS / "examples" / "classification" +MOD_FAMILY_CONTRACT = { + Family.WEAPON_MOD: RecordType.WEAPON_MOD, + Family.GEAR_MOD: RecordType.GEAR_MOD, +} def load_json(path: Path) -> dict[str, Any]: @@ -82,6 +91,31 @@ def test_domain_contract_and_python_taxonomy_cover_every_family_view() -> None: assert families[family.value]["views"][view] == expected +def test_mod_families_have_exact_frozen_detail_semantics() -> None: + contract = load_json(CONTRACTS / "domain-contract-v1.json") + assert contract["contract_version"] == 1 + assert set(MOD_FAMILY_CONTRACT) == {Family.WEAPON_MOD, Family.GEAR_MOD} + for family, record_type in MOD_FAMILY_CONTRACT.items(): + assert SCREEN_VIEWS[family] == ("detail",) + assert RECORD_TYPES_BY_VIEW[(family, "detail")] == (record_type,) + assert contract["families"][family.value] == { + "gear_slot": False, + "views": {"detail": [record_type.value]}, + } + + +def test_approved_taxonomy_change_remains_contract_version_one() -> None: + archive_schema = load_json(CONTRACTS / "evidence-archive-manifest-v1.schema.json") + handoff_schema = load_json(CONTRACTS / "isac-handoff-v1.schema.json") + classification_schema = load_json(CONTRACTS / "screenshot-classification.schema.json") + assert archive_schema["properties"]["schema_version"] == {"const": 1} + assert handoff_schema["properties"]["schema_version"] == {"const": 1} + assert handoff_schema["properties"]["source_archive"]["properties"]["schema_version"] == { + "const": 1 + } + assert classification_schema["$id"].endswith("screenshot-classification-v1.json") + + def test_domain_contract_and_python_state_machines_match() -> None: contract_states = load_json(CONTRACTS / "domain-contract-v1.json")["states"] assert set(contract_states) == set(STATE_MACHINES) @@ -106,6 +140,79 @@ def test_each_family_has_a_valid_and_invalid_example(family: str) -> None: validate_classification(invalid) +@pytest.mark.parametrize(("family", "record_type"), MOD_FAMILY_CONTRACT.items()) +def test_mod_family_examples_validate_through_schema_and_semantics( + family: Family, record_type: RecordType +) -> None: + validator = schema_validator("screenshot-classification.schema.json") + valid_path = CLASSIFICATION_EXAMPLES / f"{family.value}.valid.json" + invalid_path = CLASSIFICATION_EXAMPLES / f"{family.value}.invalid.json" + assert valid_path.is_file() + assert invalid_path.is_file() + valid = load_json(valid_path) + invalid = load_json(invalid_path) + assert valid == { + "family": family.value, + "view": "detail", + "gear_slot": None, + "record_types": [record_type.value], + "confidence": valid["confidence"], + "abstention_reason": None, + } + validator.validate(valid) + validate_classification(valid) + with pytest.raises(ValidationError): + validator.validate(invalid) + with pytest.raises(ContractValidationError): + validate_classification(invalid) + + +@pytest.mark.parametrize(("family", "record_type"), MOD_FAMILY_CONTRACT.items()) +def test_archive_and_handoff_schemas_accept_mod_record_types( + family: Family, record_type: RecordType +) -> None: + archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json") + archive["screens"][0]["classification"] = { + "family": family.value, + "view": "detail", + "gear_slot": None, + "record_types": [record_type.value], + "confidence": 0.96, + "abstention_reason": None, + } + archive["observed_instances"][0]["family"] = family.value + archive["canonical_candidates"][0]["record_type"] = record_type.value + schema_validator("evidence-archive-manifest-v1.schema.json").validate(archive) + validate_archive_document(archive) + + handoff = load_json(CONTRACTS / "examples" / "handoff" / "isac-handoff.valid.json") + handoff["records"][0]["record_type"] = record_type.value + schema_validator("isac-handoff-v1.schema.json").validate(handoff) + validate_handoff_document(handoff) + + +@pytest.mark.parametrize(("family", "record_type"), MOD_FAMILY_CONTRACT.items()) +@pytest.mark.parametrize( + ("field", "invalid_value"), + [ + ("view", "quality_profile"), + ("record_types", ["weapon"]), + ("record_types", ["weapon_mod_effect"]), + ("gear_slot", "Mask"), + ], +) +def test_mod_classifications_fail_closed_on_unsupported_values( + family: Family, record_type: RecordType, field: str, invalid_value: object +) -> None: + classification = load_json(CLASSIFICATION_EXAMPLES / f"{family.value}.valid.json") + assert classification["record_types"] == [record_type.value] + classification[field] = invalid_value + with pytest.raises(ValidationError): + schema_validator("screenshot-classification.schema.json").validate(classification) + with pytest.raises(ContractValidationError): + validate_classification(classification) + + def test_composite_classification_preserves_multiple_record_types() -> None: classification = load_json(CLASSIFICATION_EXAMPLES / "weapon.valid.json") validate_classification(classification) @@ -121,6 +228,15 @@ def test_unknown_enum_value_is_rejected() -> None: validate_classification(classification) +def test_semantic_validator_enforces_the_bounded_record_type_collection() -> None: + classification = load_json(CLASSIFICATION_EXAMPLES / "weapon.valid.json") + classification["record_types"] = [f"future_record_{index}" for index in range(9)] + with pytest.raises(ValidationError): + schema_validator("screenshot-classification.schema.json").validate(classification) + with pytest.raises(ContractValidationError, match="more than 8"): + validate_classification(classification) + + @pytest.mark.parametrize( ("schema_name", "valid_fixture", "invalid_fixture"), [ diff --git a/tools/game-data-capture/tests/integration/storage/test_corpus.py b/tools/game-data-capture/tests/integration/storage/test_corpus.py index c98bcba..cbf2b0a 100644 --- a/tools/game-data-capture/tests/integration/storage/test_corpus.py +++ b/tools/game-data-capture/tests/integration/storage/test_corpus.py @@ -7,6 +7,7 @@ from pathlib import Path import pytest +from game_data_capture.domain.validation import validate_classification from game_data_capture.storage import CaptureCorpus, CaptureRepository, ContentAddressedBlobStore from game_data_capture.storage.blobs import BlobCorruptionError, BlobLockError from game_data_capture.storage.database import StorageDatabase @@ -24,6 +25,17 @@ def _label(*, view: str = "detail", records: list[str] | None = None) -> dict[st } +def _mod_label(family: str) -> dict[str, object]: + return { + "family": family, + "view": "detail", + "gear_slot": None, + "record_types": [family], + "confidence": 1.0, + "abstention_reason": None, + } + + @pytest.fixture def corpus(tmp_path: Path) -> tuple[StorageDatabase, CaptureRepository, CaptureCorpus]: root = (tmp_path / "utility").resolve() @@ -420,6 +432,91 @@ def test_label_transaction_rolls_back_on_evidence_failure( assert stored is not None and '"view":"detail"' in stored["classification_json"] +def test_mod_labels_round_trip_through_restart_projections_and_shared_validation( + corpus: tuple[StorageDatabase, CaptureRepository, CaptureCorpus], + monkeypatch: pytest.MonkeyPatch, +) -> None: + database, repository, port = corpus + weapon_mod = _mod_label("weapon_mod") + gear_mod = _mod_label("gear_mod") + _receive(repository, "capture-0001", "receipt-0001") + _receive(repository, "capture-0002", "receipt-0002") + assert port.label("capture-0001", weapon_mod) == "updated" + assert port.label("capture-0002", gear_mod) == "updated" + + shared_validator = validate_classification + validated: list[dict[str, object]] = [] + + def record_shared_validation(value: object) -> None: + assert isinstance(value, dict) + validated.append(value.copy()) + shared_validator(value) + + monkeypatch.setattr( + "game_data_capture.storage.corpus.validate_classification", record_shared_validation + ) + restarted = CaptureCorpus( + database, ContentAddressedBlobStore(database.root), max_media_bytes=1024 + ) + listed = { + record.capture_id: record.classification for record in restarted.list_captures(limit=2) + } + assert listed == {"capture-0001": weapon_mod, "capture-0002": gear_mod} + weapon_detail = restarted.get_capture("capture-0001") + gear_detail = restarted.get_capture("capture-0002") + assert weapon_detail is not None and weapon_detail.classification == weapon_mod + assert gear_detail is not None and gear_detail.classification == gear_mod + assert validated == [gear_mod, weapon_mod, weapon_mod, gear_mod] + + +def test_invalid_mod_label_leaves_current_label_and_evidence_unchanged( + corpus: tuple[StorageDatabase, CaptureRepository, CaptureCorpus], +) -> None: + database, repository, port = corpus + weapon_mod = _mod_label("weapon_mod") + gear_mod = _mod_label("gear_mod") + _receive(repository, "capture-0001", "receipt-0001") + assert port.label("capture-0001", weapon_mod) == "updated" + assert port.label("capture-0001", gear_mod) == "updated" + with database.connect() as connection: + current = connection.execute( + "SELECT classification_json, revision FROM capture_labels WHERE capture_id = ?", + ("capture-0001",), + ).fetchone() + assert current is not None + before_current = tuple(current) + before_evidence = [ + tuple(row) + for row in connection.execute( + "SELECT replaced_revision, classification_json FROM capture_label_evidence " + "WHERE capture_id = ? ORDER BY replaced_revision", + ("capture-0001",), + ) + ] + malformed = {**weapon_mod, "gear_slot": "Mask"} + unsupported = {**weapon_mod, "view": "blueprint"} + for invalid in (malformed, unsupported): + with pytest.raises(ValueError, match="frozen taxonomy shape"): + port.label("capture-0001", invalid) + with database.connect() as connection: + current = connection.execute( + "SELECT classification_json, revision FROM capture_labels WHERE capture_id = ?", + ("capture-0001",), + ).fetchone() + assert current is not None + after_current = tuple(current) + after_evidence = [ + tuple(row) + for row in connection.execute( + "SELECT replaced_revision, classification_json FROM capture_label_evidence " + "WHERE capture_id = ? ORDER BY replaced_revision", + ("capture-0001",), + ) + ] + assert after_current == before_current + assert after_evidence == before_evidence + + def test_deletion_intent_recovers_after_unlink_failure( corpus: tuple[StorageDatabase, CaptureRepository, CaptureCorpus], monkeypatch: pytest.MonkeyPatch, diff --git a/tools/game-data-capture/tests/integration/test_capture_labeling.py b/tools/game-data-capture/tests/integration/test_capture_labeling.py index 07eaac5..f9dc7c0 100644 --- a/tools/game-data-capture/tests/integration/test_capture_labeling.py +++ b/tools/game-data-capture/tests/integration/test_capture_labeling.py @@ -122,6 +122,44 @@ def test_private_capture_labeling_flow_preserves_rejection_before_retention_dele assert corpus.deleted is True +def test_mod_labels_round_trip_through_the_reviewed_label_route() -> None: + token = new_bootstrap_token() + client = TestClient( + create_app( + config=WebConfig(ServerConfig()), + capture_control=CaptureControl(), + corpus=Corpus(), + bootstrap_token=token, + ), + base_url="http://127.0.0.1:8765", + ) + origin = "http://127.0.0.1:8765" + csrf = str( + client.post("/api/bootstrap", headers={"Origin": origin}, json={"token": token}).json()[ + "csrf_token" + ] + ) + + for family, record_type in (("weapon_mod", "weapon_mod"), ("gear_mod", "gear_mod")): + label = { + "family": family, + "view": "detail", + "gear_slot": None, + "record_types": [record_type], + "confidence": 1.0, + "abstention_reason": None, + } + response = client.post( + f"/api/captures/{_ID}/label", + headers={"Origin": origin, "X-CSRF-Token": csrf}, + json=label, + ) + + assert response.status_code == 200 + csrf = str(response.json()["csrf_token"]) + assert client.get(f"/api/captures/{_ID}").json()["capture"]["classification"] == label + + def test_review_ui_uses_local_external_assets_and_keeps_guardrails_in_static_source() -> None: web_root = Path(__file__).parents[2] / "src" / "game_data_capture" / "web" template = (web_root / "templates" / "index.html").read_text(encoding="utf-8") @@ -146,12 +184,15 @@ def test_review_ui_uses_local_external_assets_and_keeps_guardrails_in_static_sou assert 'credentials: "same-origin"' in script assert "localStorage" not in script assert "sessionStorage" not in script + assert "document.cookie" not in script for expected in ( "weapon", "gear", "skill_chip", "os_protocol", "specialization", + "weapon_mod", + "gear_mod", "unknown", "Mask", "Gloves", @@ -193,3 +234,4 @@ def test_review_page_and_local_static_assets_serve_with_security_headers() -> No assert "csrfPattern" in script.text assert "setCheckedMembers" in script.text assert "announce: false" in script.text + assert 'id="hide-reviewed"' in page.text diff --git a/tools/game-data-capture/tests/integration/test_review_ui.py b/tools/game-data-capture/tests/integration/test_review_ui.py index d6822cb..1014884 100644 --- a/tools/game-data-capture/tests/integration/test_review_ui.py +++ b/tools/game-data-capture/tests/integration/test_review_ui.py @@ -85,6 +85,92 @@ def test_review_script_guards_session_taxonomy_pending_and_selection_state() -> assert "fallbackFocus?.focus();" in script assert "function clearActiveDetail()" in script assert 'if (action === "delete") clearActiveDetail();' in script - assert "captures.some((capture) => capture.capture_id === captureId)" in script + assert ( + "const savedCapture = captures.find((capture) => capture.capture_id === captureId);" + in script + ) + assert "if (savedCapture && !savedCaptureIsHidden) await selectCapture" in script + assert "else { clearActiveDetail(); renderCaptures(); }" in script assert "refreshCaptures({ fallbackFocus: els.refresh })" in script assert "focusIfAvailable(els.save);" in script + + +def test_queue_filter_keeps_reviewed_captures_local_and_focus_safe() -> None: + web_root = Path(__file__).parents[2] / "src" / "game_data_capture" / "web" + template = (web_root / "templates" / "index.html").read_text(encoding="utf-8") + script = (web_root / "static" / "review.js").read_text(encoding="utf-8") + stylesheet = (web_root / "static" / "review.css").read_text(encoding="utf-8") + + assert '' in template + assert "Hide reviewed captures" in template + assert ( + "checked" + not in template[ + template.index('id="hide-reviewed"') - 20 : template.index('id="hide-reviewed"') + 50 + ] + ) + assert "let hideReviewed = false;" in script + assert "els.filter.checked = false; hideReviewed = false;" in script + assert "function isReviewedCapture(capture)" in script + assert "capture?.classification !== null" in script + assert "function visibleCaptures()" in script + assert "captures.filter((capture) => !isReviewedCapture(capture))" in script + assert "`${visible.length} shown / ${captures.length} loaded`" in script + assert "ALL LOADED CAPTURES HIDDEN" in script + assert 'button.setAttribute("aria-current", "true")' in script + assert "els.filter.disabled = pending || locked;" in script + assert 'els.filter.addEventListener("change", applyReviewedFilter);' in script + filter_start = script.index("function applyReviewedFilter()") + filter_end = script.index("async function refreshCaptures", filter_start) + filter_source = script[filter_start:filter_end] + assert "const activeProjection = activeCaptureProjection();" in filter_source + assert ( + "const activeIsHidden = hideReviewed && isReviewedCapture(activeProjection);" + in filter_source + ) + assert "isReviewedCapture(activeCapture)" not in filter_source + assert "function activeCaptureProjection()" in script + assert ( + "captures.find((capture) => capture.capture_id === activeCapture.capture_id)" + " || activeCapture" in script + ) + assert "if (activeIsHidden) clearActiveDetail();" in script + assert "function focusVisibleCaptureOrFilter()" in script + assert 'els.list.querySelector("button:not(:disabled)") || els.filter' in script + assert "const savedCaptureIsHidden" in script + assert "if (savedCapture && !savedCaptureIsHidden) await selectCapture" in script + assert "if (savedCaptureIsHidden) focusVisibleCaptureOrFilter();" in script + assert ".capture-scroll { min-height: 0; overflow-x: hidden; overflow-y: auto;" in stylesheet + assert ".capture-panel { height: auto; min-height: 0;" in stylesheet + assert ".capture-scroll { overflow: visible;" in stylesheet + assert "body { margin: 0; min-width:" not in stylesheet + assert "function abbreviateCaptureId(value)" in script + assert "value.slice(0, 11)" in script + assert "function formatCaptureTime(value)" in script + assert '"TIME UNAVAILABLE"' in script + assert 'typeof value !== "string" || value.length > 64' in script + assert 'const reviewState = isReviewedCapture(capture) ? "REVIEWED" : "UNREVIEWED";' in script + assert 'button.setAttribute("aria-label", `Capture ${capture.capture_id}.' in script + assert "id.textContent = abbreviateCaptureId(capture.capture_id);" in script + assert "selectCapture(capture.capture_id)" in script + assert "els.reviewTitle.textContent = activeCapture.capture_id;" in script + assert ".capture-list .capture-id { min-width: 0; overflow: hidden;" in stylesheet + assert "text-overflow: ellipsis" in stylesheet + assert ".capture-list .capture-review" in stylesheet + + +def test_mod_taxonomy_maps_exact_labels_without_gear_slots_and_restores_them() -> None: + script = ( + Path(__file__).parents[2] / "src" / "game_data_capture" / "web" / "static" / "review.js" + ).read_text(encoding="utf-8") + + for family, record_type in (("weapon_mod", "weapon_mod"), ("gear_mod", "gear_mod")): + assert ( + f'{family}: {{ views: ["detail"], records: {{ detail: ["{record_type}"] }} }}' in script + ) + assert 'const isGear = family === "gear";' in script + assert "if (isGear) gearSlots.forEach" in script + assert 'gear_slot: family === "gear" ? selected("gear_slot") : null' in script + assert 'setChecked("family", classification.family);' in script + assert 'setChecked("view", classification.view);' in script + assert 'setCheckedMembers("record_type", classification.record_types || []);' in script diff --git a/tools/game-data-capture/tests/unit/pipeline/test_runtime.py b/tools/game-data-capture/tests/unit/pipeline/test_runtime.py index 79dd5df..d8d7f0e 100644 --- a/tools/game-data-capture/tests/unit/pipeline/test_runtime.py +++ b/tools/game-data-capture/tests/unit/pipeline/test_runtime.py @@ -25,6 +25,7 @@ from game_data_capture.runtime import ( _CaptureControl, ) from game_data_capture.storage import CaptureCorpus, CaptureRepository, StorageDatabase +from game_data_capture.web.app import WebConfig _PNG = PNG_SIGNATURE + b"unit-png" @@ -451,6 +452,37 @@ def test_serve_constructs_durable_corpus_without_auto_arming_and_uses_one_fragme reusable.release() +def test_serve_loads_bounded_corpus_history_independently_of_intake_capacity( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + runtime = CaptureRuntime( + AppConfig( + limits=CaptureLimits(max_queue_items=64), + lifecycle=LifecycleConfig(lock_timeout_seconds=0), + ), + _paths(tmp_path), + migrations_dir=(Path(__file__).parents[3] / "migrations").resolve(), + ) + captured: dict[str, object] = {} + + def factory(**kwargs: object) -> object: + captured.update(kwargs) + return object() + + def runner(_: object, **__: object) -> None: + control = cast(_CaptureControl, captured["capture_control"]) + assert control.status().queue_capacity == runtime.config.limits.max_queue_items + + monkeypatch.setattr(runtime_module, "create_app", factory) + monkeypatch.setattr(uvicorn, "run", runner) + + runtime.serve() + + web_config = cast(WebConfig, captured["config"]) + assert web_config.max_capture_items == 256 + assert web_config.max_capture_items > runtime.config.limits.max_queue_items + + def test_service_startup_reconciliation_failure_releases_the_instance_lock( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: