User Story #68 » 0006-feat-ui-render-role-aware-render-safeguard-anchors-s.patch
| androidApp/src/main/java/com/aether/mofe/ui/AppShell.kt | ||
|---|---|---|
|
import com.aether.mofe.ui.design.ScreenHeader
|
||
|
import com.aether.mofe.ui.design.StatusPill
|
||
|
import com.aether.mofe.ui.design.ToastHost
|
||
|
import com.aether.mofe.engine.render.MeshDisplayPolicy
|
||
|
import com.aether.mofe.engine.render.MeshSurface
|
||
|
import com.aether.mofe.ui.design.ToastTone
|
||
|
import com.aether.mofe.ui.design.meshLabel
|
||
|
import com.aether.mofe.ui.design.meshTone
|
||
| ... | ... | |
|
val constellationFrame by app.mofeEngineHost.constellationFrame.collectAsState()
|
||
|
val geometryReport by app.mofeEngineHost.geometryReport.collectAsState()
|
||
|
// Role-aware startup surface (safeguard). A CLIENT is protected from unsolved locations
|
||
|
// pre-quorum (the scene holds); an ANCHOR/ROOT is instead SHOWN that state — coming up
|
||
|
// auto-joining its last mesh, it defaults straight to the diagnostic panel. The rule lives
|
||
|
// in engine.render.MeshDisplayPolicy (pure + unit-tested); here we only supply role + readiness.
|
||
|
val selfIsAnchorOrRoot = shared.nodes
|
||
|
.firstOrNull { it.nodeId == selfId }
|
||
|
?.meshRoles?.any { it.equals("ANCHOR", true) || it.equals("ROOT", true) } == true
|
||
|
val startRoute = when (
|
||
|
MeshDisplayPolicy.startupSurface(
|
||
|
isAnchorOrRoot = selfIsAnchorOrRoot,
|
||
|
readiness = renderReadiness,
|
||
|
autoJoinBringup = true, // first composition = app bringup into the last mesh
|
||
|
)
|
||
|
) {
|
||
|
MeshSurface.DIAGNOSTICS -> Routes.Calibrate
|
||
|
else -> Routes.Visualizer
|
||
|
}
|
||
|
val realizerScope = rememberCoroutineScope()
|
||
|
val realizerVm = remember { PredicateRealizerViewModel(app.meshRepository, realizerScope) }
|
||
| ... | ... | |
|
},
|
||
|
) {
|
||
|
Box(Modifier.fillMaxSize()) {
|
||
|
NavHost(navController = navController, startDestination = Routes.Visualizer) {
|
||
|
NavHost(navController = navController, startDestination = startRoute) {
|
||
|
// HOME is the Filament scene (3b promotion). Its own top-left hamburger opens the drawer.
|
||
|
composable(Routes.Visualizer) {
|
||
|
val anchorIds = shared.nodes
|
||
| common/src/commonMain/kotlin/com/aether/mofe/engine/render/MeshDisplayPolicy.kt | ||
|---|---|---|
|
package com.aether.mofe.engine.render
|
||
|
/**
|
||
|
* Role-aware surface selection — the second half of the render-readiness safeguard.
|
||
|
*
|
||
|
* [MeshRenderReadiness] answers the objective question *"may solved locations be drawn
|
||
|
* yet?"*. It does NOT know this device's mesh role, and the field rule depends on it:
|
||
|
*
|
||
|
* - A **client** (a plain member / observer) is *protected*. Before the mesh is
|
||
|
* [RenderReadinessState.READY] it must not be shown unsolved locations — the raw,
|
||
|
* spatially-wrong, jittery view is indistinguishable from a working one — so it
|
||
|
* HOLDS with a status. Once READY it renders the spatial scene.
|
||
|
* - An **anchor / root** is the opposite party. The pre-quorum mesh state (links,
|
||
|
* quorum progress, geometry) is exactly what its operator is there to watch, so it
|
||
|
* is shown the DIAGNOSTICS surface rather than a client hold. And when the app comes
|
||
|
* up auto-joining its last mesh *as* an anchor/root, DIAGNOSTICS is the default
|
||
|
* surface it lands on — building/monitoring the mesh is the job in front of them.
|
||
|
*
|
||
|
* Pure and platform-agnostic: the host supplies the role + readiness + bringup signal;
|
||
|
* the UI maps the resulting [MeshSurface] to its own navigation destination / content.
|
||
|
*/
|
||
|
enum class MeshSurface {
|
||
|
/** Render the 3-D / FPV spatial scene (solved node positions + FPV camera). */
|
||
|
SPATIAL_SCENE,
|
||
|
/** Hold with a status label — a client that must not draw unsolved locations yet. */
|
||
|
HOLDING,
|
||
|
/** The diagnostic panel: mesh health, quorum progress, inter-anchor links, calibration. */
|
||
|
DIAGNOSTICS,
|
||
|
}
|
||
|
/** The chosen [surface] plus a one-line [reason] for the dev diagnostic / logs. */
|
||
|
data class MeshDisplayDecision(val surface: MeshSurface, val reason: String)
|
||
|
object MeshDisplayPolicy {
|
||
|
/**
|
||
|
* Decide what this device should display.
|
||
|
*
|
||
|
* @param isAnchorOrRoot this device backs the mesh frame — it holds an ANCHOR or ROOT
|
||
|
* mesh role (as opposed to a plain client / member). The host computes it from
|
||
|
* the self node's mesh roles (or the current Raft root).
|
||
|
* @param readiness objective spatial readiness from [MeshRenderReadiness.evaluate].
|
||
|
* @param autoJoinBringup the app has just launched and is auto-joining its last mesh
|
||
|
* (i.e. this is the startup surface decision, not a mid-session re-evaluation).
|
||
|
*/
|
||
|
fun decide(
|
||
|
isAnchorOrRoot: Boolean,
|
||
|
readiness: MeshRenderReadiness,
|
||
|
autoJoinBringup: Boolean = false,
|
||
|
): MeshDisplayDecision = when {
|
||
|
// Anchor/root auto-joining its last mesh → default straight to diagnostics,
|
||
|
// regardless of readiness: establishing/monitoring the mesh is its job and the
|
||
|
// pre-quorum state is its to see. The operator can navigate to the scene.
|
||
|
isAnchorOrRoot && autoJoinBringup ->
|
||
|
MeshDisplayDecision(MeshSurface.DIAGNOSTICS, "anchor/root auto-join bringup")
|
||
|
// Anchor/root before the mesh is solvable → diagnostics, NOT a client hold: this is
|
||
|
// the device where the unsolved / quorum-building state is prevalent and wanted.
|
||
|
isAnchorOrRoot && !readiness.canRenderSpatial ->
|
||
|
MeshDisplayDecision(MeshSurface.DIAGNOSTICS, "anchor/root pre-quorum (${readiness.detail})")
|
||
|
// Everyone else falls under the client spatial gate: render the scene only when
|
||
|
// READY, otherwise HOLD so a client never shows unsolved locations.
|
||
|
readiness.canRenderSpatial ->
|
||
|
MeshDisplayDecision(MeshSurface.SPATIAL_SCENE, "ready")
|
||
|
else ->
|
||
|
MeshDisplayDecision(MeshSurface.HOLDING, readiness.label)
|
||
|
}
|
||
|
/** The app's start destination on launch — [decide] projected to its [MeshSurface]. */
|
||
|
fun startupSurface(
|
||
|
isAnchorOrRoot: Boolean,
|
||
|
readiness: MeshRenderReadiness,
|
||
|
autoJoinBringup: Boolean,
|
||
|
): MeshSurface = decide(isAnchorOrRoot, readiness, autoJoinBringup).surface
|
||
|
}
|
||
| common/src/commonTest/kotlin/com/aether/mofe/engine/render/MeshDisplayPolicyTest.kt | ||
|---|---|---|
|
package com.aether.mofe.engine.render
|
||
|
import kotlin.test.Test
|
||
|
import kotlin.test.assertEquals
|
||
|
/**
|
||
|
* The role-aware surface safeguard ([MeshDisplayPolicy]).
|
||
|
*
|
||
|
* Clients are protected from unsolved locations pre-quorum (HOLD); anchors/root are
|
||
|
* instead shown the DIAGNOSTICS surface for that same state, and default to it on
|
||
|
* auto-join bringup. Readiness alone (role-blind) can't express this — that's why the
|
||
|
* policy sits on top of [MeshRenderReadiness].
|
||
|
*/
|
||
|
class MeshDisplayPolicyTest {
|
||
|
private val ready = MeshRenderReadiness.evaluate(engineStarted = true, meshSolved = true, referenceCount = 4, selfLocalized = true)
|
||
|
private val acquiringMesh = MeshRenderReadiness.evaluate(engineStarted = true, meshSolved = false, referenceCount = 0, selfLocalized = false)
|
||
|
private val acquiringSelf = MeshRenderReadiness.evaluate(engineStarted = true, meshSolved = true, referenceCount = 4, selfLocalized = false)
|
||
|
private fun surface(isAnchorOrRoot: Boolean, r: MeshRenderReadiness, autoJoin: Boolean = false) =
|
||
|
MeshDisplayPolicy.decide(isAnchorOrRoot, r, autoJoin).surface
|
||
|
// ─────────────────────────── CLIENT: protected ───────────────────────────
|
||
|
@Test
|
||
|
fun client_holds_before_the_mesh_is_ready() {
|
||
|
assertEquals(MeshSurface.HOLDING, surface(isAnchorOrRoot = false, acquiringMesh))
|
||
|
assertEquals(MeshSurface.HOLDING, surface(isAnchorOrRoot = false, acquiringSelf))
|
||
|
}
|
||
|
@Test
|
||
|
fun client_renders_the_scene_only_when_ready() {
|
||
|
assertEquals(MeshSurface.SPATIAL_SCENE, surface(isAnchorOrRoot = false, ready))
|
||
|
}
|
||
|
@Test
|
||
|
fun client_auto_join_is_still_gated_never_shown_diagnostics_or_unsolved_locations() {
|
||
|
// autoJoin must not change the client rule — it is not an anchor/root.
|
||
|
assertEquals(MeshSurface.HOLDING, surface(isAnchorOrRoot = false, acquiringMesh, autoJoin = true))
|
||
|
assertEquals(MeshSurface.SPATIAL_SCENE, surface(isAnchorOrRoot = false, ready, autoJoin = true))
|
||
|
}
|
||
|
// ─────────────────────── ANCHOR / ROOT: informed ─────────────────────────
|
||
|
@Test
|
||
|
fun anchor_root_auto_join_bringup_defaults_to_the_diagnostic_panel() {
|
||
|
// Regardless of readiness — the whole point of the bringup default.
|
||
|
assertEquals(MeshSurface.DIAGNOSTICS, surface(isAnchorOrRoot = true, acquiringMesh, autoJoin = true))
|
||
|
assertEquals(MeshSurface.DIAGNOSTICS, surface(isAnchorOrRoot = true, ready, autoJoin = true))
|
||
|
}
|
||
|
@Test
|
||
|
fun anchor_root_sees_diagnostics_pre_quorum_not_a_client_hold() {
|
||
|
assertEquals(MeshSurface.DIAGNOSTICS, surface(isAnchorOrRoot = true, acquiringMesh))
|
||
|
assertEquals(MeshSurface.DIAGNOSTICS, surface(isAnchorOrRoot = true, acquiringSelf))
|
||
|
}
|
||
|
@Test
|
||
|
fun anchor_root_not_auto_joining_falls_through_to_the_scene_once_ready() {
|
||
|
assertEquals(MeshSurface.SPATIAL_SCENE, surface(isAnchorOrRoot = true, ready))
|
||
|
}
|
||
|
// ─────────────────────────────── contract ────────────────────────────────
|
||
|
@Test
|
||
|
fun startupSurface_matches_decide() {
|
||
|
for (anchor in listOf(false, true))
|
||
|
for (r in listOf(ready, acquiringMesh, acquiringSelf))
|
||
|
for (auto in listOf(false, true))
|
||
|
assertEquals(
|
||
|
MeshDisplayPolicy.decide(anchor, r, auto).surface,
|
||
|
MeshDisplayPolicy.startupSurface(anchor, r, auto),
|
||
|
"startupSurface must project decide().surface for anchor=$anchor auto=$auto state=${r.state}",
|
||
|
)
|
||
|
}
|
||
|
@Test
|
||
|
fun a_client_is_the_only_role_that_can_be_held() {
|
||
|
// Safeguard invariant: HOLDING is reachable only for a non-anchor (client). An
|
||
|
// anchor/root is always either shown the scene (READY) or diagnostics — never a
|
||
|
// bland hold that would deny the operator the state they need.
|
||
|
for (r in listOf(ready, acquiringMesh, acquiringSelf))
|
||
|
for (auto in listOf(false, true))
|
||
|
assertEquals(
|
||
|
false, surface(isAnchorOrRoot = true, r, auto) == MeshSurface.HOLDING,
|
||
|
"anchor/root must never be put in HOLDING (state=${r.state} auto=$auto)",
|
||
|
)
|
||
|
}
|
||
|
}
|
||