feat: classify gear and weapon mods
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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": []}
|
||||
|
||||
@@ -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"]}}}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"family": "gear_mod",
|
||||
"view": "detail",
|
||||
"gear_slot": "Mask",
|
||||
"record_types": ["gear_mod"],
|
||||
"confidence": 0.95,
|
||||
"abstention_reason": null
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"family": "gear_mod",
|
||||
"view": "detail",
|
||||
"gear_slot": null,
|
||||
"record_types": ["gear_mod"],
|
||||
"confidence": 0.95,
|
||||
"abstention_reason": null
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"family": "weapon_mod",
|
||||
"view": "effect",
|
||||
"gear_slot": null,
|
||||
"record_types": ["weapon_mod"],
|
||||
"confidence": 0.96,
|
||||
"abstention_reason": null
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"family": "weapon_mod",
|
||||
"view": "detail",
|
||||
"gear_slot": null,
|
||||
"record_types": ["weapon_mod"],
|
||||
"confidence": 0.96,
|
||||
"abstention_reason": null
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"): (),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -46,10 +46,16 @@
|
||||
<p class="kicker">PRIVATE QUEUE</p>
|
||||
<h2 id="capture-list-title">Captures</h2>
|
||||
</div>
|
||||
<span id="capture-count">0 items</span>
|
||||
<span id="capture-count">0 shown / 0 loaded</span>
|
||||
</div>
|
||||
<label class="queue-filter" for="hide-reviewed">
|
||||
<input id="hide-reviewed" type="checkbox" />
|
||||
<span>Hide reviewed captures</span>
|
||||
</label>
|
||||
<div class="capture-scroll" id="capture-scroll">
|
||||
<p class="empty-copy" id="capture-empty">Authenticate to inspect the local capture queue.</p>
|
||||
<ol class="capture-list" id="capture-list" aria-label="Available captures"></ol>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="review-panel" aria-labelledby="review-title">
|
||||
|
||||
@@ -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"),
|
||||
[
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 '<input id="hide-reviewed" type="checkbox" />' 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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user