diff --git a/app/src/main/java/com/runicgateway/app/core/time/Instants.kt b/app/src/main/java/com/runicgateway/app/core/time/Instants.kt new file mode 100644 index 0000000..af4494d --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/core/time/Instants.kt @@ -0,0 +1,41 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.core.time + +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId + +/** + * Parse a timestamp off the wire, in either shape the backend sends. + * + * **Which one arrives is not the app's to decide.** Express serializes a `Date` + * to ISO-8601 with a `Z`, but these values start life as MariaDB `DATETIME` + * columns, and one read back as a string reaches the wire as + * `2026-08-31 07:13:50` with no zone at all. A zoneless stamp is read as **UTC**, + * because that is what the server stores — reading it as local time would + * silently shift every timestamp by the device's offset, which is a bug that + * looks right on the machine it was written on. + * + * Anything unparseable answers null, and every caller is expected to render + * *something* without it: a notification with an odd date is still worth reading, + * and an event with one is still worth listing. + * + * Lives here rather than beside either caller because the trap is the wire's, not + * one screen's — the inbox found it (ENGAGEMENT.md phase 8) and the event screens + * inherit it (EVENTS.md §I). + */ +fun parseWireInstant(raw: String?): Instant? { + val text = raw?.trim().orEmpty() + if (text.isEmpty()) return null + return try { + Instant.parse(text) + } catch (_: Exception) { + try { + LocalDateTime.parse(text.replace(' ', 'T')).atZone(ZoneId.of("UTC")).toInstant() + } catch (_: Exception) { + null + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/data/api/EventsApi.kt b/app/src/main/java/com/runicgateway/app/data/api/EventsApi.kt new file mode 100644 index 0000000..ed49068 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/api/EventsApi.kt @@ -0,0 +1,84 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.data.api + +import com.runicgateway.app.data.api.dto.EventCalendarDto +import com.runicgateway.app.data.api.dto.EventHistoryDto +import com.runicgateway.app.data.api.dto.EventSeriesResponse +import com.runicgateway.app.data.api.dto.PublicEventResponse +import retrofit2.http.GET +import retrofit2.http.Path +import retrofit2.http.Query + +/** + * The event surface (PLAN.md §9 M13, `docs/website/EVENTS.md` § API surface). + * + * **These are CORE routes, not a module's**, which is why they live here rather + * than beside the shard reads in [PublicApi]: they exist on a backend + * with no game module installed at all, and they are gated by core's own `events` + * capability rather than by a module's. Nothing here is under `/shard`. + * + * The three public reads and the one player read share an interface for the same + * reason the website mounts them in one feature: the history row's whole purpose + * is to link back to the public page. The player call carries a bearer through + * [com.runicgateway.app.core.net.AuthInterceptor] like every other authenticated + * call; there is one Retrofit. + */ +interface EventsApi { + + /** + * The public calendar. Defaults to now through 31 days out when neither end + * is named; the window may span at most 92 days and the server 400s past it. + * + * Rehearsals and unlisted events are absent — that filtering is in SQL, not + * in the answer, so there is nothing here to re-check. + */ + @GET("api/v1/public/events") + suspend fun getCalendar( + @Query("from") from: String? = null, + @Query("to") to: String? = null, + @Query("seriesId") seriesId: Long? = null, + ): EventCalendarDto + + /** + * One event. + * + * **[run] selects which occurrence the results table is about**, and is what + * an announcement's link carries: the page lives at the definition's slug, so + * a weekly event has one address that survives a retitle, while every + * `event.` trigger is about one occurrence. A run belonging to some other + * event is ignored rather than refused, so a stale link in a months-old mail + * still opens the page it was about. + * + * A draft, an archived definition and an unlisted one all answer 404, + * indistinguishable from a slug that never existed. + */ + @GET("api/v1/public/events/{slug}") + suspend fun getEvent( + @Path("slug") slug: String, + @Query("run") run: String? = null, + ): PublicEventResponse + + /** + * One arc. A series with no listed events answers 404 rather than an empty + * page — an arc is a label on its definitions, so a page for an empty one + * would publish that an operator has named something they have not announced. + */ + @GET("api/v1/public/events/series/{slug}") + suspend fun getSeries(@Path("slug") slug: String): EventSeriesResponse + + /** + * The caller's own participation history. Self-scoped on the session's user + * id server-side; there is deliberately no id parameter here, because there + * is none on the route. + * + * [before] is a participation row id, not an offset — the list gains rows at + * the top as the reader attends things. + */ + @GET("api/v1/player/events/history") + suspend fun getHistory( + @Query("limit") limit: Int? = null, + @Query("before") before: Long? = null, + ): EventHistoryDto +} diff --git a/app/src/main/java/com/runicgateway/app/data/api/PublicApi.kt b/app/src/main/java/com/runicgateway/app/data/api/PublicApi.kt index ef1d654..808b44f 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/PublicApi.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/PublicApi.kt @@ -18,6 +18,7 @@ import com.runicgateway.app.data.api.dto.HouseDto import com.runicgateway.app.data.api.dto.MarketMetaDto import com.runicgateway.app.data.api.dto.MarketPageDto import com.runicgateway.app.data.api.dto.MarketVendorDto +import com.runicgateway.app.data.api.dto.ModulesDto import com.runicgateway.app.data.api.dto.OnlineStaffDto import com.runicgateway.app.data.api.dto.PageDto import com.runicgateway.app.data.api.dto.PointsBoardDto @@ -67,6 +68,18 @@ interface PublicApi { @GET("api/v1/public/settings") suspend fun getSettings(): SettingsDto + /** + * Which modules this backend is serving, and the capabilities each declares + * (§5, M13). Read together with the `version` block's own `capabilities` — + * core's list and a module's are separate lists on purpose. + * + * This is what lets the app tell a module that is **not installed** from a + * lookup that failed: `/public/shard/features` 404s in both cases, and only + * this call distinguishes them. + */ + @GET("api/v1/public/modules") + suspend fun getModules(): ModulesDto + // ── News & content ─────────────────────────────────────────────────── @GET("api/v1/public/posts/{category}") suspend fun getPosts(@Path("category") category: String): List diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/EventsDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/EventsDto.kt new file mode 100644 index 0000000..25e5d6b --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/EventsDto.kt @@ -0,0 +1,212 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.data.api.dto + +import kotlinx.serialization.Serializable + +/** + * Wire shapes for the public event surface (`docs/website/EVENTS.md` §I, events + * Phase 14a; the app's half is M13). Field names match + * `server/src/model/events/eventPublic.model.js` exactly. + * + * **That model is a PROJECTION, and these DTOs must not out-grow it.** Nothing on + * the server side is spread into a public entry — a field reaches one because a + * line put it there — so three things are absent from every shape below and each + * absence is a decision core made: the **spec** (phases, steps, actions and their + * params are the operator's plan for changing a live world; a visitor gets the + * phase LABEL while a run is live and nothing else), **health, cleanup, claims + * and errors** (facts about the deployment's plumbing, not about the event), and + * **`member_key`** (module-opaque, so core cannot say what publishing one would + * disclose). Adding a field here that the server does not send would decode to a + * default and render as a fact. + * + * Every DTO ignores unknown keys (NetworkModule's lenient Json), so an additive + * backend field is safe. + */ + +/** + * One calendar entry. [kind] is `run` or `projected` and the two are drawn + * differently on purpose. + * + * A **run** is a materialised occurrence: a row exists, it can be cancelled, and + * what it says is committed to. A **projected** entry is arithmetic past the + * materialisation horizon — a forecast with nothing behind it — so the screen + * labels it rather than drawing it as a booking. [adjusted] and [shiftMinutes] + * only ever arrive on a projection, and say a DST shift moved it. + * + * [scheduledFor] is a UTC instant and [timezone] is the EVENT's own zone, never + * the reader's. See [com.runicgateway.app.ui.events.eventTime]. + */ +@Serializable +data class EventCalendarEntryDto( + val kind: String = "run", + val title: String = "", + val slug: String = "", + val seriesName: String? = null, + val seriesSlug: String? = null, + val scheduledFor: String = "", + val timezone: String? = null, + val status: String = "scheduled", + val live: Boolean = false, + val adjusted: Boolean = false, + val shiftMinutes: Int = 0, +) { + /** True for a forecast the server has committed nothing to. */ + val isProjected: Boolean get() = kind == "projected" +} + +/** `GET /public/events` — the calendar for a window, ascending by instant. */ +@Serializable +data class EventCalendarDto( + val entries: List = emptyList(), + /** True when the server capped the answer; the screen says so rather than lying by omission. */ + val truncated: Boolean = false, +) + +/** + * One occurrence on an event's page. + * + * [phase] is the label of the phase a live run is in, resolved from the version + * that run PINNED — so an edit since does not relabel a run in flight. It is null + * on anything that is not live, which is why the screen only ever shows it there. + */ +@Serializable +data class EventOccurrenceDto( + val runId: Long = 0, + val scheduledFor: String = "", + val timezone: String? = null, + val startedAt: String? = null, + val endedAt: String? = null, + val status: String = "scheduled", + val live: Boolean = false, + val scope: String? = null, + val phase: String? = null, + val resultsPublishedAt: String? = null, +) + +/** + * One row of a published results table. + * + * [name] is whatever the module put in its participation `meta`, and there is + * genuinely nothing else to render when it is absent: core has no name for a + * character and the member key is not published, so the screen says "Unnamed" + * rather than inventing one. + * + * **[score] is fractional, and it has to be.** `event_run_participants.score` is + * `DECIMAL(18,4)`, and a module scoring by distance, time or a weighted tally + * writes a fraction — the live walk found `318.5` in the first row it read. + * Declaring it `Long` does not merely round: kotlinx REFUSES the body, the whole + * response fails to decode, and the screen reports a server error for a `200`. + * See [com.runicgateway.app.ui.events.scoreText] for how it is rendered. + */ +@Serializable +data class EventParticipantDto( + val name: String? = null, + val score: Double = 0.0, + val rank: Int? = null, +) + +/** The results table for ONE occurrence, present only once it has been published. */ +@Serializable +data class EventResultsDto( + val runId: Long = 0, + val scheduledFor: String = "", + val publishedAt: String? = null, + val participants: List = emptyList(), +) + +/** The arc an event belongs to, as its own page names it. */ +@Serializable +data class EventSeriesRefDto( + val name: String = "", + val slug: String = "", +) + +/** `GET /public/events/:slug` — the event. */ +@Serializable +data class PublicEventDto( + val title: String = "", + val slug: String = "", + val summary: String? = null, + /** Sanitized HTML, written the way a wiki page and a forum post are. */ + val body: String? = null, + val imageUrl: String? = null, + val timezone: String? = null, + val series: EventSeriesRefDto? = null, + val live: Boolean = false, + val current: EventOccurrenceDto? = null, + /** + * The next occurrence — **narrower than the first of [upcoming]**, and the + * server decides which. A cancelled occurrence still appears under what is + * coming, because "next Friday is off" is what somebody checking a calendar + * came to find out; it is not what "next" means. + */ + val next: EventOccurrenceDto? = null, + val upcoming: List = emptyList(), + val past: List = emptyList(), + val results: EventResultsDto? = null, +) + +/** The envelope `GET /public/events/:slug` answers with. */ +@Serializable +data class PublicEventResponse(val event: PublicEventDto = PublicEventDto()) + +/** One event as an arc lists it — the editor's order, so no dates. */ +@Serializable +data class EventSeriesEntryDto( + val title: String = "", + val slug: String = "", + val summary: String? = null, + val imageUrl: String? = null, +) + +/** `GET /public/events/series/:slug` — one arc and the listed events in it. */ +@Serializable +data class EventSeriesDto( + val name: String = "", + val slug: String = "", + val description: String? = null, + val events: List = emptyList(), +) + +/** The envelope `GET /public/events/series/:slug` answers with. */ +@Serializable +data class EventSeriesResponse(val series: EventSeriesDto = EventSeriesDto()) + +/** + * One row of the caller's own participation history. + * + * [rank] is null until `core.results.publish` ran for that occurrence, and that + * is a real state rather than an error — the screen says "not published" rather + * than rendering a dash that reads as a bug. + * + * [id] is the participation row's own id and is what the keyset page walks back + * on: the list gains a row every time the reader attends something, so an offset + * would skip and repeat. + */ +@Serializable +data class EventHistoryEntryDto( + val id: Long = 0, + val runId: Long = 0, + val title: String = "", + val slug: String = "", + val seriesName: String? = null, + val seriesSlug: String? = null, + val scheduledFor: String = "", + val startedAt: String? = null, + val endedAt: String? = null, + val timezone: String? = null, + val status: String = "scheduled", + val joinedAt: String? = null, + // Fractional, for the reason [EventParticipantDto.score] gives. + val score: Double = 0.0, + val rank: Int? = null, + val resultsPublishedAt: String? = null, +) + +/** `GET /player/events/history` — self-scoped, one page. */ +@Serializable +data class EventHistoryDto( + val entries: List = emptyList(), +) diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/PublicDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/PublicDto.kt index 3d69e7a..cdcde5c 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/dto/PublicDto.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/PublicDto.kt @@ -20,6 +20,47 @@ data class VersionDto( val service: String = "", val api: String = "", val server: String = "", + /** + * What CORE serves beyond the baseline every backend has (events Phase 14a; + * `MODULE_API.md` §2.9). Opaque strings, the same word a module uses on + * `GET /public/modules` so a client feature-detects one way, and a **separate + * list** because core is not a module. + * + * **The value is in what is absent**, which is why the default is empty + * rather than something meaningful: a backend released before a capability + * existed omits the key entirely, and that is how the app tells an older site + * from one that simply has nothing to show. An unknown string is absent, and + * no route may be inferred from one. + */ + val capabilities: List = emptyList(), +) + +/** + * One installed, **started** module on `GET /public/modules`. + * + * A module that is disabled or failed to load is absent rather than listed with a + * state — its routes and its nav are absent too, so a client renders a site + * without that capability rather than one advertising a capability that 503s. + */ +@Serializable +data class InstalledModuleDto( + val id: String = "", + val name: String = "", + val version: String = "", + val capabilities: List = emptyList(), +) + +/** + * `GET /public/modules` — what this backend is serving beyond core. + * + * Database-free and never gated by site mode, so the app can feature-detect + * during maintenance. A **500** is the one answer that is not an answer: core + * refuses to return `[]` for a list read before its loader ran, because a caller + * cannot tell an empty list from a mis-ordered boot. + */ +@Serializable +data class ModulesDto( + val modules: List = emptyList(), ) /** `GET /public/status` — site mode + version for the first-run probe (§3). */ diff --git a/app/src/main/java/com/runicgateway/app/data/repository/ConnectionRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/ConnectionRepository.kt index 104c49d..e97f35f 100644 --- a/app/src/main/java/com/runicgateway/app/data/repository/ConnectionRepository.kt +++ b/app/src/main/java/com/runicgateway/app/data/repository/ConnectionRepository.kt @@ -29,6 +29,7 @@ class ConnectionRepository @Inject constructor( private val sessionManager: SessionManager, private val trustTokenStore: TrustTokenStore, private val shardFeaturesRepository: ShardFeaturesRepository, + private val siteCapabilitiesRepository: SiteCapabilitiesRepository, private val pushManager: com.runicgateway.app.core.push.PushManager, private val config: com.runicgateway.app.core.AppConfig, ) { @@ -116,6 +117,10 @@ class ConnectionRepository @Inject constructor( // a switch between two signed-out hosts changes no session, so nothing else // invalidates the cache and the new shard would inherit the old one's menu. shardFeaturesRepository.invalidate() + // Same argument, one layer up: what the OLD host served says nothing about + // the new one, and a stale "this backend has no game module" would hide the + // new host's shard rows until its first successful read. + siteCapabilitiesRepository.invalidate() prefs.clear() baseUrlHolder.set(null) } diff --git a/app/src/main/java/com/runicgateway/app/data/repository/EventsRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/EventsRepository.kt new file mode 100644 index 0000000..0ada5d4 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/repository/EventsRepository.kt @@ -0,0 +1,61 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.data.repository + +import com.runicgateway.app.core.result.ApiResult +import com.runicgateway.app.core.result.map +import com.runicgateway.app.core.result.safeApiCall +import com.runicgateway.app.data.api.EventsApi +import com.runicgateway.app.data.api.dto.EventCalendarDto +import com.runicgateway.app.data.api.dto.EventHistoryEntryDto +import com.runicgateway.app.data.api.dto.EventSeriesDto +import com.runicgateway.app.data.api.dto.PublicEventDto +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The event calendar, event pages, arcs and the caller's own participation + * history (PLAN.md §6.1, §9 M13). + * + * The two single-object reads unwrap their envelope here rather than in a view + * model, so a screen never holds a `…Response` whose only job was to carry one + * field. The calendar and the history keep theirs: `truncated` is a fact about + * the answer that the screen renders, and the history's page is a list the pager + * appends to. + */ +@Singleton +class EventsRepository @Inject constructor( + private val api: EventsApi, +) { + /** The public calendar. Both ends optional; the server's default window is 31 days. */ + suspend fun calendar( + from: String? = null, + to: String? = null, + seriesId: Long? = null, + ): ApiResult = safeApiCall { api.getCalendar(from, to, seriesId) } + + /** + * One event, optionally about one occurrence. + * + * [run] is passed through untouched — including a run that belongs to some + * other event, which the server ignores rather than refusing. Filtering it + * here would turn a stale link into a dead end instead of a page about the + * thing the link was about. + */ + suspend fun event(slug: String, run: String? = null): ApiResult = + safeApiCall { api.getEvent(slug, run?.takeIf { it.isNotBlank() }) }.map { it.event } + + /** One arc. A series with nothing listed in it answers 404, not an empty page. */ + suspend fun series(slug: String): ApiResult = + safeApiCall { api.getSeries(slug) }.map { it.series } + + /** + * One page of the caller's own participation history, newest first. + * + * [before] is the id of the last row already shown — a keyset page, not an + * offset, because the list gains rows at the top as the reader attends things. + */ + suspend fun history(limit: Int, before: Long? = null): ApiResult> = + safeApiCall { api.getHistory(limit, before) }.map { it.entries } +} diff --git a/app/src/main/java/com/runicgateway/app/data/repository/SiteCapabilitiesRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/SiteCapabilitiesRepository.kt new file mode 100644 index 0000000..a5417a8 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/repository/SiteCapabilitiesRepository.kt @@ -0,0 +1,164 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.data.repository + +import com.runicgateway.app.core.result.ApiResult +import com.runicgateway.app.core.result.safeApiCall +import com.runicgateway.app.data.api.PublicApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import javax.inject.Inject +import javax.inject.Singleton + +/** + * What this BACKEND serves — core's own capabilities and every started module's + * (PLAN.md §5, §9 M13; `docs/website/MODULE_API.md` §2.9). + * + * ## Why this exists at all, and why it is not [ShardFeaturesRepository] + * + * The two answer different questions and neither can answer the other's: + * + * - **Capability — is this module installed at all?** Per HOST. It changes when + * an operator installs or removes a module, so it is resolved beside the + * appearance and invalidated on a server switch. + * - **Feature — does this shard publish this surface to this viewer?** Per + * VIEWER. It changes on sign-in, which is why it is resolved on every session + * change. + * + * Without the first, the app cannot tell a module that is **not installed** from + * a lookup that failed: `GET /public/shard/features` 404s in both cases, and + * [ShardFeaturesRepository] maps every failure to "unknown", which [canSee] + * treats as visible. On a site running a different game that renders every shard + * row in the drawer and every one of them 404s when tapped. + * + * ## Absence of an answer is not an answer of absence + * + * The distinction this class exists to make, and the reason [SiteCapabilities] + * carries no "unknown" member of its own — the *absence of the whole value* is + * the unknown state: + * + * - a **successful** read that does not name a capability is an answer, and + * [canUse] hides what needs it; + * - a **failed** read keeps the last answer this host gave, because a moment + * with no connectivity is not an uninstall; + * - a host that has **never** answered leaves the value null, and [canUse] + * passes — the drawer renders as it did before this existed rather than + * flickering its rows in on every cold start. + * + * The last one is deliberately the same fail-open direction [canSee] takes, for + * the same reason: the server gates every call regardless, so the cost of + * guessing wrong is a link that briefly 404s. + */ +@Singleton +class SiteCapabilitiesRepository @Inject constructor( + private val api: PublicApi, +) { + private val _capabilities = MutableStateFlow(null) + + /** The current answer, or `null` while this host has never given one. */ + val capabilities: StateFlow = _capabilities.asStateFlow() + + // Serializes concurrent refreshes: the shell refreshes on resume and the + // connect flow refreshes on first load, and two overlapping reads would race + // to publish. + private val mutex = Mutex() + + /** + * Re-resolve what this backend serves. + * + * **Two calls, and one failing is not the same as both failing.** Core's list + * and a module's are separate lists (§2.9), so they are merged from separate + * reads and each is kept only if it answered. A backend released before + * events omits `capabilities` from its `version` block entirely, which is an + * answer — the empty list — and not a failure. + */ + suspend fun refresh() = mutex.withLock { + val status = safeApiCall { api.getStatus() } + val modules = safeApiCall { api.getModules() } + + // Neither call answered: keep whatever this host said last, which for a + // host that has never answered is still null. + if (status !is ApiResult.Ok && modules !is ApiResult.Ok) return@withLock + + val previous = _capabilities.value + val core = (status as? ApiResult.Ok)?.data?.version?.capabilities?.toSet() + ?: previous?.core + ?: emptySet() + val installed = (modules as? ApiResult.Ok)?.data?.modules + ?.flatMap { it.capabilities } + ?.toSet() + ?: previous?.modules + ?: emptySet() + + _capabilities.value = SiteCapabilities(core = core, modules = installed) + } + + /** + * Drop the answer. Called on a Settings → Server switch: capabilities belong + * to the host that reported them, and the new host must not inherit them — + * a switch between two signed-out hosts changes no session, so nothing else + * would invalidate this. + */ + fun invalidate() { + _capabilities.value = null + } +} + +/** + * What one backend serves, as two lists rather than one. + * + * They are kept apart because core is not a module: merging them would leave the + * app unable to tell *"this backend has events"* from *"a module called core + * happens to be installed"*, which is exactly the distinction + * `GET /public/modules` exists to make. [canUse] looks in both, because a menu + * entry does not care which half serves it — but the halves stay separable, so a + * future caller that does care still can. + */ +data class SiteCapabilities( + /** Core's own, from the `version` block. Empty on a backend that predates them. */ + val core: Set, + /** Every started module's, flattened. Two modules may declare the same string. */ + val modules: Set, +) { + /** True when either half names [capability]. */ + operator fun contains(capability: String): Boolean = + capability in core || capability in modules +} + +/** + * True when [capability] may be relied on — **or when this host has not answered + * yet**. + * + * The null case is the fail-open one and it is not the same as the empty one: a + * [SiteCapabilities] that names nothing is a backend that told us it serves + * nothing extra, and that hides. See the class doc above. + * + * `null` [capability] means the caller declared none, which always passes. + */ +fun canUse(capabilities: SiteCapabilities?, capability: String?): Boolean = + capability == null || capabilities == null || capability in capabilities + +/** + * The capability strings the app gates on. + * + * **Deliberately few.** `module-uo` declares eight, and gating each shard row on + * its own would be a second, worse copy of what the per-viewer feature flags + * already decide — and one that drifts, because a capability is opaque to core + * and nothing checks the two agree. One string answers the only question a + * capability can: is the module there. + */ +object Capability { + /** + * A game module serving a live shard. Declared by `module-uo`; a different + * game's module that serves the same surfaces would declare it too, which is + * the point of an opaque string. + */ + const val SHARD = "shard" + + /** Core's event system (events Phase 14a). Never a module's. */ + const val EVENTS = "events" +} diff --git a/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt b/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt index ecbb819..2e76578 100644 --- a/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt +++ b/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt @@ -15,6 +15,7 @@ import com.runicgateway.app.core.net.TokenAuthenticator import com.runicgateway.app.core.net.UserAgentInterceptor import com.runicgateway.app.data.api.AuthApi import com.runicgateway.app.data.api.AuthRefreshApi +import com.runicgateway.app.data.api.EventsApi import com.runicgateway.app.data.api.MeApi import com.runicgateway.app.data.api.AdminApi import com.runicgateway.app.data.api.NotificationsApi @@ -122,6 +123,15 @@ object NetworkModule { fun providePlayerShardApi(retrofit: Retrofit): PlayerShardApi = retrofit.create(PlayerShardApi::class.java) + /** + * The event surface (§9 M13). Three public reads and one bearer-authed player + * read on one interface — they are all CORE routes, so none of them is a + * module path and none is under `/shard`. + */ + @Provides + @Singleton + fun provideEventsApi(retrofit: Retrofit): EventsApi = retrofit.create(EventsApi::class.java) + /** Opt-in push devices + subscriptions (§11, M7) — bearer-authed on the main client. */ @Provides @Singleton diff --git a/app/src/main/java/com/runicgateway/app/ui/AppViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/AppViewModel.kt index ca6d7f9..991a4e2 100644 --- a/app/src/main/java/com/runicgateway/app/ui/AppViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/AppViewModel.kt @@ -11,6 +11,7 @@ import com.runicgateway.app.core.result.ApiResult import com.runicgateway.app.data.appearance.SiteAppearance import com.runicgateway.app.data.repository.ConnectionRepository import com.runicgateway.app.data.repository.SettingsRepository +import com.runicgateway.app.data.repository.SiteCapabilitiesRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -29,6 +30,7 @@ class AppViewModel @Inject constructor( private val settingsRepository: SettingsRepository, private val baseUrlHolder: BaseUrlHolder, private val pushManager: PushManager, + private val siteCapabilitiesRepository: SiteCapabilitiesRepository, ) : ViewModel() { sealed interface AppState { @@ -76,6 +78,12 @@ class AppViewModel @Inject constructor( fun refreshAppearance() { if (_state.value !is AppState.Ready) return viewModelScope.launch { + // What the backend SERVES is a per-host fact and refreshes on the same + // clock as the appearance: an operator who installs a module while the + // app is backgrounded should see its rows on the next resume. Done + // before the early return below, because a failed settings read is no + // reason to skip it — they are separate calls to separate routes. + siteCapabilitiesRepository.refresh() val settings = (settingsRepository.getSettings() as? ApiResult.Ok)?.data ?: return@launch pushManager.setNtfyUrl(settings.push.ntfyUrl) // changeServer() may have raced us back to the connect screen while the @@ -100,6 +108,7 @@ class AppViewModel @Inject constructor( * or sign-in. Returns [SiteAppearance.NONE] if settings couldn't be loaded. */ private suspend fun loadAppearance(): SiteAppearance { + siteCapabilitiesRepository.refresh() val settings = (settingsRepository.getSettings() as? ApiResult.Ok)?.data pushManager.setNtfyUrl(settings?.push?.ntfyUrl) return SiteAppearance.from(settings) diff --git a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt index 6fa808f..a90403e 100644 --- a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt +++ b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt @@ -64,6 +64,10 @@ import com.runicgateway.app.ui.auth.TrustedDevicesScreen import com.runicgateway.app.ui.auth.roleLabelRes import com.runicgateway.app.ui.components.BrandLogo import com.runicgateway.app.ui.contact.ContactScreen +import com.runicgateway.app.ui.events.EventScreen +import com.runicgateway.app.ui.events.EventSeriesScreen +import com.runicgateway.app.ui.events.EventsScreen +import com.runicgateway.app.ui.events.MyEventsScreen import com.runicgateway.app.ui.home.HomeScreen import com.runicgateway.app.ui.navigation.APP_MENU import com.runicgateway.app.ui.navigation.NavNode @@ -110,6 +114,10 @@ private val TOP_LEVEL_ROUTES = setOf( // on them too (M11). Routes.SHARD_RULES, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET, Routes.ATLAS, Routes.NOTIFICATIONS, + // Events (M13): the calendar and the history are drawer rows, so the drawer + // gesture works on them. The event page and an arc are detail screens and are + // deliberately absent — a back gesture there means "back", not "open the menu". + Routes.EVENTS, Routes.MY_EVENTS, Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES, Routes.ADMIN_DASHBOARD, Routes.ADMIN_CONTENT, Routes.ADMIN_MODERATION, Routes.ADMIN_SUPPORT, ) @@ -141,6 +149,9 @@ fun RunicApp( val session by sessionViewModel.session.collectAsStateWithLifecycle() // What this shard publishes, independently of who the caller is (§5, M11). val shardFeatures by sessionViewModel.shardFeatures.collectAsStateWithLifecycle() + // What this BACKEND serves at all, independently of both (§5, M13). A different + // question from the line above and gated separately — see `isEntryVisible`. + val capabilities by sessionViewModel.capabilities.collectAsStateWithLifecycle() // Re-validate the cached role each time the app returns to the foreground (§4.3), // and re-read the unread count with it: a tickle that arrived while the app was @@ -178,7 +189,7 @@ fun RunicApp( // `pruneNav` still decides what this caller may see and remains the boundary // (§6.1, AC-3). With no stored row the merge returns APP_MENU itself. val nav = pruneNav(buildNavTree(APP_MENU, appearance.navPublic)) { - isEntryVisible(it, session, shardFeatures) + isEntryVisible(it, session, shardFeatures, capabilities) } val context = LocalContext.current @@ -478,6 +489,50 @@ private fun RunicNavHost( ) { PostScreen() } + // Events (M13). CORE's routes, so these screens are reachable on a backend + // with no game module at all — which is why they sit above the shard block + // rather than inside it. + composable(Routes.EVENTS) { + EventsScreen(onOpenEvent = { slug -> navController.navigate(Routes.event(slug)) }) + } + // The app's one route with a query argument. `run` is optional and nullable: + // navigating to Routes.event(slug) with no run matches this pattern with no + // argument, which is every route in except an announcement's link. + composable( + route = Routes.EVENT_ROUTE, + arguments = listOf( + navArgument(Routes.Args.SLUG) { type = NavType.StringType }, + navArgument(Routes.Args.RUN) { + type = NavType.StringType + nullable = true + defaultValue = null + }, + ), + ) { + EventScreen( + onOpenSeries = { slug -> navController.navigate(Routes.eventSeries(slug)) }, + onOpenRun = { slug, runId -> + navController.navigate(Routes.event(slug, runId.toString())) + }, + ) + } + composable( + route = Routes.EVENT_SERIES, + arguments = listOf(navArgument(Routes.Args.SLUG) { type = NavType.StringType }), + ) { + EventSeriesScreen(onOpenEvent = { slug -> navController.navigate(Routes.event(slug)) }) + } + composable(Routes.MY_EVENTS) { + // Signed out, this route is not in the drawer — but a saved back-stack + // entry can still be restored onto it, so the shell says where to go + // rather than letting the screen ask the server and render a 401. + when (session) { + is Session.SignedIn -> MyEventsScreen(onOpenRun = { slug, runId -> + navController.navigate(Routes.event(slug, runId.toString())) + }) + Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) } + } + } composable(Routes.SHARD) { ShardScreen(onOpenBoard = { board -> navController.navigate( @@ -586,6 +641,10 @@ private fun RunicNavHost( when (session) { is Session.SignedIn -> InboxScreen( onOpenSettings = { navController.navigate(Routes.NOTIFICATIONS_SETTINGS) }, + // A notification whose link the app can render opens in the app. + // `navigate`, not `navigateTopLevel`: the inbox is where the + // reader came from and back should return there. + onOpenRoute = { route -> navController.navigate(route) }, ) Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) } } diff --git a/app/src/main/java/com/runicgateway/app/ui/events/EventScreen.kt b/app/src/main/java/com/runicgateway/app/ui/events/EventScreen.kt new file mode 100644 index 0000000..c9b0469 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/events/EventScreen.kt @@ -0,0 +1,310 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.events + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.runicgateway.app.R +import com.runicgateway.app.data.api.dto.EventOccurrenceDto +import com.runicgateway.app.data.api.dto.EventParticipantDto +import com.runicgateway.app.data.api.dto.PublicEventDto +import com.runicgateway.app.ui.UiState +import com.runicgateway.app.ui.components.ErrorView +import com.runicgateway.app.ui.components.HtmlText +import com.runicgateway.app.ui.components.LoadingView +import com.runicgateway.app.ui.components.PillTone +import com.runicgateway.app.ui.components.ShardCard +import com.runicgateway.app.ui.components.StatusPill + +/** + * One event's public page (EVENTS.md § API surface, M13). + * + * The storyline, its arc, what is live, what is next, what happened recently, and + * a results table once an occurrence has published one. + * + * **The plan behind the event is never shown**, because the server never sends + * it: a live run carries the LABEL of the phase it is in — resolved from the + * version that run pinned, so an edit since does not relabel it — and nothing + * else. Phases, steps and actions are the operator's. + */ +@Composable +fun EventScreen( + onOpenSeries: (String) -> Unit, + onOpenRun: (String, Long) -> Unit, + modifier: Modifier = Modifier, + viewModel: EventViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + // Error before content. Phase 13 found the inverse of this one tier along: a + // `if (loading || !form)` spinner above the error branch left a failed load + // spinning for ever with nothing on screen naming the problem. + when (val s = state) { + is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier) + is UiState.Loading -> LoadingView(modifier) + is UiState.Success -> EventBody(s.data, onOpenSeries, onOpenRun, modifier) + } +} + +@Composable +private fun EventBody( + event: PublicEventDto, + onOpenSeries: (String) -> Unit, + onOpenRun: (String, Long) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item(key = "head") { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = event.title, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + event.summary?.takeIf { it.isNotBlank() }?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + event.series?.let { series -> + Text( + text = stringResource(R.string.events_part_of, series.name), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.clickable { onOpenSeries(series.slug) }, + ) + } + } + } + + // The one fact a visitor came for, above the storyline rather than below + // it: whether it is happening now, and if not, when it next is. + item(key = "headline") { Headline(event) } + + event.body?.takeIf { it.isNotBlank() }?.let { body -> + item(key = "body") { + ShardCard(Modifier.fillMaxWidth()) { + // Sanitized on write, the treatment a wiki page and a forum + // post already get. + HtmlText(body, Modifier.padding(16.dp)) + } + } + } + + event.results?.let { results -> + item(key = "results-head") { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = stringResource(R.string.events_results), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = eventDateTime(results.scheduledFor, event.timezone), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (results.participants.isEmpty()) { + item(key = "results-empty") { + Text( + text = stringResource(R.string.events_results_nobody), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + items(results.participants.size, key = { "p$it" }) { index -> + ParticipantRow(results.participants[index]) + } + } + } + + occurrenceSection( + key = "upcoming", + titleRes = R.string.events_coming_up, + list = event.upcoming, + timezone = event.timezone, + slug = event.slug, + onOpenRun = onOpenRun, + linkResults = false, + ) + occurrenceSection( + key = "past", + titleRes = R.string.events_previously, + list = event.past, + timezone = event.timezone, + slug = event.slug, + onOpenRun = onOpenRun, + linkResults = true, + ) + + if (event.current == null && event.next == null && event.past.isEmpty()) { + item(key = "unscheduled") { + Text( + text = stringResource(R.string.events_never_scheduled), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun Headline(event: PublicEventDto) { + ShardCard(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + val current = event.current + when { + event.live && current != null -> { + StatusPill( + text = stringResource(R.string.events_status_live), + tone = PillTone.Success, + ) + Text( + // The phase LABEL, and only while it is live. + text = current.phase?.takeIf { it.isNotBlank() } + ?: stringResource(R.string.events_under_way), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + event.next != null -> { + Text( + text = stringResource(R.string.events_next), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = eventDateTime(event.next.scheduledFor, event.next.timezone ?: event.timezone), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + else -> Text( + text = stringResource(R.string.events_nothing_scheduled), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +/** + * A titled list of occurrences, or nothing at all when there are none. + * + * `linkResults` is what separates the two calls: only a PAST occurrence that + * actually published results gets its own tap target, because on any other one + * `?run=` would change nothing a reader could see. + */ +private fun androidx.compose.foundation.lazy.LazyListScope.occurrenceSection( + key: String, + titleRes: Int, + list: List, + timezone: String?, + slug: String, + onOpenRun: (String, Long) -> Unit, + linkResults: Boolean, +) { + if (list.isEmpty()) return + item(key = "$key-title") { + Text( + text = stringResource(titleRes), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + items(list.size, key = { "$key-${list[it].runId}" }) { index -> + val occurrence = list[index] + val tappable = linkResults && occurrence.resultsPublishedAt != null + ShardCard( + modifier = Modifier + .fillMaxWidth() + .then( + if (tappable) Modifier.clickable { onOpenRun(slug, occurrence.runId) } + else Modifier, + ), + ) { + Row( + Modifier.fillMaxWidth().padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = eventDateTime(occurrence.scheduledFor, occurrence.timezone ?: timezone), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + Text( + text = stringResource( + statusWordRes(occurrence.status, occurrence.scheduledFor), + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun ParticipantRow(participant: EventParticipantDto) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = participant.rank?.toString() ?: "—", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.End, + modifier = Modifier.width(32.dp), + ) + Text( + // A module supplies a display name in its participation meta or it does + // not; the member key is never published, so there is genuinely nothing + // else to render. + text = participant.name?.takeIf { it.isNotBlank() } + ?: stringResource(R.string.events_participant_unnamed), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + Text( + text = scoreText(participant.score), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/events/EventSeriesScreen.kt b/app/src/main/java/com/runicgateway/app/ui/events/EventSeriesScreen.kt new file mode 100644 index 0000000..f605ca5 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/events/EventSeriesScreen.kt @@ -0,0 +1,113 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.events + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.runicgateway.app.ui.UiState +import com.runicgateway.app.ui.components.ErrorView +import com.runicgateway.app.ui.components.LoadingView +import com.runicgateway.app.ui.components.ShardCard + +/** + * One arc (EVENTS.md §I, M13). + * + * **The arc is the thing the tooling this replaces could not express at all.** A + * calendar plugin has no series field, so "Royal Spy Mission → Risky Partner → + * Message From the Void" existed only in a GM's head and in whatever the forum + * post said. This screen is that continuity, in the order an editor arranged it — + * which is why the events are numbered rather than dated: an arc has an order, and + * its parts may be months apart or run out of sequence. + */ +@Composable +fun EventSeriesScreen( + onOpenEvent: (String) -> Unit, + modifier: Modifier = Modifier, + viewModel: EventSeriesViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + // Error first, then loading — the order Phase 13 had to fix one tier along. + when (val s = state) { + is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier) + is UiState.Loading -> LoadingView(modifier) + is UiState.Success -> { + val series = s.data + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item(key = "head") { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = series.name, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + series.description?.takeIf { it.isNotBlank() }?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + items(series.events.size, key = { series.events[it].slug }) { index -> + val entry = series.events[index] + ShardCard( + modifier = Modifier + .fillMaxWidth() + .clickable { onOpenEvent(entry.slug) }, + ) { + Row( + Modifier.fillMaxWidth().padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text( + text = (index + 1).toString(), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.End, + modifier = Modifier.width(32.dp), + ) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = entry.title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + entry.summary?.takeIf { it.isNotBlank() }?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/events/EventSeriesViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/events/EventSeriesViewModel.kt new file mode 100644 index 0000000..b5d6781 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/events/EventSeriesViewModel.kt @@ -0,0 +1,50 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.events + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.runicgateway.app.data.api.dto.EventSeriesDto +import com.runicgateway.app.data.repository.EventsRepository +import com.runicgateway.app.ui.UiState +import com.runicgateway.app.ui.navigation.Routes +import com.runicgateway.app.ui.toUiState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * One arc (PLAN.md §9 M13). + * + * A series with nothing listed in it answers 404 rather than an empty page, so + * there is no "empty arc" state to render: the error branch is the whole of it, + * and that is the server's decision rather than this screen's — an empty page + * would publish that an operator has named something they have not announced. + */ +@HiltViewModel +class EventSeriesViewModel @Inject constructor( + private val repository: EventsRepository, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val slug: String = savedStateHandle.get(Routes.Args.SLUG).orEmpty() + + private val _state = MutableStateFlow>(UiState.Loading) + val state: StateFlow> = _state.asStateFlow() + + init { + load() + } + + fun load() { + _state.value = UiState.Loading + viewModelScope.launch { + _state.value = repository.series(slug).toUiState() + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/events/EventTimes.kt b/app/src/main/java/com/runicgateway/app/ui/events/EventTimes.kt new file mode 100644 index 0000000..0f80c95 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/events/EventTimes.kt @@ -0,0 +1,166 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.events + +import androidx.annotation.StringRes +import com.runicgateway.app.R +import com.runicgateway.app.core.time.parseWireInstant +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Locale + +/** + * Rendering an event's instant and its status word (EVENTS.md §I). + * + * Everything here is pure and takes its clock, zone and locale as parameters, so + * the rules below are unit-tested off-device rather than eyeballed on one. + * + * ## The split, which is the one thing about event times that is easy to get wrong + * + * The server returns UTC instants and never guesses the reader's zone. The client + * places them, and it places the two halves differently: + * + * - the **day** an entry is filed under is the READER's own — "what is on this + * month" is a question about the month the person holding the phone is living + * in; + * - the **time** beside it is always the EVENT's zone, carried on the entry — + * because every listing this feature replaces is written in the shard's local + * zone, and "8pm" means the shard's evening to everyone reading it. + * + * Rendering the time in the reader's zone instead is defensible and wrong here: a + * player in Berlin told an American shard's event is at 02:00 has been told + * something true and useless, and told it in a way that makes the shard's own + * announcement look like a mistake. + */ + +/** + * A participation score, as a reader should see it. + * + * Scores are `DECIMAL(18,4)` on the wire because a module may score by distance, + * time or a weighted tally — but most score by counting, and rendering a plain + * tally of kills as `12.0` reads as a rounding artefact. So a whole number prints + * whole and a fraction keeps its digits, with trailing zeros trimmed: `1420`, + * `318.5`, `0.25`. + */ +fun scoreText(score: Double, locale: Locale = Locale.getDefault()): String { + if (!score.isFinite()) return "0" + if (score == Math.floor(score) && Math.abs(score) < 1e15) { + return String.format(locale, "%d", score.toLong()) + } + return String.format(locale, "%.4f", score).trimEnd('0').trimEnd('.', ',') +} + +/** The event's own wall clock, with the zone named so it misreads as nothing. */ +fun eventTime( + instant: String?, + timezone: String?, + locale: Locale = Locale.getDefault(), +): String { + val at = parseWireInstant(instant) ?: return "" + val zone = eventZone(timezone) + val time = DateTimeFormatter.ofPattern("HH:mm", locale).withZone(zone).format(at) + return "$time ${shortZone(timezone)}" +} + +/** + * The event's own day and time together, for a screen showing one occurrence. + * + * Localized rather than patterned, because a full date's field order is the + * locale's business; only the zone stays the event's. + */ +fun eventDateTime( + instant: String?, + timezone: String?, + locale: Locale = Locale.getDefault(), +): String { + val at = parseWireInstant(instant) ?: return "" + val zone = eventZone(timezone) + val text = DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT) + .withLocale(locale) + .withZone(zone) + .format(at) + return "$text ${shortZone(timezone)}" +} + +/** The reader's own day, for the heading an entry is filed under. */ +fun readerDayLabel( + instant: String?, + zone: ZoneId = ZoneId.systemDefault(), + locale: Locale = Locale.getDefault(), +): String { + val at = parseWireInstant(instant) ?: return "" + return DateTimeFormatter + .ofLocalizedDate(FormatStyle.FULL) + .withLocale(locale) + .withZone(zone) + .format(at) +} + +/** + * The zone as a reader recognises it: `America/New_York` → `New York`. + * + * Not the abbreviation (`EDT`), which is unstable across the year and unknown to + * most readers of a shard in another country. + */ +fun shortZone(timezone: String?): String { + if (timezone.isNullOrBlank()) return "UTC" + return timezone.substringAfterLast('/').replace('_', ' ') +} + +/** + * The event's zone, or UTC when its column holds something `java.time` will not + * read. + * + * A typo in a definition's timezone must still render: UTC off the instant is the + * honest answer when the zone cannot be honoured, and it is what the web client + * falls back to for the same reason. + */ +private fun eventZone(timezone: String?): ZoneId = try { + if (timezone.isNullOrBlank()) ZoneId.of("UTC") else ZoneId.of(timezone) +} catch (_: Exception) { + ZoneId.of("UTC") +} + +/** + * The word beside an occurrence, for the four statuses the server publishes. + * + * **`cancelled` needs the instant, and that is the whole reason this takes one.** + * The server publishes `failed` and `missed` as `cancelled` too — to a visitor the + * three are one event, and the difference between them is about the deployment — + * but the three do not share one English sentence. *Did not happen* is right for a + * past occurrence and a plain falsehood for a future one, and a run four days out + * that an operator has called off is exactly the common case: this is the defect + * Phase 14a's own calendar shipped and the live walk caught, which is why it is + * restated here rather than ported. + * + * So **the tense follows the clock, not the status**. A future call-off reads + * *Cancelled*; a past one reads *Did not happen*, which is also the honest word + * for the failed and missed runs folded in with it. + * + * An unrecognised status reads *Scheduled*, mirroring the server's own fallback: + * `publicStatus()` folds anything it does not know to `scheduled`, so a word the + * app has never seen is a contract break rather than a state, and rendering a raw + * enum at a reader is not an improvement on it. + */ +@StringRes +fun statusWordRes(status: String?, scheduledFor: String?, now: Instant = Instant.now()): Int = + when (status) { + "live" -> R.string.events_status_live + "completed" -> R.string.events_status_completed + "cancelled" -> { + val at = parseWireInstant(scheduledFor) + // An unreadable instant is treated as past, which is the safer of the + // two: "did not happen" about something unplaceable in time is vague, + // while "cancelled" about a past run implies it is still coming. + if (at != null && at.isAfter(now)) { + R.string.events_status_cancelled + } else { + R.string.events_status_did_not_happen + } + } + else -> R.string.events_status_scheduled + } diff --git a/app/src/main/java/com/runicgateway/app/ui/events/EventViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/events/EventViewModel.kt new file mode 100644 index 0000000..0fa6d07 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/events/EventViewModel.kt @@ -0,0 +1,59 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.events + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.runicgateway.app.data.api.dto.PublicEventDto +import com.runicgateway.app.data.repository.EventsRepository +import com.runicgateway.app.ui.UiState +import com.runicgateway.app.ui.navigation.Routes +import com.runicgateway.app.ui.toUiState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * One event's page (PLAN.md §9 M13, EVENTS.md § API surface). + * + * **`run` is read from the route and passed through untouched**, because that is + * what an announcement's link carries. The page lives at the definition's slug — + * one stable address, so a link posted in Discord survives a retitle — and the + * occurrence has to be in the query or a mail about last Friday's invasion would + * open next Friday's. + * + * A run that belongs to some other event is **not** filtered here. The server + * ignores it and answers with this event anyway, which turns a stale link in a + * months-old mail into the page it was about rather than a dead end; second- + * guessing that would undo it. + */ +@HiltViewModel +class EventViewModel @Inject constructor( + private val repository: EventsRepository, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val slug: String = savedStateHandle.get(Routes.Args.SLUG).orEmpty() + + /** Null unless the route carried one; never an empty string forwarded to the server. */ + private val run: String? = savedStateHandle.get(Routes.Args.RUN)?.takeIf { it.isNotBlank() } + + private val _state = MutableStateFlow>(UiState.Loading) + val state: StateFlow> = _state.asStateFlow() + + init { + load() + } + + fun load() { + _state.value = UiState.Loading + viewModelScope.launch { + _state.value = repository.event(slug, run).toUiState() + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/events/EventsScreen.kt b/app/src/main/java/com/runicgateway/app/ui/events/EventsScreen.kt new file mode 100644 index 0000000..a298618 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/events/EventsScreen.kt @@ -0,0 +1,191 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.events + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.runicgateway.app.R +import com.runicgateway.app.data.api.dto.EventCalendarEntryDto +import com.runicgateway.app.ui.UiState +import com.runicgateway.app.ui.components.EmptyView +import com.runicgateway.app.ui.components.ErrorView +import com.runicgateway.app.ui.components.LoadingView +import com.runicgateway.app.ui.components.PillTone +import com.runicgateway.app.ui.components.ShardCard +import com.runicgateway.app.ui.components.StatusPill + +/** + * The public event calendar (EVENTS.md §I, M13). + * + * **A list, not a month grid**, which is the same call the web client makes and + * for the same reason: an operator's question is "what does this month look + * like" — coverage, clashes, the gap on the third weekend — and a grid answers + * it. A visitor's question is "what is on, and when is the next one", which a + * chronological list answers in one glance and a grid answers by making them + * count squares. On a phone the grid is not even a close second. + * + * **A projection is drawn differently from a run**, one tier along from the + * operator's own reason for the distinction: past the materialisation horizon + * there is no row, nothing is committed to, and nothing can be cancelled. Drawing + * a forecast identically to a booking would be the screen promising something the + * server has not. + */ +@Composable +fun EventsScreen( + onOpenEvent: (String) -> Unit, + modifier: Modifier = Modifier, + viewModel: EventsViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + when (val s = state) { + is UiState.Loading -> LoadingView(modifier) + is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier) + is UiState.Success -> { + val entries = s.data.entries + if (entries.isEmpty()) { + EmptyView(stringResource(R.string.events_empty), modifier) + } else { + Calendar(entries, s.data.truncated, onOpenEvent, modifier) + } + } + } +} + +/** + * Group by the READER's day, preserving the server's order rather than re-sorting. + * + * Internal + pure so the grouping — and the fact that it never reorders — is + * unit-tested without Compose. + */ +internal fun groupByReaderDay(entries: List): List { + val days = mutableListOf() + for (entry in entries) { + val label = readerDayLabel(entry.scheduledFor) + val last = days.lastOrNull() + if (last != null && last.label == label) { + last.entries.add(entry) + } else { + days.add(CalendarDayGroup(label, mutableListOf(entry))) + } + } + return days +} + +/** A mutable builder shape for [groupByReaderDay]; the screen only reads it. */ +internal data class CalendarDayGroup( + val label: String, + val entries: MutableList, +) + +@Composable +private fun Calendar( + entries: List, + truncated: Boolean, + onOpenEvent: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val days = groupByReaderDay(entries) + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + days.forEach { day -> + item(key = "day-${day.label}") { + Text( + text = day.label, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + items( + items = day.entries, + key = { "${it.slug}-${it.scheduledFor}-${it.kind}" }, + ) { entry -> + EntryCard(entry, onOpenEvent) + } + } + if (truncated) { + item(key = "truncated") { + Text( + text = stringResource(R.string.events_truncated), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun EntryCard(entry: EventCalendarEntryDto, onOpenEvent: (String) -> Unit) { + ShardCard( + modifier = Modifier + .fillMaxWidth() + // A projection has a page too — the definition's — so it opens like any + // other entry. What it does not have is an occurrence to link to. + .clickable { onOpenEvent(entry.slug) }, + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = entry.title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + StatusPill( + text = stringResource(statusWordRes(entry.status, entry.scheduledFor)), + tone = if (entry.live) PillTone.Success else PillTone.Neutral, + ) + } + Text( + text = eventTime(entry.scheduledFor, entry.timezone), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + entry.seriesName?.takeIf { it.isNotBlank() }?.let { series -> + Text( + text = series, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (entry.isProjected) { + // Said in words rather than drawn as a dashed border, because a + // phone reader skimming a list will not decode a border and the + // distinction is worth more than the pixel it would cost. + Text( + text = stringResource(R.string.events_projected), + style = MaterialTheme.typography.bodySmall, + fontStyle = FontStyle.Italic, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/events/EventsViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/events/EventsViewModel.kt new file mode 100644 index 0000000..c01b040 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/events/EventsViewModel.kt @@ -0,0 +1,51 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.events + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.runicgateway.app.data.api.dto.EventCalendarDto +import com.runicgateway.app.data.repository.EventsRepository +import com.runicgateway.app.ui.UiState +import com.runicgateway.app.ui.toUiState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * The public event calendar (PLAN.md §9 M13, EVENTS.md §I). + * + * **No window is asked for**, and that is the whole of this view model's design. + * The server's default is now through 31 days out, so a client that computed a + * window before it could ask anything would make every deep link carry two ISO + * instants and would have to agree with the server about what "now" is. The + * window bound and the entry cap are the server's defence on the one surface with + * no login in front of it; there is nothing for the app to add. + * + * `toUiState`, not `toShardUiState`: these are CORE routes. A 404 here means the + * backend has no events at all, not that an admin switched a shard surface off, + * and offering "not published here" for it would name the wrong cause. + */ +@HiltViewModel +class EventsViewModel @Inject constructor( + private val repository: EventsRepository, +) : ViewModel() { + + private val _state = MutableStateFlow>(UiState.Loading) + val state: StateFlow> = _state.asStateFlow() + + init { + load() + } + + fun load() { + _state.value = UiState.Loading + viewModelScope.launch { + _state.value = repository.calendar().toUiState() + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/events/MyEventsScreen.kt b/app/src/main/java/com/runicgateway/app/ui/events/MyEventsScreen.kt new file mode 100644 index 0000000..8d13be1 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/events/MyEventsScreen.kt @@ -0,0 +1,142 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.events + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.runicgateway.app.R +import com.runicgateway.app.data.api.dto.EventHistoryEntryDto +import com.runicgateway.app.ui.UiState +import com.runicgateway.app.ui.components.EmptyView +import com.runicgateway.app.ui.components.ErrorView +import com.runicgateway.app.ui.components.LoadingView +import com.runicgateway.app.ui.components.ShardCard + +/** + * This account's event participation (EVENTS.md §J, M13). + * + * **The screen's one real design decision is what an unranked row says.** A run + * whose participants were collected but whose results have not been published has + * a score and no rank, and that is a real state rather than an error — it is the + * same state the admin run console has shown since events Phase 10. Rendering a + * dash with nothing explaining it would read as a bug; the row says the results + * are not published, which is a fact about the event rather than about the reader. + * + * Reached by **one drawer row for every signed-in account**, players and staff + * alike. The website mounts this twice only because its `RequirePlayer` guard sits + * over `/account` and the route behind it is role-agnostic; the app has no such + * wall, so it needs no second mount. + */ +@Composable +fun MyEventsScreen( + onOpenRun: (String, Long) -> Unit, + modifier: Modifier = Modifier, + viewModel: MyEventsViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + when (val items = state.items) { + is UiState.Loading -> LoadingView(modifier) + is UiState.Error -> ErrorView(items.kind, onRetry = viewModel::load, modifier = modifier) + is UiState.Success -> if (items.data.isEmpty()) { + EmptyView(stringResource(R.string.events_history_empty), modifier) + } else { + androidx.compose.foundation.lazy.LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(items.data.size, key = { items.data[it].id }) { index -> + HistoryRow(items.data[index], onOpenRun) + } + if (state.hasMore) { + item(key = "more") { + TextButton( + onClick = viewModel::loadMore, + enabled = !state.loadingMore, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + stringResource( + if (state.loadingMore) R.string.events_loading + else R.string.events_show_more, + ), + ) + } + } + } + } + } + } +} + +@Composable +private fun HistoryRow(entry: EventHistoryEntryDto, onOpenRun: (String, Long) -> Unit) { + ShardCard( + modifier = Modifier + .fillMaxWidth() + // Straight to the occurrence the reader took part in, not to whatever + // is next: `?run=` is what makes the event page answer about this one. + .clickable { onOpenRun(entry.slug, entry.runId) }, + ) { + Row( + Modifier.fillMaxWidth().padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = entry.title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = eventDateTime(entry.scheduledFor, entry.timezone), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + entry.seriesName?.takeIf { it.isNotBlank() }?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Column(horizontalAlignment = Alignment.End) { + Text( + text = entry.rank + ?.let { stringResource(R.string.events_rank, it) } + ?: stringResource(R.string.events_results_unpublished), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.End, + ) + Text( + text = stringResource(R.string.events_score, scoreText(entry.score)), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/events/MyEventsViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/events/MyEventsViewModel.kt new file mode 100644 index 0000000..c42ca75 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/events/MyEventsViewModel.kt @@ -0,0 +1,117 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.events + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.runicgateway.app.core.auth.Session +import com.runicgateway.app.core.auth.SessionManager +import com.runicgateway.app.core.result.ApiResult +import com.runicgateway.app.data.api.dto.EventHistoryEntryDto +import com.runicgateway.app.data.repository.EventsRepository +import com.runicgateway.app.ui.UiState +import com.runicgateway.app.ui.toUiState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * This account's event participation (PLAN.md §9 M13, EVENTS.md §J). + * + * **Self-scoped by the session and nothing else.** There is no id parameter on + * the route and deliberately none here: one account never reads another's, and + * there is no argument that could later grow into one. + * + * **Keyset-paged on the participation row's own id, never an offset** — the list + * gains a row every time the reader attends something, so an offset page would + * skip and repeat rows around the seam. + * + * ## Why this watches the session, when no other screen here does + * + * **A drawer route's view model outlives a sign-out.** `navigateTopLevel` saves + * and restores back-stack state, so the `NavBackStackEntry` for this route keeps + * its `ViewModelStore` across a sign-out and a sign-in as somebody else — and a + * view model that loads only in `init` never runs again. The live walk found the + * consequence: signing out of an admin account and back in as a player showed the + * PLAYER the admin's participation history, with no request made at all. + * + * The public event screens have the same lifetime and do not care, because a + * calendar is the same for everybody. This one is per-account, so the account is + * what it keys on: the flow emits the current session immediately, which is also + * the first load, and re-emits only when the signed-in id actually changes — a + * resume revalidation returning the same user does not refetch. + */ +@HiltViewModel +class MyEventsViewModel @Inject constructor( + private val repository: EventsRepository, + sessionManager: SessionManager, +) : ViewModel() { + + data class State( + val items: UiState> = UiState.Loading, + val hasMore: Boolean = false, + val loadingMore: Boolean = false, + ) + + private val _state = MutableStateFlow(State()) + val state: StateFlow = _state.asStateFlow() + + init { + viewModelScope.launch { + sessionManager.state + .map { (it as? Session.SignedIn)?.user?.id } + .distinctUntilChanged() + .collect { userId -> + // Signed out: drop the rows rather than leave the last + // account's on screen behind a shell that is about to + // navigate away. + if (userId == null) _state.value = State(items = UiState.Success(emptyList())) + else load() + } + } + } + + fun load() { + _state.value = State() + viewModelScope.launch { + val result = repository.history(PAGE) + _state.value = State( + items = result.toUiState(), + // A full page means there is probably another; a short one is the + // end. One request rather than a count the server does not send. + hasMore = (result as? ApiResult.Ok)?.data?.size == PAGE, + ) + } + } + + fun loadMore() { + val current = _state.value + val shown = (current.items as? UiState.Success)?.data ?: return + val last = shown.lastOrNull() ?: return + if (current.loadingMore || !current.hasMore) return + + _state.value = current.copy(loadingMore = true) + viewModelScope.launch { + when (val result = repository.history(PAGE, before = last.id)) { + is ApiResult.Ok -> _state.value = State( + items = UiState.Success(shown + result.data), + hasMore = result.data.size == PAGE, + ) + // A failed NEXT page keeps the pages already read rather than + // replacing a screenful of history with an error: the reader can + // still see what loaded, and tapping again retries. + else -> _state.value = current.copy(loadingMore = false) + } + } + } + + private companion object { + const val PAGE = 25 + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt b/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt index 56a9071..2c0056c 100644 --- a/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt +++ b/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt @@ -6,9 +6,12 @@ package com.runicgateway.app.ui.navigation import androidx.annotation.StringRes import com.runicgateway.app.R import com.runicgateway.app.core.auth.Session +import com.runicgateway.app.data.repository.Capability import com.runicgateway.app.data.repository.ShardFeature import com.runicgateway.app.data.repository.ShardFeatures +import com.runicgateway.app.data.repository.SiteCapabilities import com.runicgateway.app.data.repository.canSee +import com.runicgateway.app.data.repository.canUse /** * One shared, declarative, access-level navigation definition (PLAN.md §5): a @@ -52,6 +55,20 @@ data class MenuEntry( * isn't shard-derived and only [access] applies. */ val feature: String? = null, + /** + * The backend capability this row needs, or null when it needs none (M13). + * + * **A different question from [feature], which is why it is a second field + * and not a wider one.** This asks whether the code behind the row is + * *installed at all* — a per-HOST fact, from `GET /public/modules` and core's + * own list — while [feature] asks whether this shard publishes that surface + * to *this viewer*, which is per-viewer and admin-configurable. A site with no + * game module has no `shard` capability and no shard rows, whoever is looking; + * a site with one may still hide its market from anonymous visitors. + * + * The two also fail differently, and [canUse] is where that lives. + */ + val capability: String? = null, /** * An admin's own label for this row, from the shard's `nav_public` override * (THEMING_AND_NAV.md §6). Null — always, as coded — means [labelRes] stands. @@ -71,21 +88,84 @@ data class MenuEntry( val APP_MENU: List = listOf( MenuEntry(Routes.HOME, R.string.menu_home), MenuEntry(Routes.NEWS, R.string.menu_news), + // Events are CORE's, so this row is gated on core's own capability rather than + // a module's: a site with no game module still has a calendar. Placed here to + // match the website's own nav, where Events is the row after News. + MenuEntry(Routes.EVENTS, R.string.menu_events, capability = Capability.EVENTS), MenuEntry(Routes.WIKI, R.string.menu_wiki), - MenuEntry(Routes.SHARD, R.string.menu_shard, feature = ShardFeature.STATUS), + // The shard group. Every row needs the game module INSTALLED (one capability, + // because that is the only question a capability can answer) and its own + // feature published to this viewer (M11) — both, independently. + MenuEntry( + Routes.SHARD, + R.string.menu_shard, + feature = ShardFeature.STATUS, + capability = Capability.SHARD, + ), // Protocol 3.0 shard content (M11). Each hides when the shard doesn't publish it, // which for a brand-new install is every one of them until the plugin has swept. - MenuEntry(Routes.SHARD_RULES, R.string.menu_rules, feature = ShardFeature.RULESET), - MenuEntry(Routes.ATLAS, R.string.menu_atlas, feature = ShardFeature.ATLAS), - MenuEntry(Routes.SHARD_LEADERBOARDS, R.string.menu_leaderboards, feature = ShardFeature.LEADERBOARDS), - MenuEntry(Routes.SHARD_MARKET, R.string.menu_market, feature = ShardFeature.MARKET), + MenuEntry( + Routes.SHARD_RULES, + R.string.menu_rules, + feature = ShardFeature.RULESET, + capability = Capability.SHARD, + ), + MenuEntry( + Routes.ATLAS, + R.string.menu_atlas, + feature = ShardFeature.ATLAS, + capability = Capability.SHARD, + ), + MenuEntry( + Routes.SHARD_LEADERBOARDS, + R.string.menu_leaderboards, + feature = ShardFeature.LEADERBOARDS, + capability = Capability.SHARD, + ), + MenuEntry( + Routes.SHARD_MARKET, + R.string.menu_market, + feature = ShardFeature.MARKET, + capability = Capability.SHARD, + ), MenuEntry(Routes.page("about"), R.string.menu_about), MenuEntry(Routes.CONTACT, R.string.menu_contact), MenuEntry(Routes.ACCOUNT, R.string.menu_account, MenuAccess.SIGNED_IN), MenuEntry(Routes.NOTIFICATIONS, R.string.menu_notifications, MenuAccess.SIGNED_IN), - MenuEntry(Routes.PLAYER_CHARACTERS, R.string.menu_my_characters, MenuAccess.PLAYER), - MenuEntry(Routes.PLAYER_VENDORS, R.string.menu_my_vendors, MenuAccess.PLAYER), - MenuEntry(Routes.PLAYER_HOUSES, R.string.menu_my_houses, MenuAccess.PLAYER), + // Participation history: SIGNED_IN, not PLAYER. The route is `requireAuth` + // alone and self-scoped on the caller's own id, and the website needed two + // mounts for it only because `RequirePlayer` guards `/account` there. Staff + // attend events too, and event history is not game-linked data. + MenuEntry( + Routes.MY_EVENTS, + R.string.menu_my_events, + MenuAccess.SIGNED_IN, + capability = Capability.EVENTS, + ), + // These three read `/player/shard/*`, which is the SAME module's player mount — + // so they need the capability for the same reason the public rows do. They + // carry no `feature`, because the visibility framework covers the public + // surfaces and these are self-service, gated by role and ownership instead. + // That asymmetry is exactly why the live walk found them and the suite did + // not: "a shard row" had been defined as "a row with a feature". + MenuEntry( + Routes.PLAYER_CHARACTERS, + R.string.menu_my_characters, + MenuAccess.PLAYER, + capability = Capability.SHARD, + ), + MenuEntry( + Routes.PLAYER_VENDORS, + R.string.menu_my_vendors, + MenuAccess.PLAYER, + capability = Capability.SHARD, + ), + MenuEntry( + Routes.PLAYER_HOUSES, + R.string.menu_my_houses, + MenuAccess.PLAYER, + capability = Capability.SHARD, + ), // Staff operations (§1, M10) — revealed for staff roles; the backend re-checks every call. MenuEntry(Routes.ADMIN_DASHBOARD, R.string.menu_admin_dashboard, MenuAccess.STAFF), MenuEntry(Routes.ADMIN_CONTENT, R.string.menu_admin_content, MenuAccess.STAFF), @@ -94,24 +174,33 @@ val APP_MENU: List = listOf( ) /** - * The entries the given [session] may see, given the shard [features] it may reach. - * Pure + side-effect-free so the gating is unit-tested without Compose. + * The entries the given [session] may see, on a backend with these [capabilities] + * and this shard's [features]. Pure + side-effect-free so the gating is unit-tested + * without Compose. * - * Two independent filters, and both must pass: + * Three independent filters, and all three must pass: * * - [MenuEntry.access] against the session — who the caller is. + * - [MenuEntry.capability] against what this backend serves — whether the code + * behind the row is installed at all (M13). Per host. * - [MenuEntry.feature] against the shard's live visibility config — what this shard - * publishes at all (M11). `null` [features] means the answer isn't known yet and - * every shard entry shows; see [canSee] for why that direction is deliberate. + * publishes to this viewer (M11). Per viewer. + * + * **The last two both fail open on an unknown answer, but "unknown" means + * different things to them.** A `null` [features] is unknown; so is a `null` + * [capabilities] — but a *non-null* [capabilities] that does not name the string + * is an ANSWER, and it hides. Without that, a site with no game module renders + * five shard rows that each 404. See [canUse]. */ fun visibleEntries( entries: List, session: Session, features: ShardFeatures? = null, -): List = entries.filter { isEntryVisible(it, session, features) } + capabilities: SiteCapabilities? = null, +): List = entries.filter { isEntryVisible(it, session, features, capabilities) } /** - * [visibleEntries] for a single entry — the same two filters, and the same + * [visibleEntries] for a single entry — the same three filters, and the same * boundary. Split out because the drawer is a tree once an admin groups rows into * sections (§6.3): [pruneNav] applies this predicate inside a section as well, and * both callers must ask exactly one question or a sectioned row could be gated by @@ -121,6 +210,7 @@ fun isEntryVisible( entry: MenuEntry, session: Session, features: ShardFeatures? = null, + capabilities: SiteCapabilities? = null, ): Boolean { val allowedByRole = when (entry.access) { MenuAccess.PUBLIC -> true @@ -129,5 +219,7 @@ fun isEntryVisible( MenuAccess.STAFF -> session is Session.SignedIn && session.user.isStaff MenuAccess.MODERATOR -> session is Session.SignedIn && session.user.isModerator } - return allowedByRole && (entry.feature == null || canSee(features, entry.feature)) + return allowedByRole && + canUse(capabilities, entry.capability) && + (entry.feature == null || canSee(features, entry.feature)) } diff --git a/app/src/main/java/com/runicgateway/app/ui/navigation/NavPaths.kt b/app/src/main/java/com/runicgateway/app/ui/navigation/NavPaths.kt index 381d02a..69766cf 100644 --- a/app/src/main/java/com/runicgateway/app/ui/navigation/NavPaths.kt +++ b/app/src/main/java/com/runicgateway/app/ui/navigation/NavPaths.kt @@ -11,34 +11,68 @@ import com.runicgateway.app.data.repository.ContentRepository.PostCategory * The public nav an admin edits is keyed by **website** paths, so honoring it in * the app needs a translation. This is the one new piece of cross-repo coupling * the milestone introduces, which is why it lives in a single file with the - * website's own array quoted right beside it — the coupling is visible and + * website's own arrays quoted right beside it — the coupling is visible and * reviewable in one place rather than spread across the drawer's call sites. * + * ## The nav is TWO arrays now, and that is what M13 had to correct + * + * This file was written when the website's public nav was one sixteen-row array. + * Since the module-system cutover on 2026-08-12 it is **core's eight rows plus + * every installed module's**, interleaved at render time by `withModuleNav`, and + * a module's pages are mounted by core at `//` — so the nine + * shard rows moved from `/site/champs` to `/uo/champs` and this table stopped + * resolving any of them. Three things followed, all of them true of the shipped + * app until M13: a nav override on a shard row was ignored, an added link to a + * shard page handed off to a browser instead of opening natively, and the sort + * key line below was a sixteen-row line against a nav numbered differently. + * + * **The nine `/uo/` paths are hardcoded, and they are ONE module's.** The alternative + * — reading the installed module's id from `GET /public/modules` and building + * `//shard` — is forbidden by `MODULE_API.md` §2.9 (*"a client must not infer + * a route from a capability"*) and would hardcode the same path shape less + * visibly. A site running a different game module matches none of these nine, its + * links hand off to a Custom Tab, and that is the correct answer rather than a + * gap: core cannot tell the app what another module calls its pages. + * * Verbatim from `website/client/src/components/SiteHeader.jsx`, which is the - * exported owner of the list (`export const NAV`, and Admin → Navigation edits + * exported owner of core's list (`export const NAV`, and Admin → Navigation edits * exactly it): * * ```js * export const NAV = [ * { label: 'Home', to: '/', end: true }, * { label: 'News', to: '/site/news' }, + * { label: 'Events', to: '/site/events' }, * { label: 'Screenshots', to: '/site/screenshots' }, * { label: 'Five on Friday', to: '/site/five-on-friday' }, * { label: 'Newsletter', to: '/site/newsletter' }, * { label: 'Wiki', to: '/wiki' }, - * { label: 'Shard', to: '/site/shard', feature: 'status' }, - * { label: 'Champions', to: '/site/champs', feature: 'champs' }, - * { label: 'Guilds', to: '/site/guilds', feature: 'guilds' }, - * { label: 'Governors', to: '/site/governors', feature: 'governors' }, - * { label: 'Houses', to: '/site/houses', feature: 'houses' }, - * { label: 'Rules', to: '/site/rules', feature: 'ruleset' }, - * { label: 'Atlas', to: '/site/atlas', feature: 'atlas' }, - * { label: 'Leaderboards', to: '/site/leaderboards', feature: 'leaderboards' }, - * { label: 'Market', to: '/site/market', feature: 'market' }, * { label: 'About', to: '/site/about' }, * ] * ``` * + * and from `module-uo/client/src/entry.jsx`, which registers the rest: + * + * ```jsx + * registry.registerNav(ID, { + * area: 'public', + * items: [ + * { label: 'Shard', to: '/uo/shard', feature: 'status' }, + * { label: 'Champions', to: '/uo/champs', feature: 'champs' }, + * { label: 'Guilds', to: '/uo/guilds', feature: 'guilds' }, + * { label: 'Governors', to: '/uo/governors', feature: 'governors' }, + * { label: 'Houses', to: '/uo/houses', feature: 'houses' }, + * { label: 'Rules', to: '/uo/rules', feature: 'ruleset' }, + * { label: 'Atlas', to: '/uo/atlas', feature: 'atlas' }, + * { label: 'Leaderboards', to: '/uo/leaderboards', feature: 'leaderboards' }, + * { label: 'Market', to: '/uo/market', feature: 'market' }, + * ], + * }) + * ``` + * + * None of those nine declares an `order`, so `mergeFlat` appends them after core's + * rows in registration order — which is the order they are listed in below. + * * The `feature` values are **not** mirrored here on purpose. [APP_MENU] is the * app's own source of truth for gating, and a second copy of a security-relevant * value that drifts silently is worth more than it costs. This table carries the @@ -58,27 +92,33 @@ data class WebNavPath(val path: String, val route: String) * *this* list (the admin's editor writes the position a row holds on the web), so * a row the admin never moved has to take its key from the same number line or * explicit and implicit keys would be incomparable. See `NavOverrides.kt`. + * + * Core's eight first, then the module's nine, because that is what `withModuleNav` + * renders and therefore what the admin's editor numbered. */ val WEBSITE_PUBLIC_NAV: List = listOf( WebNavPath("/", Routes.HOME), WebNavPath("/site/news", Routes.NEWS), + WebNavPath("/site/events", Routes.EVENTS), // The app's News screen carries all four categories as tabs, so these three // have a route but no drawer row of their own — see the note below. WebNavPath("/site/screenshots", Routes.news(PostCategory.SCREENSHOTS)), WebNavPath("/site/five-on-friday", Routes.news(PostCategory.FIVE_ON_FRIDAY)), WebNavPath("/site/newsletter", Routes.news(PostCategory.NEWSLETTER)), WebNavPath("/wiki", Routes.WIKI), - WebNavPath("/site/shard", Routes.SHARD), - // Behind the Shard hub in the app, deliberately — no drawer row either. - WebNavPath("/site/champs", Routes.SHARD_CHAMPS), - WebNavPath("/site/guilds", Routes.SHARD_GUILDS), - WebNavPath("/site/governors", Routes.SHARD_GOVERNORS), - WebNavPath("/site/houses", Routes.SHARD_HOUSES), - WebNavPath("/site/rules", Routes.SHARD_RULES), - WebNavPath("/site/atlas", Routes.ATLAS), - WebNavPath("/site/leaderboards", Routes.SHARD_LEADERBOARDS), - WebNavPath("/site/market", Routes.SHARD_MARKET), WebNavPath("/site/about", Routes.page("about")), + // module-uo's rows. Mounted by core at `//`, which is why + // every one of these is `/uo/` and not `/site/`. + WebNavPath("/uo/shard", Routes.SHARD), + // Behind the Shard hub in the app, deliberately — no drawer row either. + WebNavPath("/uo/champs", Routes.SHARD_CHAMPS), + WebNavPath("/uo/guilds", Routes.SHARD_GUILDS), + WebNavPath("/uo/governors", Routes.SHARD_GOVERNORS), + WebNavPath("/uo/houses", Routes.SHARD_HOUSES), + WebNavPath("/uo/rules", Routes.SHARD_RULES), + WebNavPath("/uo/atlas", Routes.ATLAS), + WebNavPath("/uo/leaderboards", Routes.SHARD_LEADERBOARDS), + WebNavPath("/uo/market", Routes.SHARD_MARKET), ) /** @@ -134,6 +174,11 @@ private fun normalizeWebPath(path: String?): String? { */ private val RESERVED_TOP_LEVEL = setOf( "admin", "account", "player", "site", "wiki", "invite", "preview", "api", "uploads", + // An installed module's pages are mounted at `//…` and are not CMS pages. + // Only ids the app knows about need listing: an unknown module's `/` would + // resolve to a CMS page that 404s, which is the same answer the browser gives + // it, and core cannot enumerate them for us here anyway. + "uo", ) /** @@ -153,16 +198,17 @@ private val RESERVED_TOP_LEVEL = setOf( * } /> * } /> * } /> + * } /> + * } /> + * } /> * } /> * } /> - * } /> - * } /> - * ... /site/champs, /guilds, /governors, /houses, /rules, /leaderboards, /market - * } /> - * } /> - * } /> * } /> * } /> + * // Installed modules' pages, mounted at `//`: + * // /uo/shard, /uo/shard/activity, /uo/champs, /uo/guilds, /uo/guilds/:id, + * // /uo/governors, /uo/houses, /uo/rules, /uo/leaderboards, /uo/market, + * // /uo/market/vendors/:serial, /uo/atlas, /uo/atlas/:slug * // CMS pages: top-level /:slug, matched only after the named routes above * } /> * ``` @@ -177,19 +223,31 @@ private val RESERVED_TOP_LEVEL = setOf( * /site/{screenshots,five-on-friday,newsletter} * → NEWS, that category's tab * /site/newsletter/ → POST (the site's one post-detail route) + * /site/events → EVENTS + * /site/events/series/ → EVENT_SERIES + * /site/events/[?run=] → EVENT (the one route that takes a query) * /wiki → WIKI * /wiki/ → WIKI_PAGE - * /site/ → the mapped shard route (§6.2) - * /site/atlas/ → ATLAS_CREATURE - * /site/market/vendors/ → SHARD_MARKET_VENDOR + * /uo/ → the mapped shard route (§6.2) + * /uo/atlas/ → ATLAS_CREATURE + * /uo/market/vendors/ → SHARD_MARKET_VENDOR * /site/about → PAGE("about") * / → PAGE(slug), unless is reserved * anything else → null, i.e. the Custom Tab * ``` * - * **A path carrying a query or a fragment hands off**, whatever its route part - * says. No app route takes either, so a native match would quietly drop what the - * admin wrote; the browser honors it exactly. + * **A path carrying a query or a fragment hands off — with exactly one + * exception.** The rule exists because no app route took either, so a native + * match would quietly drop what the admin wrote while the browser honors it. The + * event page (M13) is the first route that takes a query, and it takes one key: + * `run`, which is what every `event.` announcement's `eventUrl` carries. So a + * `?run=` on an event path resolves natively and **anything else in a query + * string, any second parameter, and any fragment still hand off** — the carve-out + * is one key on one path, not a general "parse the query". + * + * That narrowness is the point: an admin who writes `/site/events/x?utm=mail` gets + * the browser, which honors `utm`, rather than an app screen that silently ignored + * it. * * Resolving a path is not the same as being allowed to see the screen behind it. * A link to `/site/market` on a shard that does not publish the market lands on @@ -197,24 +255,70 @@ private val RESERVED_TOP_LEVEL = setOf( * URL on the web does too (§6.3). */ fun resolveWebPath(path: String?): String? { - val normalized = normalizeWebPath(path) ?: return null - if (normalized.any { it == '?' || it == '#' }) return null - WEB_PATH_TO_ROUTE[normalized]?.let { return it } + val raw = path?.trim().orEmpty() + // A fragment is never honored natively: no app route has one to put it in. + if (raw.isEmpty() || '#' in raw) return null + + val queryAt = raw.indexOf('?') + val query = if (queryAt >= 0) raw.substring(queryAt + 1) else "" + val normalized = normalizeWebPath(if (queryAt >= 0) raw.substring(0, queryAt) else raw) + ?: return null + + if (query.isEmpty()) WEB_PATH_TO_ROUTE[normalized]?.let { return it } if (!normalized.startsWith("/")) return null // Blank segments ("/site//news") mean a malformed path, not a slug. val segments = normalized.removePrefix("/").split('/') if (segments.any { it.isBlank() }) return null + // The one path that may carry a query, and the one key it may carry. Checked + // before the general "a query hands off" rule below, and nowhere else. + if (segments.size == 3 && segments[0] == "site" && segments[1] == "events" && + segments[2] != "series" + ) { + // No query is the ordinary case — a link to the event rather than to one + // of its occurrences. A query is honored only when it is exactly the run. + if (query.isEmpty()) return Routes.event(segments[2]) + val run = runParam(query) ?: return null + return Routes.event(segments[2], run) + } + if (query.isNotEmpty()) return null + return when { segments.size == 1 -> segments[0].takeIf { it !in RESERVED_TOP_LEVEL }?.let(Routes::page) segments[0] == "wiki" && segments.size == 2 -> Routes.wikiPage(segments[1]) - segments[0] != "site" -> null - segments.size == 3 && segments[1] == "newsletter" -> + segments[0] == "site" && segments.size == 3 && segments[1] == "newsletter" -> Routes.post(PostCategory.NEWSLETTER.urlSlug, segments[2]) - segments.size == 3 && segments[1] == "atlas" -> Routes.atlasCreature(segments[2]) - segments.size == 4 && segments[1] == "market" && segments[2] == "vendors" -> - Routes.marketVendor(segments[3]) + segments[0] == "site" && segments.size == 4 && segments[1] == "events" && + segments[2] == "series" -> Routes.eventSeries(segments[3]) + segments[0] == MODULE_UO && segments.size == 3 && segments[1] == "atlas" -> + Routes.atlasCreature(segments[2]) + segments[0] == MODULE_UO && segments.size == 4 && segments[1] == "market" && + segments[2] == "vendors" -> Routes.marketVendor(segments[3]) else -> null } } + +/** + * The `run` value of a query that consists of **exactly** `run=`, or + * null for every other query — including one that merely contains a `run` among + * others. + * + * Deliberately not a query parser. A second parameter means the writer meant + * something the app cannot honor, and the honest answer to that is the browser. + * An empty value (`?run=`) is null too: it would reach the screen as a blank + * string and be forwarded to the server as one. + */ +private fun runParam(query: String): String? { + val value = query.removePrefix("run=") + if (value.length == query.length || value.isEmpty()) return null + return value.takeIf { '&' !in it && '=' !in it } +} + +/** + * The module id whose public pages this table maps. + * + * Named once rather than spelled into four branches, so what is coupled to one + * module is countable. It is a literal on purpose — see the file header. + */ +private const val MODULE_UO = "uo" diff --git a/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt b/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt index 5f4e8b4..c21ad96 100644 --- a/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt +++ b/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt @@ -47,6 +47,29 @@ object Routes { const val NOTIFICATIONS = "notifications" const val NOTIFICATIONS_SETTINGS = "notifications/settings" + /** + * Events (§9 M13) — CORE's, not a module's: these screens exist on a backend + * with no game module at all, which is why they are not under `shard/`. + * + * **[EVENT_ROUTE] is the app's first route that takes a query**, and it takes + * exactly one: `run`, naming which occurrence a results table is about. The + * page lives at the definition's slug so a weekly event has one address that + * survives a retitle, and the occurrence has to live somewhere else. See + * [resolveWebPath], whose "a query hands off" rule this is the one exception + * to. + * + * **[MY_EVENTS] is `account/events` and not `events/mine`**, which is not + * cosmetic: `events/mine` and `events/{slug}` are both two segments, and a + * static-versus-argument race between two NavHost patterns is exactly the bug + * events Phase 13 shipped one tier along, where a static `events/new` outranked + * `events/:id` in React Router and made creating an event impossible for seven + * phases. Under `account/` there is no dynamic sibling and no race to lose. + */ + const val EVENTS = "events" + const val EVENT_ROUTE = "events/{slug}?run={run}" + const val EVENT_SERIES = "events/series/{slug}" + const val MY_EVENTS = "account/events" + /** Public shard hub (§6.2). */ const val SHARD = "shard" @@ -97,6 +120,7 @@ object Routes { const val CATEGORY = "category" const val ID_OR_SLUG = "idOrSlug" const val SERIAL = "serial" + const val RUN = "run" } fun page(slug: String) = "page/$slug" @@ -119,6 +143,21 @@ object Routes { /** One creature's atlas page, by slug. */ fun atlasCreature(slug: String) = "atlas/$slug" + /** + * One event's page, optionally about one occurrence. + * + * [runId] is what an announcement's link carries, and it is dropped when + * absent rather than sent as an empty argument — `events/x?run=` would reach + * the screen as a blank string and be forwarded to the server as one. + */ + fun event(slug: String, runId: String? = null): String { + val base = "events/$slug" + return if (runId.isNullOrBlank()) base else "$base?run=$runId" + } + + /** One arc, by slug. */ + fun eventSeries(slug: String) = "events/series/$slug" + /** * The in-app destination a tapped push notification deep-links to (§11, M7 * Part 2 work item 7). Maps a stream id to the screen that shows its content; @@ -135,9 +174,19 @@ object Routes { com.runicgateway.app.core.push.PushStreams.VENDOR_SALE -> PLAYER_VENDORS com.runicgateway.app.core.push.PushStreams.HOUSE_IDOC -> PLAYER_HOUSES com.runicgateway.app.core.push.PushStreams.ACCOUNT_LOGIN -> ACCOUNT - else -> HOME + // An engagement rule's tickle carries the TRIGGER id as its stream + // (ENGAGEMENT.md §7.2's one namespace), and `event.run.started` is the only + // event trigger that is also a push stream. The calendar is the honest + // destination when there is no inbox row to send it to — the tickle names + // no occurrence, so there is no page to open. A row, when there is one, + // wins via [forTickle] and carries the link that does. + else -> if (streamId.startsWith(EVENT_STREAM_PREFIX)) EVENTS else HOME } + /** What every core `event.` trigger id begins with (EVENTS.md §J). */ + private const val EVENT_STREAM_PREFIX = "event." + + /** * Where a tapped tickle lands, given both halves of `{ stream, ref }`. * diff --git a/app/src/main/java/com/runicgateway/app/ui/notifications/InboxFormatting.kt b/app/src/main/java/com/runicgateway/app/ui/notifications/InboxFormatting.kt index 63156eb..e357508 100644 --- a/app/src/main/java/com/runicgateway/app/ui/notifications/InboxFormatting.kt +++ b/app/src/main/java/com/runicgateway/app/ui/notifications/InboxFormatting.kt @@ -3,8 +3,7 @@ */ package com.runicgateway.app.ui.notifications -import java.time.Instant -import java.time.LocalDateTime +import com.runicgateway.app.core.time.parseWireInstant import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle @@ -14,11 +13,8 @@ import java.time.format.FormatStyle * locale (ENGAGEMENT.md phase 8). Pure, so it is unit-testable off-device. * * **Two shapes have to be accepted, and which one arrives is not the app's to - * decide.** Express serializes a `Date` to ISO-8601 with a `Z`, but the value - * starts life as a MariaDB `DATETIME`, and a column read back as a string reaches - * the wire as `2026-08-31 07:13:50` with no zone at all. A zoneless stamp is read - * as UTC — that is what the server stores — rather than as local time, which - * would silently shift every timestamp by the device's offset. + * decide** — see [parseWireInstant], which owns that trap for every screen that + * reads a timestamp, this one and the event screens (M13). * * Anything unparseable returns null and the row simply shows no stamp: a * notification with an odd date is still worth reading. @@ -29,25 +25,10 @@ fun inboxTimestamp( formatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT), ): String? { - val instant = parseInstant(raw) ?: return null + val instant = parseWireInstant(raw) ?: return null return try { formatter.withZone(zone).format(instant) } catch (_: Exception) { null } } - -private fun parseInstant(raw: String): Instant? { - val text = raw.trim() - if (text.isEmpty()) return null - return try { - Instant.parse(text) - } catch (_: Exception) { - try { - // No zone on the wire → UTC, because that is what the server stored. - LocalDateTime.parse(text.replace(' ', 'T')).atZone(ZoneId.of("UTC")).toInstant() - } catch (_: Exception) { - null - } - } -} diff --git a/app/src/main/java/com/runicgateway/app/ui/notifications/InboxScreen.kt b/app/src/main/java/com/runicgateway/app/ui/notifications/InboxScreen.kt index 8679694..7ef54f8 100644 --- a/app/src/main/java/com/runicgateway/app/ui/notifications/InboxScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/notifications/InboxScreen.kt @@ -63,6 +63,7 @@ import com.runicgateway.app.ui.components.ShardCard @Composable fun InboxScreen( onOpenSettings: () -> Unit, + onOpenRoute: (String) -> Unit, modifier: Modifier = Modifier, viewModel: InboxViewModel = hiltViewModel(), ) { @@ -119,10 +120,18 @@ fun InboxScreen( onOpen = { item -> viewModel.markRead(item.id) // Most items have no url at all — an inbox row is complete on - // its own — and the ones that do carry a SITE-RELATIVE path, - // so the view model resolves it against the configured shard - // before anything is opened. - viewModel.linkFor(item)?.let { WebHandoff.open(context, it) } + // its own — and the ones that do carry a SITE-RELATIVE path. + // + // A path the app has a screen for opens natively (M13): an + // event announcement's link is the case that made this worth + // doing. Everything else resolves against the configured + // shard and goes to the browser, exactly as before. + val route = viewModel.routeFor(item) + if (route != null) { + onOpenRoute(route) + } else { + viewModel.linkFor(item)?.let { WebHandoff.open(context, it) } + } }, ) } diff --git a/app/src/main/java/com/runicgateway/app/ui/notifications/InboxViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/notifications/InboxViewModel.kt index 7bee565..a8e7d49 100644 --- a/app/src/main/java/com/runicgateway/app/ui/notifications/InboxViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/notifications/InboxViewModel.kt @@ -14,6 +14,7 @@ import com.runicgateway.app.core.result.map import com.runicgateway.app.data.api.dto.NotificationItemDto import com.runicgateway.app.data.repository.NotificationsRepository import com.runicgateway.app.ui.UiState +import com.runicgateway.app.ui.navigation.resolveWebPath import com.runicgateway.app.ui.toUiState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -220,6 +221,38 @@ class InboxViewModel @Inject constructor( return baseUrlHolder.current?.resolve(raw)?.toString() } + /** + * The app route this item opens natively, or null when it has none and + * [linkFor] should hand it to a browser (M13). + * + * **Why this exists at all:** events Phase 14a gave the six public `event.` + * triggers an `eventUrl` of the form `/site/events/?run=`, so an + * inbox row about an event now has a native destination — and opening a + * Custom Tab onto a page the app itself renders is a worse answer than it was + * when there was no such page. + * + * **It reuses `resolveWebPath` rather than adding a second link-routing + * mechanism.** That function is already the app's read of the site's own route + * table, it already answers null for everything it does not recognise, and + * every path it does not recognise still hands off exactly as before. Adding a + * parser here would put the decision in two places. + * + * The item's url is site-relative by contract, but an absolute one on this + * host is accepted too: the shape is the server's to change, and a link that + * opened the browser only because it arrived fully qualified would be a + * puzzle. An absolute url on ANOTHER host is not ours to route — the app has + * no screen for somebody else's site — so it falls through to the browser. + */ + fun routeFor(item: NotificationItemDto): String? { + val raw = item.url?.trim().orEmpty() + if (raw.isEmpty()) return null + val base = baseUrlHolder.current ?: return null + val resolved = base.resolve(raw) ?: return null + if (resolved.host != base.host) return null + val query = resolved.query + return resolveWebPath(resolved.encodedPath + if (query.isNullOrEmpty()) "" else "?$query") + } + /** * Keep the snapshot in step with a local read. * diff --git a/app/src/main/java/com/runicgateway/app/ui/session/SessionViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/session/SessionViewModel.kt index 4b10175..0f0679d 100644 --- a/app/src/main/java/com/runicgateway/app/ui/session/SessionViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/session/SessionViewModel.kt @@ -10,6 +10,8 @@ import com.runicgateway.app.core.auth.SessionManager import com.runicgateway.app.data.repository.AuthRepository import com.runicgateway.app.data.repository.ShardFeatures import com.runicgateway.app.data.repository.ShardFeaturesRepository +import com.runicgateway.app.data.repository.SiteCapabilities +import com.runicgateway.app.data.repository.SiteCapabilitiesRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -26,6 +28,7 @@ class SessionViewModel @Inject constructor( sessionManager: SessionManager, private val authRepository: AuthRepository, shardFeaturesRepository: ShardFeaturesRepository, + siteCapabilitiesRepository: SiteCapabilitiesRepository, ) : ViewModel() { val session: StateFlow = sessionManager.state @@ -38,6 +41,19 @@ class SessionViewModel @Inject constructor( */ val shardFeatures: StateFlow = shardFeaturesRepository.features + /** + * What this BACKEND serves — core's capabilities and every installed module's + * (M13). Exposed here for the reason [shardFeatures] is: the shared menu is + * the consumer, and a row is filtered by both. + * + * **Read-only here, and deliberately not refreshed here.** This answer is per + * HOST, not per viewer: signing in does not install a module. It is resolved + * beside the appearance in [com.runicgateway.app.ui.AppViewModel], which is + * what owns the host's lifecycle — first load, resume, and the Settings → + * Server switch that invalidates it. + */ + val capabilities: StateFlow = siteCapabilitiesRepository.capabilities + init { // The answer is per-viewer, so it is re-resolved on every session change. // A StateFlow conflates equal values, so a resume revalidation that returns diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 710cd54..ace8450 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -41,6 +41,7 @@ Opens in your browser Home News + Events Wiki Shard Rules @@ -50,6 +51,7 @@ About Contact My account + My events My characters My vendors My houses @@ -525,4 +527,43 @@ Your house entered IDOC Login to your account New notification + + + + Happening now + Scheduled + Finished + Cancelled + Did not happen + + Nothing on the calendar just yet — check back soon. + + Expected — not yet confirmed + Showing the first part of a busy calendar. + + Part of %1$s + Next + Under way + Nothing scheduled at the moment. + This event has not been scheduled yet. + Coming up + Previously + Results + Results were published with nobody recorded. + + Unnamed + + You have not taken part in an event yet. + Rank %1$d + + Results not published + Score %1$s + Show more + Loading… diff --git a/app/src/test/java/com/runicgateway/app/data/api/fake/FakeEventsApi.kt b/app/src/test/java/com/runicgateway/app/data/api/fake/FakeEventsApi.kt new file mode 100644 index 0000000..2003373 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/data/api/fake/FakeEventsApi.kt @@ -0,0 +1,60 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.data.api.fake + +import com.runicgateway.app.data.api.EventsApi +import com.runicgateway.app.data.api.dto.EventCalendarDto +import com.runicgateway.app.data.api.dto.EventHistoryDto +import com.runicgateway.app.data.api.dto.EventSeriesResponse +import com.runicgateway.app.data.api.dto.PublicEventResponse + +/** + * A configurable fake of [EventsApi] (M13). Set the `var` a call should answer + * with; set [error] to make every call throw. + * + * [lastRun] and [lastBefore] are what the tests that matter assert on: the run a + * page was asked about, and the keyset cursor a history page walked back from. + */ +class FakeEventsApi : EventsApi { + + var error: Throwable? = null + + var calendar: EventCalendarDto = EventCalendarDto() + var event: PublicEventResponse = PublicEventResponse() + var series: EventSeriesResponse = EventSeriesResponse() + var history: EventHistoryDto = EventHistoryDto() + + /** The `run` the last event read carried, so a test can assert a blank was dropped. */ + var lastRun: String? = null + var lastSlug: String? = null + + /** The keyset cursor the last history page asked for; null on a first page. */ + var lastBefore: Long? = null + var historyCalls: Int = 0 + + private fun reply(value: T): T { + error?.let { throw it } + return value + } + + override suspend fun getCalendar(from: String?, to: String?, seriesId: Long?): EventCalendarDto = + reply(calendar) + + override suspend fun getEvent(slug: String, run: String?): PublicEventResponse { + lastSlug = slug + lastRun = run + return reply(event) + } + + override suspend fun getSeries(slug: String): EventSeriesResponse { + lastSlug = slug + return reply(series) + } + + override suspend fun getHistory(limit: Int?, before: Long?): EventHistoryDto { + historyCalls++ + lastBefore = before + return reply(history) + } +} diff --git a/app/src/test/java/com/runicgateway/app/data/api/fake/FakePublicApi.kt b/app/src/test/java/com/runicgateway/app/data/api/fake/FakePublicApi.kt index 6cb7479..9019190 100644 --- a/app/src/test/java/com/runicgateway/app/data/api/fake/FakePublicApi.kt +++ b/app/src/test/java/com/runicgateway/app/data/api/fake/FakePublicApi.kt @@ -13,6 +13,7 @@ import com.runicgateway.app.data.api.dto.GovernorDto import com.runicgateway.app.data.api.dto.GovernorTermDto import com.runicgateway.app.data.api.dto.GuildDto import com.runicgateway.app.data.api.dto.HouseDto +import com.runicgateway.app.data.api.dto.ModulesDto import com.runicgateway.app.data.api.dto.OnlineStaffDto import com.runicgateway.app.data.api.dto.PageDto import com.runicgateway.app.data.api.dto.PostDto @@ -67,6 +68,19 @@ class FakePublicApi : PublicApi { var houses: List = emptyList() var shardFeatures: ShardFeaturesDto = ShardFeaturesDto() + /** + * `GET /public/modules` (M13). Empty by default, which is a real answer: a + * backend serving no modules at all. + */ + var modules: ModulesDto = ModulesDto() + + /** + * Per-call failures, for the one thing [error] cannot express: capability + * resolution reads TWO routes and one failing is not the same as both. + */ + var statusError: Throwable? = null + var modulesError: Throwable? = null + // Protocol 3.0 content (M11). `ruleset` is nullable on the wire: null means the // shard has never published one, which is a success, not a failure. var ruleset: RulesetDto? = null @@ -94,9 +108,17 @@ class FakePublicApi : PublicApi { } override suspend fun probeStatus(absoluteStatusUrl: String): StatusDto = reply(status) - override suspend fun getStatus(): StatusDto = reply(status) + override suspend fun getStatus(): StatusDto { + statusError?.let { throw it } + return reply(status) + } override suspend fun getSettings(): SettingsDto = reply(settings) + override suspend fun getModules(): ModulesDto { + modulesError?.let { throw it } + return reply(modules) + } + override suspend fun getPosts(category: String): List = reply(posts) override suspend fun getPost(category: String, idOrSlug: String): PostDto = reply(post) override suspend fun getPage(slug: String): PageDto = reply(page) diff --git a/app/src/test/java/com/runicgateway/app/data/repository/SiteCapabilitiesRepositoryTest.kt b/app/src/test/java/com/runicgateway/app/data/repository/SiteCapabilitiesRepositoryTest.kt new file mode 100644 index 0000000..c0cb08e --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/data/repository/SiteCapabilitiesRepositoryTest.kt @@ -0,0 +1,160 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.data.repository + +import com.runicgateway.app.data.api.dto.InstalledModuleDto +import com.runicgateway.app.data.api.dto.ModulesDto +import com.runicgateway.app.data.api.dto.StatusDto +import com.runicgateway.app.data.api.dto.VersionDto +import com.runicgateway.app.data.api.fake.FakePublicApi +import com.runicgateway.app.util.httpError +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException + +/** + * What this backend serves, and — the point of the class — the three different + * things "we don't know" can mean (PLAN.md §9 M13). + * + * **Absence of an answer is not an answer of absence.** Before M13 the app + * collapsed a 404, a dead network and "no such module" into one `null` and + * treated all three as "show everything", which rendered five shard rows that + * each 404 on a site running a different game. + */ +class SiteCapabilitiesRepositoryTest { + + private val api = FakePublicApi() + private val repository = SiteCapabilitiesRepository(api) + + private fun serving(core: List, moduleCaps: List) { + api.status = StatusDto(version = VersionDto(capabilities = core)) + api.modules = ModulesDto( + modules = listOf(InstalledModuleDto(id = "uo", capabilities = moduleCaps)), + ) + } + + @Test fun bothListsAreMergedAndStaySeparable() = runTest { + serving(core = listOf("events"), moduleCaps = listOf("shard", "atlas")) + repository.refresh() + + val answer = repository.capabilities.value!! + assertEquals(setOf("events"), answer.core) + assertEquals(setOf("shard", "atlas"), answer.modules) + // A menu entry does not care which half serves it. + assertTrue("events" in answer) + assertTrue("shard" in answer) + assertFalse("market" in answer) + } + + @Test fun aBackendWithNoModulesAnswersRatherThanFailing() = runTest { + api.status = StatusDto(version = VersionDto(capabilities = listOf("events"))) + api.modules = ModulesDto(modules = emptyList()) + repository.refresh() + + val answer = repository.capabilities.value!! + assertTrue("events" in answer) + // The answer that hides the shard rows, and the whole reason for the class. + assertFalse("shard" in answer) + assertFalse(canUse(answer, Capability.SHARD)) + assertTrue(canUse(answer, Capability.EVENTS)) + } + + @Test fun aBackendOlderThanEventsOmitsTheKeyAndThatIsAnAnswer() = runTest { + // No `capabilities` in the version block at all — the value is in what is + // absent, and it must not read as "unknown". + api.status = StatusDto(version = VersionDto(service = "runic-gateway")) + api.modules = ModulesDto(modules = listOf(InstalledModuleDto(id = "uo", capabilities = listOf("shard")))) + repository.refresh() + + val answer = repository.capabilities.value!! + assertTrue(answer.core.isEmpty()) + assertFalse(canUse(answer, Capability.EVENTS)) + assertTrue(canUse(answer, Capability.SHARD)) + } + + // ── The three failure directions ───────────────────────────────────── + + @Test fun aHostThatHasNeverAnsweredLeavesTheGateOpen() = runTest { + api.error = IOException("offline") + repository.refresh() + + // Null, not empty. The drawer renders as it did before this existed rather + // than flickering its rows in on every cold start. + assertNull(repository.capabilities.value) + assertTrue(canUse(repository.capabilities.value, Capability.SHARD)) + assertTrue(canUse(repository.capabilities.value, Capability.EVENTS)) + } + + @Test fun aFailedRefreshKeepsTheLastAnswer() = runTest { + serving(core = listOf("events"), moduleCaps = listOf("shard")) + repository.refresh() + + api.error = IOException("offline") + repository.refresh() + + // A moment with no connectivity is not an uninstall. + val answer = repository.capabilities.value!! + assertTrue("shard" in answer) + assertTrue("events" in answer) + } + + @Test fun oneCallFailingKeepsThatHalfAndUpdatesTheOther() = runTest { + serving(core = listOf("events"), moduleCaps = listOf("shard")) + repository.refresh() + + // The module list answers with the game module gone; the status call is down. + api.statusError = httpError(500) + api.modules = ModulesDto(modules = emptyList()) + repository.refresh() + + val answer = repository.capabilities.value!! + // The half that answered is believed… + assertFalse("shard" in answer) + // …and the half that did not keeps what it last said. + assertTrue("events" in answer) + } + + @Test fun aFiveHundredOnTheModuleListIsNotAnEmptyList() = runTest { + serving(core = listOf("events"), moduleCaps = listOf("shard")) + repository.refresh() + + // Core answers 500 for a module list read before its loader ran, precisely + // so a caller cannot read it as "no modules installed". + api.modulesError = httpError(500) + repository.refresh() + + assertTrue("shard" in repository.capabilities.value!!) + } + + @Test fun aServerSwitchDropsTheAnswerEntirely() = runTest { + serving(core = listOf("events"), moduleCaps = listOf("shard")) + repository.refresh() + assertNotNull(repository.capabilities.value) + + repository.invalidate() + + // Not "empty" — unknown. The new host has said nothing, and inheriting the + // old one's answer would hide its shard rows until its first read lands. + assertNull(repository.capabilities.value) + assertTrue(canUse(repository.capabilities.value, Capability.SHARD)) + } + + @Test fun twoModulesMayDeclareTheSameString() = runTest { + api.status = StatusDto() + api.modules = ModulesDto( + modules = listOf( + InstalledModuleDto(id = "uo", capabilities = listOf("shard")), + InstalledModuleDto(id = "other", capabilities = listOf("shard", "cards")), + ), + ) + repository.refresh() + + assertEquals(setOf("shard", "cards"), repository.capabilities.value!!.modules) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/events/EventTimesTest.kt b/app/src/test/java/com/runicgateway/app/ui/events/EventTimesTest.kt new file mode 100644 index 0000000..0571583 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/events/EventTimesTest.kt @@ -0,0 +1,151 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.events + +import com.runicgateway.app.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.Instant +import java.time.ZoneId +import java.util.Locale + +/** + * Rendering an event's instant and its status word (EVENTS.md §I). + * + * Two of these are regression tests for defects the WEBSITE shipped and its live + * walk caught in events Phase 14a — restated in Kotlin because a rule that is + * only written down in another language gets re-derived wrong. + */ +class EventTimesTest { + + private val uk = Locale.UK + + // ── The zone split: the day is the reader's, the time is the event's ── + + @Test fun theTimeIsTheEventsZoneNotTheReaders() { + // 2026-09-11T00:00Z is 20:00 the previous evening in New York. A shard's + // 8pm event is 8pm to everyone reading about it; rendering the reader's + // 02:00 would be true and useless. + assertEquals("20:00 New York", eventTime("2026-09-11T00:00:00Z", "America/New_York", uk)) + assertEquals("02:00 Berlin", eventTime("2026-09-11T00:00:00Z", "Europe/Berlin", uk)) + } + + @Test fun theDayHeadingIsTheReadersOwn() { + // The same instant files under different days for two readers, which is the + // other half of the split: "what is on this month" is about the month the + // person holding the phone is living in. + val instant = "2026-09-11T00:30:00Z" + val london = readerDayLabel(instant, ZoneId.of("Europe/London"), uk) + val newYork = readerDayLabel(instant, ZoneId.of("America/New_York"), uk) + assertNotEquals(london, newYork) + assertTrue(london, london.contains("11")) + assertTrue(newYork, newYork.contains("10")) + } + + @Test fun anUnknownZoneFallsBackToUtcRatherThanThrowing() { + // A typo in a definition's timezone column must still render. + assertEquals("00:00 Nowhere", eventTime("2026-09-11T00:00:00Z", "Mars/Nowhere", uk)) + assertEquals("00:00 UTC", eventTime("2026-09-11T00:00:00Z", null, uk)) + } + + @Test fun aZonelessStampIsReadAsUtc() { + // MariaDB DATETIME read back as a string reaches the wire with no zone. It + // is what the server stored, so it is UTC — reading it as local time would + // shift every event by the device's offset. + assertEquals("00:00 UTC", eventTime("2026-09-11 00:00:00", "UTC", uk)) + } + + @Test fun anUnreadableInstantRendersNothingRatherThanCrashing() { + assertEquals("", eventTime("not a date", "UTC", uk)) + assertEquals("", eventDateTime(null, "UTC", uk)) + assertEquals("", readerDayLabel("", ZoneId.of("UTC"), uk)) + } + + @Test fun theZoneIsNamedAsAReaderRecognisesIt() { + assertEquals("New York", shortZone("America/New_York")) + assertEquals("Berlin", shortZone("Europe/Berlin")) + assertEquals("UTC", shortZone(null)) + assertEquals("UTC", shortZone(" ")) + } + + // ── Scores are fractional, and the walk is why we know ─────────────── + + @Test fun aWholeScorePrintsWhole() { + // Most modules score by counting, and `12.0` reads as a rounding artefact. + assertEquals("1420", scoreText(1420.0, uk)) + assertEquals("0", scoreText(0.0, uk)) + assertEquals("-5", scoreText(-5.0, uk)) + } + + @Test fun aFractionalScoreKeepsItsDigits() { + // The live walk's first history row was 318.5. Declaring this field `Long` + // did not round it — kotlinx refused the whole body, and a 200 rendered as + // "Something went wrong on the server." + assertEquals("318.5", scoreText(318.5, uk)) + assertEquals("0.25", scoreText(0.25, uk)) + // DECIMAL(18,4): four places, and no trailing zeros past the last digit. + assertEquals("1.0625", scoreText(1.0625, uk)) + } + + @Test fun aNonFiniteScoreDoesNotReachTheScreen() { + assertEquals("0", scoreText(Double.NaN, uk)) + assertEquals("0", scoreText(Double.POSITIVE_INFINITY, uk)) + } + + // ── The status word: the tense follows the CLOCK, not the status ────── + + @Test fun aFutureCancellationReadsCancelled() { + // Phase 14a's own defect: the calendar told a visitor an event four days + // away "DID NOT HAPPEN". It had been cancelled, not missed. + val now = Instant.parse("2026-09-08T12:00:00Z") + assertEquals( + R.string.events_status_cancelled, + statusWordRes("cancelled", "2026-09-12T20:00:00Z", now), + ) + } + + @Test fun aPastCancellationReadsDidNotHappen() { + // Which is also the honest word for the `failed` and `missed` runs the + // server folds into `cancelled`. + val now = Instant.parse("2026-09-08T12:00:00Z") + assertEquals( + R.string.events_status_did_not_happen, + statusWordRes("cancelled", "2026-09-01T20:00:00Z", now), + ) + } + + @Test fun anUnreadableInstantOnACancellationReadsPast() { + val now = Instant.parse("2026-09-08T12:00:00Z") + assertEquals( + R.string.events_status_did_not_happen, + statusWordRes("cancelled", null, now), + ) + } + + @Test fun theOtherThreeStatusesDoNotDependOnTheClock() { + val past = Instant.parse("2027-01-01T00:00:00Z") + val future = Instant.parse("2020-01-01T00:00:00Z") + for (now in listOf(past, future)) { + assertEquals(R.string.events_status_live, statusWordRes("live", "2026-09-12T20:00:00Z", now)) + assertEquals(R.string.events_status_completed, statusWordRes("completed", "2026-09-01T20:00:00Z", now)) + assertEquals(R.string.events_status_scheduled, statusWordRes("scheduled", "2026-09-12T20:00:00Z", now)) + } + } + + @Test fun anUnknownStatusFallsBackTheWayTheServerDoes() { + // `publicStatus()` folds anything it does not know to `scheduled`, so a word + // the app has never seen is a contract break rather than a state — and + // rendering a raw enum at a reader is not an improvement on it. + assertEquals( + R.string.events_status_scheduled, + statusWordRes("starting", "2026-09-12T20:00:00Z", Instant.now()), + ) + assertEquals( + R.string.events_status_scheduled, + statusWordRes(null, null, Instant.now()), + ) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/events/EventsViewModelsTest.kt b/app/src/test/java/com/runicgateway/app/ui/events/EventsViewModelsTest.kt new file mode 100644 index 0000000..cd820d7 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/events/EventsViewModelsTest.kt @@ -0,0 +1,261 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.events + +import androidx.lifecycle.SavedStateHandle +import com.runicgateway.app.core.auth.SessionManager +import com.runicgateway.app.core.auth.StoredSession +import com.runicgateway.app.core.auth.TokenStore +import com.runicgateway.app.data.api.dto.SafeUserDto +import com.runicgateway.app.data.api.dto.EventCalendarDto +import com.runicgateway.app.data.api.dto.EventCalendarEntryDto +import com.runicgateway.app.data.api.dto.EventHistoryDto +import com.runicgateway.app.data.api.dto.EventHistoryEntryDto +import com.runicgateway.app.data.api.dto.EventSeriesDto +import com.runicgateway.app.data.api.dto.EventSeriesResponse +import com.runicgateway.app.data.api.dto.PublicEventDto +import com.runicgateway.app.data.api.dto.PublicEventResponse +import com.runicgateway.app.data.api.fake.FakeEventsApi +import com.runicgateway.app.data.repository.EventsRepository +import com.runicgateway.app.ui.ErrorKind +import com.runicgateway.app.ui.UiState +import com.runicgateway.app.util.MainDispatcherRule +import com.runicgateway.app.util.httpError +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import java.io.IOException + +/** The four event screens' view models (PLAN.md §9 M13). */ +class EventsViewModelsTest { + + @get:Rule val dispatcher = MainDispatcherRule() + + private val api = FakeEventsApi() + private val repository = EventsRepository(api) + + private fun entry(slug: String, at: String, kind: String = "run") = + EventCalendarEntryDto(kind = kind, title = slug, slug = slug, scheduledFor = at) + + // ── The calendar ───────────────────────────────────────────────────── + + @Test fun theCalendarAsksForNoWindow() = runTest { + api.calendar = EventCalendarDto(entries = listOf(entry("a", "2026-09-11T20:00:00Z"))) + + val state = EventsViewModel(repository).state.value + + assertTrue(state is UiState.Success) + assertEquals(1, (state as UiState.Success).data.entries.size) + } + + @Test fun aFourOhFourOnTheCalendarIsNotAFeatureBeingSwitchedOff() = runTest { + // These are CORE routes: `toShardUiState`'s "not published here" would name + // the wrong cause, and offer an explanation an admin cannot act on. + api.error = httpError(404) + + val state = EventsViewModel(repository).state.value + + assertEquals(ErrorKind.NOT_FOUND, (state as UiState.Error).kind) + } + + @Test fun theServersOrderIsPreservedByTheDayGrouping() { + // The server already sorted by instant; grouping must not re-sort. Two + // entries on one reader-day share a heading, a third on another starts one. + val entries = listOf( + entry("a", "2026-09-11T20:00:00Z"), + entry("b", "2026-09-11T21:00:00Z"), + entry("c", "2026-09-14T20:00:00Z"), + ) + + val days = groupByReaderDay(entries) + + assertEquals(2, days.size) + assertEquals(listOf("a", "b"), days[0].entries.map { it.slug }) + assertEquals(listOf("c"), days[1].entries.map { it.slug }) + } + + @Test fun anEntrySaysWhetherItIsAForecast() { + assertTrue(entry("a", "2026-09-11T20:00:00Z", kind = "projected").isProjected) + assertTrue(!entry("a", "2026-09-11T20:00:00Z").isProjected) + } + + // ── One event ──────────────────────────────────────────────────────── + + @Test fun theRunIsPassedThroughUntouched() = runTest { + api.event = PublicEventResponse(PublicEventDto(slug = "yew")) + val handle = SavedStateHandle(mapOf("slug" to "yew", "run" to "3692")) + + EventViewModel(repository, handle) + + assertEquals("yew", api.lastSlug) + assertEquals("3692", api.lastRun) + } + + @Test fun aBlankRunIsDroppedRatherThanForwarded() = runTest { + api.event = PublicEventResponse(PublicEventDto(slug = "yew")) + val handle = SavedStateHandle(mapOf("slug" to "yew", "run" to " ")) + + EventViewModel(repository, handle) + + assertNull(api.lastRun) + } + + @Test fun anAbsentRunIsNotSent() = runTest { + api.event = PublicEventResponse(PublicEventDto(slug = "yew")) + + EventViewModel(repository, SavedStateHandle(mapOf("slug" to "yew"))) + + assertNull(api.lastRun) + } + + @Test fun theEnvelopeIsUnwrappedForTheScreen() = runTest { + api.event = PublicEventResponse(PublicEventDto(slug = "yew", title = "The Yew Invasion")) + + val state = EventViewModel(repository, SavedStateHandle(mapOf("slug" to "yew"))).state.value + + assertEquals("The Yew Invasion", (state as UiState.Success).data.title) + } + + // ── An arc ─────────────────────────────────────────────────────────── + + @Test fun anArcWithNothingListedIsAnErrorRatherThanAnEmptyPage() = runTest { + // The server's decision, not the screen's: a page for an empty arc would + // publish that an operator has named something they have not announced. + api.error = httpError(404) + + val state = EventSeriesViewModel(repository, SavedStateHandle(mapOf("slug" to "void"))).state.value + + assertEquals(ErrorKind.NOT_FOUND, (state as UiState.Error).kind) + } + + @Test fun anArcUnwrapsItsEnvelope() = runTest { + api.series = EventSeriesResponse(EventSeriesDto(name = "The Void", slug = "void")) + + val state = EventSeriesViewModel(repository, SavedStateHandle(mapOf("slug" to "void"))).state.value + + assertEquals("The Void", (state as UiState.Success).data.name) + } + + // ── Participation history ──────────────────────────────────────────── + + private fun rows(vararg ids: Long) = EventHistoryDto( + entries = ids.map { EventHistoryEntryDto(id = it, runId = it, slug = "e$it") }, + ) + + @Test fun aFractionalScoreDecodesRatherThanFailingTheWholeBody() { + // The regression the live walk found: `score` is DECIMAL(18,4) on the wire + // and a `Long` field makes kotlinx refuse the ENTIRE response, so a 200 + // reaches the screen as a server error. Decoded from real JSON so the DTO's + // type is what is under test, not a hand-built object. + val json = Json { ignoreUnknownKeys = true; explicitNulls = false } + + val history = json.decodeFromString( + """{"entries":[{"id":2,"runId":3667,"title":"Midsummer Fair","slug":"mf","score":318.5,"rank":null}]}""", + ) + assertEquals(318.5, history.entries.single().score, 0.0) + + val event = json.decodeFromString( + """{"event":{"slug":"mf","results":{"runId":1,"participants":[{"name":"A","score":318.5}]}}}""", + ) + assertEquals(318.5, event.event.results!!.participants.single().score, 0.0) + } + + // A signed-in session manager, so the history view model has an account to + // scope to. The screen is unreachable signed out. + private class FakeTokenStore(private var stored: StoredSession?) : TokenStore { + override fun load(): StoredSession? = stored + override fun save(session: StoredSession) { stored = session } + override fun clear() { stored = null } + } + + private fun playerDto(userId: Long) = + SafeUserDto(id = userId, username = "u$userId", role = "player") + + private fun sessionFor(userId: Long) = + SessionManager(FakeTokenStore(StoredSession("a", "r", userId, "u$userId", "player"))) + + @Test fun switchingAccountDoesNotShowThePreviousOnesHistory() { + // **The leak the live walk found, and the suite could not.** A drawer + // route's view model survives a sign-out: `navigateTopLevel` saves and + // restores back-stack state, so the entry keeps its ViewModelStore and a + // view model that loaded only in `init` never runs again. Signing out of + // an admin and in as a player showed the player the admin's rows, with no + // request made at all. + val sessions = sessionFor(33) + api.history = rows(9, 8) + val vm = MyEventsViewModel(repository, sessions) + assertEquals(2, (vm.state.value.items as UiState.Success).data.size) + + api.history = rows(1) + sessions.onSignedOut() + // Signed out, the previous account's rows are gone rather than left up. + assertEquals(0, (vm.state.value.items as UiState.Success).data.size) + + sessions.onSignedIn("a", "r", playerDto(35)) + assertEquals(listOf(1L), (vm.state.value.items as UiState.Success).data.map { it.id }) + } + + @Test fun aResumeRevalidationReturningTheSameUserDoesNotRefetch() { + // The other half: the gate is the account, not every session emission. + val sessions = sessionFor(33) + api.history = rows(9, 8) + val vm = MyEventsViewModel(repository, sessions) + val callsAfterFirstLoad = api.historyCalls + + sessions.onUserRefreshed(playerDto(33)) + + assertEquals(callsAfterFirstLoad, api.historyCalls) + assertEquals(2, (vm.state.value.items as UiState.Success).data.size) + } + + @Test fun aShortFirstPageIsTheEnd() = runTest { + api.history = rows(3, 2, 1) + + val state = MyEventsViewModel(repository, sessionFor(1)).state.value + + assertEquals(3, (state.items as UiState.Success).data.size) + assertTrue(!state.hasMore) + } + + @Test fun aFullPageWalksBackOnTheLastRowsOwnId() = runTest { + // Keyset, never an offset: the list gains rows at the top as the reader + // attends things, so an offset page would skip and repeat around the seam. + api.history = rows(*(1L..25L).reversed().toList().toLongArray()) + val vm = MyEventsViewModel(repository, sessionFor(1)) + assertTrue(vm.state.value.hasMore) + + api.history = rows(0) + vm.loadMore() + + assertEquals(1L, api.lastBefore) + assertEquals(26, (vm.state.value.items as UiState.Success).data.size) + assertTrue(!vm.state.value.hasMore) + } + + @Test fun aFailedNextPageKeepsThePagesAlreadyRead() = runTest { + api.history = rows(*(1L..25L).reversed().toList().toLongArray()) + val vm = MyEventsViewModel(repository, sessionFor(1)) + + api.error = IOException("offline") + vm.loadMore() + + // Not an error screen replacing a screenful of history. + assertEquals(25, (vm.state.value.items as UiState.Success).data.size) + assertTrue(!vm.state.value.loadingMore) + } + + @Test fun loadMoreDoesNothingWithoutAFullFirstPage() = runTest { + api.history = rows(2, 1) + val vm = MyEventsViewModel(repository, sessionFor(1)) + val callsAfterLoad = api.historyCalls + + vm.loadMore() + + assertEquals(callsAfterLoad, api.historyCalls) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/navigation/MenuCapabilityGatingTest.kt b/app/src/test/java/com/runicgateway/app/ui/navigation/MenuCapabilityGatingTest.kt new file mode 100644 index 0000000..aa1e757 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/navigation/MenuCapabilityGatingTest.kt @@ -0,0 +1,175 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.navigation + +import com.runicgateway.app.core.auth.Role +import com.runicgateway.app.core.auth.Session +import com.runicgateway.app.core.auth.SessionUser +import com.runicgateway.app.data.repository.Capability +import com.runicgateway.app.data.repository.ShardFeature +import com.runicgateway.app.data.repository.ShardFeatures +import com.runicgateway.app.data.repository.SiteCapabilities +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The third gate on a drawer row (PLAN.md §5, §9 M13): whether the code behind it + * is installed on this backend at all. + * + * **A different question from the feature flag, which is why it is a third + * filter.** Capability is per HOST — it changes when an operator installs or + * removes a module. A feature is per VIEWER — it changes on sign-in. The two also + * fail differently, and the difference is the bug this milestone fixed. + */ +class MenuCapabilityGatingTest { + + private fun signedIn(role: Role) = + Session.SignedIn(SessionUser(id = 1, username = "u", role = role)) + + private fun serving(vararg caps: String) = + SiteCapabilities(core = emptySet(), modules = caps.toSet()) + + private val shardEntry = MenuEntry( + "shard", + 0, + MenuAccess.PUBLIC, + feature = ShardFeature.STATUS, + capability = Capability.SHARD, + ) + private val eventsEntry = MenuEntry("events", 0, MenuAccess.PUBLIC, capability = Capability.EVENTS) + private val plainEntry = MenuEntry("news", 0, MenuAccess.PUBLIC) + + private fun everyFeature() = ShardFeatures(level = "anonymous", visible = setOf(ShardFeature.STATUS)) + + @Test fun aRowHidesWhenTheBackendSaysItsModuleIsNotInstalled() { + // The whole point. On a site with no game module `/public/shard/features` + // 404s, so the FEATURE answer is unknown and fails open — and before M13 + // that was the only answer the app had, so the row rendered and 404'd. + val entries = listOf(plainEntry, shardEntry, eventsEntry) + + val visible = visibleEntries( + entries, + Session.SignedOut, + features = null, + capabilities = serving("events"), + ).map { it.route } + + assertEquals(listOf("news", "events"), visible) + } + + @Test fun anUnknownCapabilityAnswerLeavesEveryRowShowing() { + // A host that has never answered. Same fail-open direction the feature gate + // takes, and for the same reason: the server gates every call regardless. + val entries = listOf(plainEntry, shardEntry, eventsEntry) + + val visible = visibleEntries( + entries, + Session.SignedOut, + features = everyFeature(), + capabilities = null, + ).map { it.route } + + assertEquals(listOf("news", "shard", "events"), visible) + } + + @Test fun anEmptyAnswerIsNotAnUnknownAnswer() { + // The distinction the whole milestone rests on, as one assertion. + assertTrue(isEntryVisible(shardEntry, Session.SignedOut, everyFeature(), null)) + assertFalse(isEntryVisible(shardEntry, Session.SignedOut, everyFeature(), serving())) + } + + @Test fun bothGatesMustPassAndNeitherCanOverrideTheOther() { + val installed = serving(Capability.SHARD) + + // Installed but not published to this viewer: hidden. + assertFalse( + isEntryVisible(shardEntry, Session.SignedOut, ShardFeatures("anonymous", emptySet()), installed), + ) + // Published but the module is gone: hidden. (Not a state a real backend + // reaches, and the gate must not depend on that.) + assertFalse(isEntryVisible(shardEntry, Session.SignedOut, everyFeature(), serving())) + // Both: shown. + assertTrue(isEntryVisible(shardEntry, Session.SignedOut, everyFeature(), installed)) + } + + @Test fun theRoleGateStillOutranksBoth() { + // An admin row is an admin row on a backend that serves everything. + val adminEntry = MenuEntry("admin/dashboard", 0, MenuAccess.STAFF) + assertFalse( + isEntryVisible(adminEntry, Session.SignedOut, everyFeature(), serving(Capability.SHARD)), + ) + assertTrue( + isEntryVisible(adminEntry, signedIn(Role.ADMIN), everyFeature(), serving(Capability.SHARD)), + ) + } + + @Test fun aRowWithNoCapabilityIsNeverGatedByOne() { + // Every row that predates M13 keeps the behaviour it had. + assertTrue(isEntryVisible(plainEntry, Session.SignedOut, null, serving())) + assertTrue(isEntryVisible(plainEntry, Session.SignedOut, null, null)) + } + + // ── The shipped menu, as coded ─────────────────────────────────────── + + @Test fun everyRowOnAModulePathDeclaresTheShardCapability() { + // **Defined by ROUTE, not by "has a feature", and the live walk is why.** + // The first cut of this test asked whether every row with a `feature` + // declared the capability — which is true and insufficient: the three + // player game-data rows read `/player/shard/*`, the same module's player + // mount, and carry no feature at all because they are gated by ownership + // rather than by the visibility framework. They rendered on a backend with + // no module installed and answered "This content couldn't be found", + // through a green suite. + val onAModulePath = APP_MENU.filter { + it.route.startsWith("shard") || it.route.startsWith("player/") || it.route == Routes.ATLAS + } + assertEquals(8, onAModulePath.size) + assertTrue( + onAModulePath.filter { it.capability != Capability.SHARD }.map { it.route }.toString(), + onAModulePath.all { it.capability == Capability.SHARD }, + ) + } + + @Test fun aModuleLessBackendShowsNoModuleRowToAnybody() { + // The walk's assertion, as a test: every rung, and not one module row. + val core = SiteCapabilities(core = setOf(Capability.EVENTS), modules = emptySet()) + for (session in listOf( + Session.SignedOut, + signedIn(Role.PLAYER), + signedIn(Role.ADMIN), + )) { + val visible = visibleEntries(APP_MENU, session, everyFeature(), core).map { it.route } + assertTrue( + visible.toString(), + visible.none { + it.startsWith("shard") || it.startsWith("player/") || it == Routes.ATLAS + }, + ) + // …and the core rows are all still there. + assertTrue(Routes.EVENTS in visible) + assertTrue(Routes.NEWS in visible) + } + } + + @Test fun bothEventRowsDeclareCoresCapabilityAndNoFeature() { + // Events are core's. A `feature` on one of them would gate a core screen on + // a module's visibility config, which is the coupling this separation exists + // to prevent. + val eventRows = APP_MENU.filter { it.capability == Capability.EVENTS } + assertEquals(listOf(Routes.EVENTS, Routes.MY_EVENTS), eventRows.map { it.route }) + assertTrue(eventRows.all { it.feature == null }) + } + + @Test fun myEventsIsSignedInRatherThanPlayer() { + // The route is `requireAuth` alone and self-scoped; staff attend events too, + // and the website needed two mounts only because of its own /account guard. + val row = APP_MENU.first { it.route == Routes.MY_EVENTS } + assertEquals(MenuAccess.SIGNED_IN, row.access) + assertTrue(isEntryVisible(row, signedIn(Role.ADMIN), null, serving(Capability.EVENTS))) + assertTrue(isEntryVisible(row, signedIn(Role.PLAYER), null, serving(Capability.EVENTS))) + assertFalse(isEntryVisible(row, Session.SignedOut, null, serving(Capability.EVENTS))) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/navigation/NavOverridesTest.kt b/app/src/test/java/com/runicgateway/app/ui/navigation/NavOverridesTest.kt index 9c12aa3..ace878d 100644 --- a/app/src/test/java/com/runicgateway/app/ui/navigation/NavOverridesTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/navigation/NavOverridesTest.kt @@ -44,12 +44,24 @@ class NavOverridesTest { private fun routes(nav: JsonObject?) = applyNavOverrides(APP_MENU, nav).map { it.route } - /** The public block's routes, in coded order — the first nine of APP_MENU. */ - private val codedPublic = listOf( - Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.SHARD_RULES, - Routes.ATLAS, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET, Routes.page("about"), + /** + * The public block once a stored row has made the merge sort it — the website's + * number line, not the app's coded order. + * + * **About sits above the shard rows here, and that is the corrected table + * showing through** (M13): About is core's last nav row at index 7 and the + * module's nine append after it at 8-16. Under the stale sixteen-row table + * About was index 15 and came last, which is what these assertions used to say. + */ + private val mergedPublic = listOf( + Routes.HOME, Routes.NEWS, Routes.EVENTS, Routes.WIKI, Routes.page("about"), + Routes.SHARD, Routes.SHARD_RULES, Routes.ATLAS, Routes.SHARD_LEADERBOARDS, + Routes.SHARD_MARKET, ) + /** How many rows that block holds, so the take/drop below say why. */ + private val publicBlock = mergedPublic.size + // ── AC-1: the untouched instance ───────────────────────────────────── @Test fun noStoredRowReturnsTheCodedMenuItself() { @@ -71,7 +83,7 @@ class NavOverridesTest { "/site/news" to entry(hidden = false), "/admin/appearance" to entry(label = "Nope"), "/site/screenshots" to entry(label = "Shots", order = 0), - "/site/champs" to entry(hidden = true), + "/uo/champs" to entry(hidden = true), ) assertSame(APP_MENU, applyNavOverrides(APP_MENU, stored)) @@ -85,7 +97,7 @@ class NavOverridesTest { val merged = applyNavOverrides(APP_MENU, stored) - assertEquals(codedPublic, merged.take(9).map { it.route }) + assertEquals(mergedPublic, merged.take(publicBlock).map { it.route }) assertEquals("Codex", merged.first { it.route == Routes.WIKI }.label) assertNull(merged.first { it.route == Routes.NEWS }.label) } @@ -93,14 +105,16 @@ class NavOverridesTest { // ── Labels ─────────────────────────────────────────────────────────── @Test fun aLabelOverridesTheBundledString() { - val merged = applyNavOverrides(APP_MENU, nav("/site/shard" to entry(label = " The Realm "))) + // `/uo/shard`, not `/site/shard`: the row belongs to module-uo and core + // mounts a module's pages at `//` (M13). + val merged = applyNavOverrides(APP_MENU, nav("/uo/shard" to entry(label = " The Realm "))) val shard = merged.first { it.route == Routes.SHARD } assertEquals("The Realm", shard.label) // The override lands on `label` and nothing else — the gates are untouched. assertEquals(ShardFeature.STATUS, shard.feature) assertEquals(MenuAccess.PUBLIC, shard.access) - assertEquals(codedPublic, merged.take(9).map { it.route }) + assertEquals(mergedPublic, merged.take(publicBlock).map { it.route }) } @Test fun aNonStringLabelIsIgnored() { @@ -112,7 +126,7 @@ class NavOverridesTest { // ── Hidden ─────────────────────────────────────────────────────────── @Test fun hiddenDropsTheRow() { - val routes = routes(nav("/site/market" to entry(hidden = true))) + val routes = routes(nav("/uo/market" to entry(hidden = true))) assertTrue(Routes.SHARD_MARKET !in routes) assertEquals(APP_MENU.size - 1, routes.size) @@ -128,7 +142,7 @@ class NavOverridesTest { } @Test fun hiddenFalseHidesNothing() { - assertSame(APP_MENU, applyNavOverrides(APP_MENU, nav("/site/market" to entry(hidden = false)))) + assertSame(APP_MENU, applyNavOverrides(APP_MENU, nav("/uo/market" to entry(hidden = false)))) } @Test fun hiddenWinsOverALabelOnTheSameRow() { @@ -140,44 +154,50 @@ class NavOverridesTest { // ── Order ──────────────────────────────────────────────────────────── @Test fun anExplicitOrderMovesTheRowWithinThePublicBlock() { - // The website's own indices: About is 15 and Home is 0, so swapping them - // is what an admin dragging About to the top writes. + // The website's own indices: About is 7, and Market — the last row of all, + // now that the module's nine append after core's eight — is 16. Dragging + // About to the top and Home past the end writes exactly this. val routes = routes( nav( "/site/about" to entry(order = 0), - "/" to entry(order = 15), + "/" to entry(order = 17), ), ) assertEquals( listOf( - Routes.page("about"), Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.SHARD_RULES, - Routes.ATLAS, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET, Routes.HOME, + Routes.page("about"), Routes.NEWS, Routes.EVENTS, Routes.WIKI, Routes.SHARD, + Routes.SHARD_RULES, Routes.ATLAS, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET, + Routes.HOME, ), - routes.take(9), + routes.take(publicBlock), ) } @Test fun anUntouchedRowKeepsItsPlaceOnTheWebsitesNumberLine() { // The tie-break that needs the website's order rather than the app's: an - // explicit 5 meets Wiki's implicit 5 (its index in the site's nav, where - // the three news categories sit between News and Wiki). Explicit wins. - val routes = routes(nav("/site/about" to entry(order = 5))) + // explicit 6 meets Wiki's implicit 6 (its index in the site's nav, where + // Events and the three news categories sit between News and Wiki). Explicit + // wins. That the number moved from 5 to 6 when the site gained a row is the + // whole reason this table has to track the site's nav rather than the app's. + val routes = routes(nav("/site/about" to entry(order = 6))) assertEquals( - listOf(Routes.HOME, Routes.NEWS, Routes.page("about"), Routes.WIKI), - routes.take(4), + listOf(Routes.HOME, Routes.NEWS, Routes.EVENTS, Routes.page("about"), Routes.WIKI), + routes.take(5), ) } @Test fun theAppsOwnRowsKeepTheirCodedOrderAfterThePublicBlock() { - // Contact, Account, Notifications, the three player groups and the four - // staff rows have no website counterpart to be reordered against (§6.2). - val tail = APP_MENU.drop(9).map { it.route } + // Contact, Account, Notifications, My Events, the three player groups and + // the four staff rows have no website counterpart to be reordered against + // (§6.2) — My Events because `/account/events` is behind the site's own + // auth guard and is not on its public nav at all. + val tail = APP_MENU.drop(publicBlock).map { it.route } val merged = routes(nav("/site/about" to entry(order = 0))) - assertEquals(tail, merged.drop(9)) + assertEquals(tail, merged.drop(publicBlock)) } @Test fun reorderingAndHidingCompose() { @@ -224,7 +244,7 @@ class NavOverridesTest { @Test fun anOverrideCannotUnhideAFeatureGatedRow() { val stored = nav( - "/site/market" to entry(label = "Bazaar", hidden = false, order = 0), + "/uo/market" to entry(label = "Bazaar", hidden = false, order = 0), ) val visible = visibleEntries( diff --git a/app/src/test/java/com/runicgateway/app/ui/navigation/NavPathsTest.kt b/app/src/test/java/com/runicgateway/app/ui/navigation/NavPathsTest.kt index d4cc3a2..37c0d3e 100644 --- a/app/src/test/java/com/runicgateway/app/ui/navigation/NavPathsTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/navigation/NavPathsTest.kt @@ -19,13 +19,27 @@ import org.junit.Test class NavPathsTest { @Test fun everyWebsiteNavPathIsMapped() { - // The sixteen rows of SiteHeader.jsx's NAV, quoted in NavPaths.kt. If the - // site adds one, this is the test that says so — a path with no mapping is - // silently unresolvable in phase 6's link handling. - assertEquals(16, WEBSITE_PUBLIC_NAV.size) + // Core's eight rows plus module-uo's nine, both quoted in NavPaths.kt. If + // either side adds one, this is the test that says so — a path with no + // mapping is silently unresolvable in phase 6's link handling, which is + // exactly how the nine shard rows went stale for a month after the + // module-system cutover moved them from /site/ to /uo/ (M13). + assertEquals(17, WEBSITE_PUBLIC_NAV.size) assertEquals(WEBSITE_PUBLIC_NAV.size, WEB_PATH_TO_ROUTE.size) } + @Test fun theShardRowsAreTheModulesPathsNotCores() { + // The defect M13 fixed, kept as an assertion: these nine belong to + // module-uo and core mounts a module's pages at `//`. A `/site/` + // spelling here is the stale table coming back. + val shard = WEBSITE_PUBLIC_NAV.map { it.path }.filter { it.startsWith("/uo/") } + assertEquals(9, shard.size) + assertTrue(WEBSITE_PUBLIC_NAV.none { it.path.startsWith("/site/shard") }) + assertTrue(WEBSITE_PUBLIC_NAV.none { it.path == "/site/champs" }) + assertNull(appRouteForWebPath("/site/champs")) + assertEquals(Routes.SHARD_CHAMPS, appRouteForWebPath("/uo/champs")) + } + @Test fun everyMappedRouteIsDistinct() { // WEB_ROUTE_ORDER is keyed by route, so a duplicate would silently drop a // row's position from the sort. @@ -34,15 +48,20 @@ class NavPathsTest { } @Test fun theWebsitesOrderIsPreserved() { - // Load-bearing: a stored `order` is an index into this list. + // Load-bearing: a stored `order` is an index into this list. Core numbers + // 0-7 and `mergeFlat` appends the module's rows after them, none of which + // declares an `order` of its own. assertEquals(0, WEB_ROUTE_ORDER[Routes.HOME]) assertEquals(1, WEB_ROUTE_ORDER[Routes.NEWS]) - assertEquals(5, WEB_ROUTE_ORDER[Routes.WIKI]) - assertEquals(15, WEB_ROUTE_ORDER[Routes.page("about")]) + assertEquals(2, WEB_ROUTE_ORDER[Routes.EVENTS]) + assertEquals(6, WEB_ROUTE_ORDER[Routes.WIKI]) + assertEquals(7, WEB_ROUTE_ORDER[Routes.page("about")]) + assertEquals(8, WEB_ROUTE_ORDER[Routes.SHARD]) + assertEquals(16, WEB_ROUTE_ORDER[Routes.SHARD_MARKET]) } - @Test fun theNineDrawerRowsAreTheIntersectionWithAppMenu() { - // Nine of the sixteen have a drawer row. The other seven are mapped but not + @Test fun theDrawerRowsAreTheIntersectionWithAppMenu() { + // Ten of the seventeen have a drawer row. The other seven are mapped but not // surfaced — three news category tabs and the four Shard hub boards — and // an override for one of them is ignored rather than obeyed (§6.2). val coded = APP_MENU.map { it.route }.toSet() @@ -50,8 +69,8 @@ class NavPathsTest { assertEquals( listOf( - "/", "/site/news", "/wiki", "/site/shard", "/site/rules", - "/site/atlas", "/site/leaderboards", "/site/market", "/site/about", + "/", "/site/news", "/site/events", "/wiki", "/site/about", + "/uo/shard", "/uo/rules", "/uo/atlas", "/uo/leaderboards", "/uo/market", ), surfaced, ) @@ -62,7 +81,7 @@ class NavPathsTest { // tab or a hub board is a perfectly good destination. val unsurfaced = listOf( "/site/screenshots", "/site/five-on-friday", "/site/newsletter", - "/site/champs", "/site/guilds", "/site/governors", "/site/houses", + "/uo/champs", "/uo/guilds", "/uo/governors", "/uo/houses", ) assertTrue(unsurfaced.all { appRouteForWebPath(it) != null }) @@ -113,13 +132,13 @@ class NavPathsTest { @Test fun aTrailingSlashIsTolerated() { // A hand-edited settings row may carry one; the root is left alone. assertEquals(Routes.WIKI, appRouteForWebPath("/wiki/")) - assertEquals(Routes.SHARD, appRouteForWebPath(" /site/shard/ ")) + assertEquals(Routes.SHARD, appRouteForWebPath(" /uo/shard/ ")) assertEquals(Routes.HOME, appRouteForWebPath("/")) } // ── resolveWebPath: an added link may name any page on the site (§6.3) ── - @Test fun theNavTablesSixteenPathsResolveTheSameWay() { + @Test fun theNavTablesPathsResolveTheSameWay() { // An added link to a path the nav already knows must land where the nav row // does, or the same destination would behave differently depending on how // the admin reached it. @@ -132,9 +151,71 @@ class NavPathsTest { // Read off website/client/src/App.jsx. Note what is NOT here: the site has // no /site/news/ route — its one post-detail route is the newsletter's. assertEquals(Routes.wikiPage("smithing"), resolveWebPath("/wiki/smithing")) - assertEquals(Routes.atlasCreature("dragon"), resolveWebPath("/site/atlas/dragon")) - assertEquals(Routes.marketVendor("0x24C"), resolveWebPath("/site/market/vendors/0x24C")) + assertEquals(Routes.atlasCreature("dragon"), resolveWebPath("/uo/atlas/dragon")) + assertEquals(Routes.marketVendor("0x24C"), resolveWebPath("/uo/market/vendors/0x24C")) assertEquals(Routes.post("newsletter", "12"), resolveWebPath("/site/newsletter/12")) + // The module's detail routes are the module's; the old /site/ spelling is + // not a second address for them. + assertNull(resolveWebPath("/site/atlas/dragon")) + assertNull(resolveWebPath("/site/market/vendors/0x24C")) + } + + // ── Events (M13) ─────────────────────────────────────── + + @Test fun theEventPagesResolve() { + assertEquals(Routes.EVENTS, resolveWebPath("/site/events")) + assertEquals(Routes.event("the-yew-invasion"), resolveWebPath("/site/events/the-yew-invasion")) + assertEquals( + Routes.eventSeries("the-void"), + resolveWebPath("/site/events/series/the-void"), + ) + } + + @Test fun anEventUrlsRunIsCarriedThrough() { + // The one exception to "a query hands off", and the whole reason for it: + // this is the exact shape events Phase 14a's `eventUrl` writes into every + // announcement. Dropping the run would open next Friday's occurrence from a + // mail about last Friday's. + assertEquals( + Routes.event("the-yew-invasion", "3692"), + resolveWebPath("/site/events/the-yew-invasion?run=3692"), + ) + assertEquals("events/the-yew-invasion?run=3692", Routes.event("the-yew-invasion", "3692")) + } + + @Test fun theRunCarveOutIsOneKeyOnOnePath() { + // Narrow on purpose. Anything the app cannot honor natively hands off, so + // the browser gets the parameter the author actually wrote. + assertNull(resolveWebPath("/site/events/x?utm=mail")) + assertNull(resolveWebPath("/site/events/x?run=3&utm=mail")) + assertNull(resolveWebPath("/site/events/x?run=")) + assertNull(resolveWebPath("/site/events/x#results")) + assertNull(resolveWebPath("/site/events?seriesId=3")) + assertNull(resolveWebPath("/site/events/series/the-void?run=3")) + // And no OTHER path gained a query: the rule is one path's, not general. + assertNull(resolveWebPath("/wiki/smithing?x=1")) + } + + @Test fun anEventRouteWithNoRunCarriesNoEmptyArgument() { + // `events/x?run=` would reach the screen as a blank string and be forwarded + // to the server as one. + assertEquals("events/x", Routes.event("x")) + assertEquals("events/x", Routes.event("x", null)) + assertEquals("events/x", Routes.event("x", " ")) + } + + @Test fun theEventRoutePatternStripsToTheTopLevelRoute() { + // Same rule the News hub needs: `destination.route` is the pattern, and the + // drawer compares on the part before the query. + assertEquals(Routes.EVENTS, Routes.EVENT_ROUTE.substringBefore('?').substringBefore('/')) + assertEquals("events/{slug}", Routes.EVENT_ROUTE.substringBefore('?')) + } + + @Test fun myEventsHasNoDynamicSibling() { + // `events/mine` would race `events/{slug}` — both two segments — which is + // the static-versus-argument bug events Phase 13 shipped one tier along. + assertTrue(Routes.MY_EVENTS.startsWith("account/")) + assertNull(resolveWebPath("/account/events")) } @Test fun aTopLevelSlugIsACmsPage() { @@ -157,15 +238,18 @@ class NavPathsTest { @Test fun aPathTheAppHasNoScreenForHandsOff() { assertNull(resolveWebPath("/site/status")) - assertNull(resolveWebPath("/site/shard/activity")) + assertNull(resolveWebPath("/uo/shard/activity")) + assertNull(resolveWebPath("/uo/guilds/12")) + // `/uo` is a module's namespace, not a CMS page slug. + assertNull(resolveWebPath("/uo")) assertNull(resolveWebPath("/account/login")) assertNull(resolveWebPath("/admin/navigation")) assertNull(resolveWebPath("/site/atlas/dragon/extra")) } @Test fun aQueryOrFragmentHandsOff() { - // No app route takes either, so a native match would quietly drop what the - // admin wrote. The browser honors it exactly. + // No app route but the event page takes either, so a native match would + // quietly drop what the admin wrote. The browser honors it exactly. assertNull(resolveWebPath("/site/news?tag=patch")) assertNull(resolveWebPath("/donate#tiers")) assertEquals(Routes.NEWS, resolveWebPath("/site/news")) diff --git a/app/src/test/java/com/runicgateway/app/ui/navigation/NavTreeTest.kt b/app/src/test/java/com/runicgateway/app/ui/navigation/NavTreeTest.kt index 43745ef..b60465c 100644 --- a/app/src/test/java/com/runicgateway/app/ui/navigation/NavTreeTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/navigation/NavTreeTest.kt @@ -166,15 +166,15 @@ class NavTreeTest { val shape = tree(row).shape() - // Eight public rows are left at the top level (Wiki moved into the section), + // Nine public rows are left at the top level (Wiki moved into the section), // then the section, then the app's own rows. - assertEquals("section:lore", shape[8]) - assertEquals(Routes.CONTACT, shape[9]) + assertEquals("section:lore", shape[9]) + assertEquals(Routes.CONTACT, shape[10]) } @Test fun aSectionsOrderPlacesItAmongTheCodedRows() { // Sections sort on the same number line as everything else: the website's - // sixteen indices, then admin-created entities after them. + // seventeen indices, then admin-created entities after them. val row = stored( items = items("/wiki" to item(section = "lore")), sections = listOf(section("lore", order = 0)), @@ -252,7 +252,7 @@ class NavTreeTest { // deliberately, and grouping is no more an invitation to surface one than // relabelling was (§6.2). val row = stored( - items = items("/site/champs" to item(section = "lore", label = "Champs")), + items = items("/uo/champs" to item(section = "lore", label = "Champs")), sections = listOf(section("lore")), ) @@ -324,7 +324,9 @@ class NavTreeTest { val shape = tree(row).shape() assertEquals(listOf("link:a", "link:b"), shape.filter { it.startsWith("link:") }) - assertEquals(Routes.page("about"), shape[shape.indexOf("link:a") - 1]) + // Market, not About: the module's nine rows append after core's eight on the + // website's number line, so Market is the last coded row rather than About. + assertEquals(Routes.SHARD_MARKET, shape[shape.indexOf("link:a") - 1]) } @Test fun aLinksOrderPlacesItAmongTheCodedRows() { @@ -364,7 +366,7 @@ class NavTreeTest { // The case the rule exists for: a group whose every member is withheld by // the shard's visibility config must not draw as a header over nothing. val row = stored( - items = items("/site/market" to item(section = "lore")), + items = items("/uo/market" to item(section = "lore")), sections = listOf(section("lore")), ) @@ -380,7 +382,7 @@ class NavTreeTest { @Test fun aSectionKeepsTheMembersThisCallerMaySee() { val row = stored( items = items( - "/site/market" to item(section = "lore"), + "/uo/market" to item(section = "lore"), "/wiki" to item(section = "lore"), ), sections = listOf(section("lore")), @@ -400,7 +402,7 @@ class NavTreeTest { // section of its own — and still not shown, because the shard does not // publish the market and an admin does not outrank that. val row = stored( - items = items("/site/market" to item(label = "Bazaar", order = 0, hidden = false, section = "lore")), + items = items("/uo/market" to item(label = "Bazaar", order = 0, hidden = false, section = "lore")), sections = listOf(section("lore", order = 0)), ) @@ -418,7 +420,7 @@ class NavTreeTest { // Links carry no gate — the page behind one enforces its own access — so a // section holding one is never emptied by the caller's role. val row = stored( - items = items("/site/market" to item(section = "lore")), + items = items("/uo/market" to item(section = "lore")), sections = listOf(section("lore")), links = listOf(link(section = "lore")), ) diff --git a/app/src/test/java/com/runicgateway/app/ui/notifications/InboxViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/notifications/InboxViewModelTest.kt index 4353c12..2e6c461 100644 --- a/app/src/test/java/com/runicgateway/app/ui/notifications/InboxViewModelTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/notifications/InboxViewModelTest.kt @@ -193,4 +193,50 @@ class InboxViewModelTest { assertNull(vm.linkFor(item(1).copy(url = "javascript:alert(1)"))) assertNull(vm.linkFor(item(1).copy(url = "intent://evil#Intent;end"))) } + + // ── Opening an item in the app rather than a browser (M13) ───────── + + @Test fun anEventAnnouncementOpensNativelyAndKeepsItsRun() { + // The exact shape events Phase 14a writes into every announcement. Before + // M13 this opened a Custom Tab onto a page the app now renders itself. + val vm = viewModel() + val item = item(1).copy(url = "/site/events/the-yew-invasion?run=3692") + + assertEquals("events/the-yew-invasion?run=3692", vm.routeFor(item)) + } + + @Test fun anAbsoluteUrlOnThisHostOpensNativelyToo() { + // The url's shape is the server's to change; a link that reached the browser + // only because it arrived fully qualified would be a puzzle. + val vm = viewModel() + val item = item(1).copy(url = "https://shard.example/site/events/yew?run=7") + + assertEquals("events/yew?run=7", vm.routeFor(item)) + } + + @Test fun aLinkToAnotherHostIsNotOursToRoute() { + val vm = viewModel() + val item = item(1).copy(url = "https://elsewhere.example/site/events/yew") + + assertNull(vm.routeFor(item)) + // …and still opens, in the browser, exactly as it did before. + assertEquals("https://elsewhere.example/site/events/yew", vm.linkFor(item)) + } + + @Test fun everyOtherLinkStillHandsOff() { + // The change is additive: a path the app has no screen for behaves exactly + // as it did, and `linkFor` is still what opens it. + val vm = viewModel() + val forum = item(1).copy(url = "/guilds/the-silver-anvil/forum/403") + + assertNull(vm.routeFor(forum)) + assertEquals("https://shard.example/guilds/the-silver-anvil/forum/403", vm.linkFor(forum)) + } + + @Test fun anItemWithNoUrlHasNoRoute() { + val vm = viewModel() + assertNull(vm.routeFor(item(1))) + assertNull(vm.routeFor(item(1).copy(url = " "))) + assertNull(vm.routeFor(item(1).copy(url = "javascript:alert(1)"))) + } }