diff --git a/DESCRIPTION b/DESCRIPTION index 1d2ab94..54a38d7 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -13,9 +13,10 @@ Depends: R (>= 4.1.0) Encoding: UTF-8 Roxygen: list(markdown = TRUE) RoxygenNote: 7.3.3 -Suggests: +Suggests: testthat (>= 3.0.0), - withr + withr, + yaml Config/testthat/edition: 3 Imports: cli, diff --git a/R/registry.R b/R/registry.R new file mode 100644 index 0000000..089c3a2 --- /dev/null +++ b/R/registry.R @@ -0,0 +1,83 @@ +# Registry lifecycle state +# +# The canonical field registry lives in cvr-cortex +# (features/slicer/slicer_fields.yaml). It marks two orthogonal facts: +# +# deprecated: consumers should stop reading the key (a replacement is +# usually named in the entry description). Says nothing about +# production — several deprecated keys are still emitted. +# sunsetted: no shipping SDK emits the key. Historical data may still be +# queryable. +# +# This package has no schema-sync machinery (unlike cognitive3dpy, which +# generates its copy), so the lists below are maintained by hand. Keep them +# in sync with the registry; test-compact-columns.R cross-checks them against +# the YAML whenever a cvr-cortex checkout sits alongside this repo. +# +# Last synced against slicer_fields.yaml: 2026-08-12 + +# Raw registry keys marked "deprecated: true". +deprecated_registry_keys <- c( + "c3d.device.eyetracking.type", + "c3d.height", + "c3d.metric_components.forward_reach_score", + "c3d.metric_components.fps_data_point_count", + "c3d.metric_components.fps_data_point_sum", + "c3d.metric_components.horizontal_reach_score", + "c3d.metric_components.pitch_score", + "c3d.metric_components.roll_score", + "c3d.metric_components.vertical_reach_score", + "c3d.metrics.app_performance", + "c3d.metrics.battery_efficiency", + "c3d.metrics.boundary_score", + "c3d.metrics.controller_engagement_score", + "c3d.metrics.controller_ergonomic_score", + "c3d.metrics.controller_events_score", + "c3d.metrics.dynamic_engagement_score", + "c3d.metrics.ergonomics_score", + "c3d.metrics.head_orientation_score", + "c3d.metrics.immersion_score", + "c3d.metrics.orientation_score", + "c3d.metrics.standing_percentage", + "c3d.participant.hmdHeight", + "c3d.roomsize" +) + +# Raw registry keys marked "sunsetted: true". +sunsetted_registry_keys <- c( + "c3d.app.androidPlugin.hostName", + "c3d.app.androidPlugin.networkHostName", + "c3d.app.multiplayer.lobbyId", + "c3d.app.plugin.version", + "c3d.device.manufacturer", + "c3d.device.screenresolution", + "c3d.device.serial_number", + "c3d.device.serialnumber", + "c3d.headphonespresent", + "c3d.height", + "c3d.oculusId", + "c3d.participant.Age", + "c3d.participant.Color", + "c3d.participant.Job", + "c3d.participant.Sex", + "c3d.participant.hmdHeight", + "c3d.roomscale", + "cvr.device.graphics.memory", + "cvr.device.graphics.version", + "cvr.device.platform", + "cvr.vr.display.family", + "cvr.vr.display.model", + "cvr.vr.enabled" +) + +# The same keys as cleaned column names, matching what the parsing pipeline +# produces. +deprecated_registry_columns <- function() { + vapply(deprecated_registry_keys, clean_property_name, character(1), + USE.NAMES = FALSE) +} + +sunsetted_registry_columns <- function() { + vapply(sunsetted_registry_keys, clean_property_name, character(1), + USE.NAMES = FALSE) +} diff --git a/R/sessions.R b/R/sessions.R index d201530..c7415f1 100644 --- a/R/sessions.R +++ b/R/sessions.R @@ -383,6 +383,11 @@ to_epoch_ms <- function(x) { # --- Session response parsing --- # Column list for compact mode (~40 columns) +# +# Curated by hand — the registry does not carry per-package presentation +# intent. It does carry lifecycle state, and test-compact-columns.R fails if +# anything here is deprecated or sunsetted in the registry (see R/registry.R). +# Data dropped from this list is still returned by compact = FALSE. compact_columns <- c( # Core session fields (top-level API fields) "session_id", "session_date", "end_date", "duration_s", "hmd", "device_id", @@ -398,14 +403,12 @@ compact_columns <- c( "c3d_geo_country", "c3d_geo_subdivision", "c3d_geo_city", "c3d_roomsize_meters", # C3D properties: key metrics (top-level scores) - "c3d_metrics_fps_score", "c3d_metrics_app_performance", - "c3d_metrics_average_fps", "c3d_metrics_presence_score", - "c3d_metrics_immersion_score", "c3d_metrics_orientation_score", - "c3d_metrics_comfort_score", "c3d_metrics_ergonomics_score", - "c3d_metrics_battery_efficiency", "c3d_metrics_boundary_score", - "c3d_metrics_controller_events_score", - "c3d_metrics_controller_engagement_score", - "c3d_metrics_dynamic_engagement_score", "c3d_metrics_standing_percentage", + # c3d_metrics_average_fps: melder intends to delete the emission but the org + # dashboard tile still reads it, so the registry keeps it active. See + # cvr-cortex investigations/average-fps-intended-deletion-vs-live-consumer.md + # before removing it. + "c3d_metrics_fps_score", "c3d_metrics_average_fps", + "c3d_metrics_presence_score", "c3d_metrics_comfort_score", # C3D properties: metric components (sub-scores) "c3d_metric_components_fps_score_degree_app_performance", "c3d_metric_components_fps_score_consistency_app_performance", @@ -422,6 +425,8 @@ compact_columns <- c( "c3d_metric_components_presence_score_interruption_score", "c3d_metric_components_presence_score_controller_movement_score", "c3d_metric_components_cyberwellness_visual_continuity", + # Replaces the retired c3d.metrics.standing_percentage compute + "c3d_metric_components_posture_standing_percentage", # C3D properties: session flags "c3d_session_tag_junk", "c3d_session_tag_test" ) diff --git a/tests/testthat/test-compact-columns.R b/tests/testthat/test-compact-columns.R new file mode 100644 index 0000000..e15e3a6 --- /dev/null +++ b/tests/testthat/test-compact-columns.R @@ -0,0 +1,90 @@ +# Guard compact_columns against drift from the cortex field registry. +# +# The list is curated by hand, so it can go stale in two ways: a column can +# name a key the registry has retired, or the hand-maintained lifecycle lists +# in R/registry.R can themselves fall behind the registry. The first three +# tests always run; the last one runs only when a cvr-cortex checkout sits +# alongside this repo. + +# Registry-retired columns deliberately kept in compact output. +# Name = column, value = why it stays. Empty today. +lifecycle_exceptions <- character(0) + +cortex_yaml_path <- function() { + candidates <- c( + Sys.getenv("SLICER_YAML", unset = NA_character_), + file.path("..", "..", "..", "cvr-cortex", "features", "slicer", + "slicer_fields.yaml") + ) + candidates <- candidates[!is.na(candidates)] + hit <- candidates[file.exists(candidates)] + if (length(hit) == 0) NULL else normalizePath(hit[[1]]) +} + +test_that("compact_columns has no duplicates", { + expect_equal(anyDuplicated(compact_columns), 0) +}) + +test_that("no registry-deprecated columns in compact output", { + offenders <- setdiff( + intersect(compact_columns, deprecated_registry_columns()), + lifecycle_exceptions + ) + expect( + length(offenders) == 0, + paste( + "Deprecated in the registry:", paste(offenders, collapse = ", "), + "- drop them from compact_columns (the data stays available via", + "compact = FALSE), or add them to lifecycle_exceptions with a reason." + ) + ) +}) + +test_that("no registry-sunsetted columns in compact output", { + offenders <- setdiff( + intersect(compact_columns, sunsetted_registry_columns()), + lifecycle_exceptions + ) + expect( + length(offenders) == 0, + paste( + "Sunsetted in the registry:", paste(offenders, collapse = ", "), + "- drop them, or add them to lifecycle_exceptions with a reason." + ) + ) +}) + +test_that("lifecycle lists match the cortex registry", { + skip_if_not_installed("yaml") + yaml_path <- cortex_yaml_path() + skip_if(is.null(yaml_path), "cvr-cortex checkout not found") + + registry <- yaml::read_yaml(yaml_path) + + flagged <- function(section, grouped, flag) { + entries <- registry[[section]] + if (is.null(entries)) return(character(0)) + if (grouped) entries <- unlist(unname(entries), recursive = FALSE) + keys <- names(entries) + if (is.null(keys)) return(character(0)) + keys[vapply(entries, function(meta) isTRUE(meta[[flag]]), logical(1))] + } + + collect <- function(flag) { + sort(unique(c( + flagged("session_fields", FALSE, flag), + flagged("event_fields", FALSE, flag), + flagged("session_properties", TRUE, flag), + flagged("event_properties", TRUE, flag) + ))) + } + + expect_equal( + sort(deprecated_registry_keys), collect("deprecated"), + info = "R/registry.R deprecated_registry_keys is stale — resync it." + ) + expect_equal( + sort(sunsetted_registry_keys), collect("sunsetted"), + info = "R/registry.R sunsetted_registry_keys is stale — resync it." + ) +}) diff --git a/tests/testthat/test-sessions.R b/tests/testthat/test-sessions.R index 2079426..d4e3b45 100644 --- a/tests/testthat/test-sessions.R +++ b/tests/testthat/test-sessions.R @@ -140,7 +140,26 @@ test_that("participant_id is parsed", { test_that("comfort and engagement scores are parsed", { result <- parse_sessions(fixture$results, compact = TRUE) expect_equal(result$c3d_metrics_comfort_score[1], 74.34505247920842) - expect_equal(result$c3d_metrics_controller_engagement_score[1], 54.27067375518957) + # controller_engagement_score is deprecated in the registry, so it is no + # longer curated into compact output — still parsed in full mode. + full_result <- parse_sessions(fixture$results, compact = FALSE) + expect_equal( + full_result$c3d_metrics_controller_engagement_score[1], + 54.27067375518957 + ) +}) + +test_that("columns dropped from compact are still returned in full mode", { + full_result <- parse_sessions(fixture$results, compact = FALSE) + for (column in c( + "c3d_metrics_app_performance", + "c3d_metrics_immersion_score", + "c3d_metrics_battery_efficiency", + "c3d_metrics_standing_percentage" + )) { + expect_false(column %in% compact_columns) + expect_true(column %in% names(full_result)) + } }) test_that("parse_sessions() handles empty input", {