14 Commits

Author SHA1 Message Date
833e51de69 feat(shard): follow the visibility framework and read the Protocol 3.0 profile
All checks were successful
PR Checks / android-build (pull_request) Successful in 6m20s
M11 Part 1 (docs/android/PLAN.md §9). The website's Protocol 3.0 work made every
shard-derived surface admin-configurable — a feature can be switched off, or its
audience raised above the caller's rung — and the app knew nothing about it: it
gated shard navigation on the session role alone, so an admin change left the
drawer and the hub offering entries that 404/403 into a generic error where the
web client hides them.

The visibility rules:

  - GET /public/shard/features behind a singleton ShardFeaturesRepository,
    re-resolved on every session change (the answer is per-viewer) and dropped on
    a Settings → Server switch, which is the one case no session change covers.
  - MenuEntry gains `feature` beside `access`; the two gates are independent and
    both must pass. ShardBoard tags each hub tile the same way.
  - An unknown answer FAILS OPEN, matching lib/useShardFeatures.js: the server
    gates every call regardless, so a link that briefly 403s beats a drawer that
    flickers its entries in on every cold start. A pre-3.0 website 404s this
    route, which reads as "unknown" and behaves exactly as before.
  - toShardUiState() maps 404 AND 403 to a new ErrorKind.FEATURE_UNAVAILABLE:
    requireFeature answers 404 for a disabled feature (deliberately not
    disclosing it exists) and 403 for a viewer below its rung. Kept separate from
    toUiState() because both statuses mean something else off the shard surface —
    a deleted post, an ownership refusal. That state renders without a retry
    button; an admin controls it, so retrying cannot change the answer.

The read-model adds, from the same v3 series:

  - char.profile `points` — the Loyalty & Points block. maxPoints 0 means
    UNCAPPED and is the common case, so nothing divides by it and only a capped
    system gets a meter; nameString is usually null (systems name themselves with
    a cliloc) so humanising the PointsType key is the primary display path; rank
    is absent unless the shard opts in, and absent is not "unranked".
  - Cliloc-resolved names — equipment `clilocName` and titles `rewardResolved`,
    so items stop rendering as a layer. rewardResolved is positional: an entry
    the table could not resolve is null and is skipped WITHOUT shifting the
    `selected` index onto its neighbour.

ActorDto keeps acct/webId but documents them as admin-locked rather than
available. Points ride ungated on /player/shard/char/:serial — a character's own
standings are self-service and do not depend on the public leaderboards feature,
so the app mirrors that rather than re-gating it.

304 unit tests pass; lint clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-30 02:34:41 -05:00
4fe7a7e2a3 Merge pull request 'feat(auth): persist the trust token returned by the SSO exchange' (#29) from feat/sso-trusted-device into main
All checks were successful
sync-project-tree / sync (push) Successful in 8s
SonarQube / analysis (push) Successful in 5m41s
Release APK / release (push) Successful in 9m29s
Reviewed-on: #29
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-28 06:11:30 +00:00
b10dd444b3 feat(auth): persist the trust token returned by the SSO exchange
All checks were successful
PR Checks / android-build (pull_request) Successful in 7m55s
Pairs with website feat/sso-trusted-device, which makes "trust this device" work
for SSO sign-ins. Two things reach this device when the user ticks the box:

  1. The rg_trust COOKIE in the Custom Tab. Custom Tabs share the system
     browser's cookie jar, so that alone makes the next SSO sign-in skip the
     TOTP step — no app change needed for that half.
  2. A trustToken in the /auth/mobile/sso/exchange response, which is what this
     commit stores. That covers the app's NATIVE password login on the same
     device, which reads the token back out of TrustTokenStore and replays it as
     X-Trust-Token.

MobileTokenResponse already carried trustToken (the native login path has always
persisted it) — SsoAuthManager simply dropped it on the floor. Save it scoped to
the signed-in username, exactly like AuthRepository.login does, so it is never
replayed for a different account on a shared device; and save it before
onSignedIn so a process death mid-callback can't lose it.

Tests: 2 new cases in SsoAuthManagerTest (token persisted + scoped to its owner;
absent token leaves the store untouched), with an in-memory FakeTrustTokenStore
matching the file's existing fake style. Full unit suite green: 266 tests.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 01:01:33 -05:00
f3da6ea618 Merge pull request 'ci(docs): auto-sync PROJECT_TREE.md to the docs repo on push to main' (#28) from chore/sync-project-tree-ci into main
All checks were successful
sync-project-tree / sync (push) Successful in 13s
SonarQube / analysis (push) Successful in 3m16s
Release APK / release (push) Successful in 9m9s
Reviewed-on: #28
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 21:24:49 +00:00
ae170670d9 ci(docs): auto-sync PROJECT_TREE.md to the docs repo on push to main
All checks were successful
PR Checks / android-build (pull_request) Successful in 2m23s
Add a sync-project-tree workflow that regenerates this repo's tracked-file
tree and opens (or force-updates) a PR against RunicGateway/docs whenever the
layout on main changes. Never writes to the docs repo's main directly. Reuses
the existing REGISTRY_USER / REGISTRY_TOKEN secrets. Tree rendering lives in
.gitea/scripts/gen_tree.py (deterministic, dirs-first ordering).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 16:22:06 -05:00
7fc497a1a4 Merge pull request 'test(coverage): raise unit coverage past the 50% gate (phases 0-2)' (#27) from test/coverage-phase-0-1-2 into main
Some checks failed
SonarQube / analysis (push) Has been cancelled
Reviewed-on: #27
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 21:11:54 +00:00
4e3bb914ff test(coverage): raise unit coverage past the 50% gate (phases 0-2)
All checks were successful
PR Checks / android-build (pull_request) Successful in 7m19s
Executes COVERAGE_PLAN.md phases 0-2 to clear the SonarQube new-code coverage
gate (was 16.4%, threshold 50%). Estimated new-code coverage after this change
is ~57%. 109 new tests across 19 files; full suite is 264 tests, all green.

Phase 0 — coverage exclusions (sonar-project.properties): drop code a JVM unit
test can't execute from the *coverage* denominator (still analysed for
bugs/smells) — pure-@Composable UI the `*Screen.kt` glob missed
(ui/components/**, BlockRenderer, ShardComponents), Android-framework glue
(push services, Keystore-backed Encrypted* stores, Hilt di/**).

Phase 1 — DTO serialization tests: AdminDto, PublicDto, WikiDto, PostDto/PageDto/
ContactDto, SsoDto, the shard board DTOs and player game-data DTOs, and the
mobile-auth request bodies — decode + encode + computed helpers
(isPublished/isMaintenance/ActorDto.label/ShardStatusDto.isOnline).

Phase 2 — ViewModel tests: a MainDispatcherRule harness + hand-written API fakes
(FakePublicApi/FakeAdminApi/FakePlayerShardApi/FakeShardStream) drive real
repositories into the ViewModels. Covers the admin (dashboard/content/moderation/
support), content (news/post/page/wiki/home/contact), player (characters/
vendors/character/my-houses) and shard-board (champs/guilds/governors/houses/
hub) ViewModels — load success/error, form validation, role/status-aware
feedback, and live-frame merging.

To make the shard boards testable, extract a small `ShardStream` interface from
`ShardStreamClient` (bound in NetworkModule) so `ShardRepository` depends on the
capability, not the OkHttp client — lets a fake stream replace the perpetual SSE
reconnect loop in tests. No production behaviour change.

Phases 3 (repositories) and 4 (core net/auth top-up) are follow-ups; the
deep-dependency auth family (Login/Account/TrustedDevices ViewModels,
AuthRepository) lands with them. See docs/android/COVERAGE_PLAN.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-22 16:04:02 -05:00
efe14d3828 Merge pull request 'chore(sonar): wire JaCoCo coverage and clear actionable smells' (#26) from chore/sonar-coverage-and-cleanup into main
All checks were successful
SonarQube / analysis (push) Successful in 9m15s
Reviewed-on: #26
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 19:11:01 +00:00
43215b49a0 chore(sonar): wire JaCoCo coverage and clear actionable smells
All checks were successful
PR Checks / android-build (pull_request) Successful in 10m44s
Fix the SonarQube coverage gate (0% on new code) — a reporting gap, not a
testing gap: the JVM unit suite already exists but the source-only scan
never received a coverage report.

- app/build.gradle.kts: apply jacoco, enable debug unit-test coverage, add a
  jacocoTestReport task (excludes generated/Hilt/Compose-singleton classes)
- sonar-project.properties: consume the JaCoCo XML; exclude pure-@Composable
  UI from coverage (JVM unit tests can't execute composable bodies)
- .gitea/workflows/sonarqube.yml: run JDK 17 + Android SDK +
  `testDebugUnitTest jacocoTestReport` before the scan

Also clear the three actionable code smells: remove an unused import
(AdminContentScreen), remove an unused parameter (AdminSupportScreen.
RespondDialog), and decompose LoginViewModel.submit() (cognitive complexity
20 -> under 15). The remaining 12 smells (snake_case DTO fields that mirror
the JSON wire contract; Compose/nav complexity) are marked Won't Fix in
SonarQube with rationale.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-22 13:58:52 -05:00
a6446b04d8 Merge pull request 'fix(notifications): always serialize streams so clearing the last subscription saves' (#25) from fix/notifications-empty-subscriptions into main
All checks were successful
SonarQube / analysis (push) Successful in 1m24s
Release APK / release (push) Successful in 16m55s
Reviewed-on: #25
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 16:48:06 +00:00
9c52a3dafa fix(notifications): always serialize streams so clearing the last subscription saves
All checks were successful
PR Checks / android-build (pull_request) Successful in 10m34s
Turning off the final notification subscription (going from one opted-in
stream to zero) failed with "could not save" and the toggle stuck on. The
backend's PUT /auth/me/notifications/subscriptions validator requires the
`streams` field (body('streams').isArray()), but kotlinx.serialization omits a
property equal to its default (encodeDefaults=false). NotificationSubscriptionsDto
defaulted `streams` to emptyList(), so an empty set serialized to `{}` and the
backend rejected it 400 "Validation failed". Any non-empty set included the
field, so only the last toggle-off broke — regardless of which stream it was.

Remove the default from NotificationSubscriptionsDto.streams so kotlinx always
emits the field; an empty set now sends `{"streams":[]}` (200). The one call
site already passes streams explicitly and the server always returns the field,
so response decoding is unaffected. Add a regression test asserting the empty
DTO serializes to `{"streams":[]}` under the production Json config.

Verified on-device (AVD) against the live site and via the live API
(`{}` -> 400, `{"streams":[]}` -> 200).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 11:36:32 -05:00
f0a3b6c03e Merge pull request 'fix(nav): show the player game-data groups to staff' (#24) from fix/staff-player-menu into main
All checks were successful
SonarQube / analysis (push) Successful in 56s
Release APK / release (push) Successful in 9m55s
Reviewed-on: #24
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 08:32:57 +00:00
3aeb295342 fix(nav): show the player game-data groups to staff
All checks were successful
PR Checks / android-build (pull_request) Successful in 6m8s
Staff are a superset of players (all player abilities plus their staff
tools), and the backend's player self-service surface is role-agnostic,
but MenuAccess.PLAYER gated "My characters/vendors/houses" on
role == player — so a signed-in admin/editor/moderator saw neither the
menu items nor, via the greyed personal streams, their own notification
options, even with linked characters.

Gate MenuAccess.PLAYER on isPlayer OR isStaff. The notifications screen
needs no change: once the backend returns the caller's linked accounts
(paired with RunicGateway/website), hasLinkedAccount resolves and the
personal streams enable themselves.

Tests: MenuAccessTest now asserts every staff role sees the player
game-data groups and a PLAYER entry, and an unrecognized role / anon
still cannot. Full unit suite passes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 02:18:33 -05:00
03d4ef6fad Merge pull request 'feat(auth): trusted devices & recovery codes on the mobile client' (#23) from feature/trusted-devices-mfa into main
All checks were successful
SonarQube / analysis (push) Successful in 1m14s
Release APK / release (push) Successful in 9m37s
Reviewed-on: #23
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 06:26:54 +00:00
66 changed files with 3196 additions and 120 deletions

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""Render an ASCII tree of tracked files, read from stdin (one path per line).
Used by the `sync-project-tree` workflow to regenerate this repo's PROJECT_TREE.md
snapshot in the RunicGateway/docs repo. Feed it `git ls-files`:
git ls-files | python3 .gitea/scripts/gen_tree.py <root-label>
Deterministic ordering: directories before files, each group sorted
case-insensitively with the raw name as a tiebreak. Output uses the classic
`tree(1)` box-drawing style so the result is stable across runs and platforms.
"""
import sys
def build(paths):
root = {}
for p in paths:
p = p.strip().replace("\\", "/")
if not p:
continue
node = root
for part in p.split("/"):
node = node.setdefault(part, {})
return root
def render(node, prefix, lines):
entries = list(node.items())
# directories (non-empty children dict) before files, then case-insensitive name
entries.sort(key=lambda kv: (0 if kv[1] else 1, kv[0].lower(), kv[0]))
for i, (name, child) in enumerate(entries):
last = i == len(entries) - 1
branch = "└── " if last else "├── "
suffix = "/" if child else ""
lines.append(f"{prefix}{branch}{name}{suffix}")
if child:
render(child, prefix + (" " if last else ""), lines)
def main():
try:
sys.stdout.reconfigure(encoding="utf-8", newline="\n")
except AttributeError:
pass
root_label = sys.argv[1] if len(sys.argv) > 1 else "."
tree = build(sys.stdin.read().splitlines())
lines = [f"{root_label}/"]
render(tree, "", lines)
sys.stdout.write("\n".join(lines) + "\n")
if __name__ == "__main__":
main()

View File

@@ -18,11 +18,12 @@
# SonarQube Quality Gate, so a failing gate does not fail this job — check the
# dashboard when you want to.
#
# Scope: this analyses the Kotlin source directly (the Sonar scanner reads
# sonar-project.properties). It does NOT run a Gradle build, so no Android SDK /
# JDK install is needed — the Kotlin analyzer is source-based. See the "Optional
# enrichment" note in sonar-project.properties for wiring in Android Lint /
# coverage reports later.
# Scope: the Sonar scanner reads sonar-project.properties and analyses the Kotlin
# source directly. Before the scan we run the JVM unit tests + JaCoCo so SonarQube
# receives real coverage (sonar.coverage.jacoco.xmlReportPaths) — otherwise it
# reports 0% and the coverage gate fails despite the test suite existing. That
# Gradle step needs JDK 17 + the Android SDK (same toolchain as pr-checks.yml);
# the runner container is bare, so base tools are apt-installed first.
name: SonarQube
@@ -40,6 +41,16 @@ jobs:
analysis:
runs-on: ubuntu-latest
steps:
# The bare runner container lacks git/curl/unzip (checkout + sdkmanager need
# them) and we install JDK 17 from the Ubuntu archive rather than
# actions/setup-java (this runner can't reach api.adoptium.net). Mirrors
# pr-checks.yml — see its header note.
- name: Install base tools + JDK 17
run: |
apt-get update
apt-get install -y git curl unzip openjdk-17-jdk-headless
echo "JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64" >> "$GITHUB_ENV"
- name: Check out (full history for accurate new-code + blame)
uses: actions/checkout@v4
with:
@@ -47,6 +58,31 @@ jobs:
# compute "new code". A shallow clone degrades both.
fetch-depth: 0
- name: Set up Android SDK
uses: android-actions/setup-android@v3
- name: Install Android SDK packages
run: |
set +o pipefail
yes | sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle.kts', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: |
gradle-${{ runner.os }}-
# Produce the JaCoCo XML the scan reports as coverage. Scoped to the debug
# variant (matches enableUnitTestCoverage) to keep peak memory down.
- name: Unit tests + JaCoCo coverage
run: |
chmod +x ./gradlew
./gradlew --no-daemon testDebugUnitTest jacocoTestReport
- name: Run SonarQube scan
uses: sonarsource/sonarqube-scan-action@v4
env:

View File

@@ -0,0 +1,111 @@
name: sync-project-tree
# Keeps this repo's file-layout snapshot (docs/android/PROJECT_TREE.md in the
# RunicGateway/docs repo) current. On every push to `main` it regenerates the
# tree from tracked files and, if it changed, opens (or force-updates) a pull
# request against the docs repo. It never writes to the docs repo's `main`
# directly. Auth reuses the same REGISTRY_USER / REGISTRY_TOKEN secrets the
# other workflows use (the token needs repo read/write on RunicGateway/docs).
on:
push:
branches: [main]
workflow_dispatch: {}
concurrency:
group: sync-project-tree
cancel-in-progress: true
env:
GITEA_HOST: gitea.whitlocktech.com
DOCS_REPO: RunicGateway/docs
SELF_REPO: RunicGateway/Android-app
DOCS_PATH: android/PROJECT_TREE.md
TREE_TITLE: Android App
ROOT_LABEL: android-app
PR_BRANCH: chore/sync-android-tree
jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Check out this repo
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Ensure python3 is available
run: |
set -euo pipefail
command -v python3 >/dev/null 2>&1 || { sudo apt-get update -qq && sudo apt-get install -y -qq python3; }
- name: Render PROJECT_TREE.md from tracked files
run: |
set -euo pipefail
mkdir -p _sync
{
printf '# %s — Project Tree\n\n' "${TREE_TITLE}"
printf '> **Auto-generated.** This file is maintained by the `sync-project-tree` CI workflow in\n'
printf '> the [`%s`](https://%s/%s) repository, which\n' "${SELF_REPO}" "${GITEA_HOST}" "${SELF_REPO}"
printf '> opens a pull request here whenever the tracked file layout on `main` changes. Do not edit\n'
printf '> by hand — changes will be overwritten by the next sync.\n\n'
printf 'A snapshot of the tracked files in the repository (build output, dependencies, and other\n'
printf 'git-ignored paths are excluded).\n\n'
printf '```text\n'
git ls-files | python3 .gitea/scripts/gen_tree.py "${ROOT_LABEL}"
printf '```\n'
} > _sync/PROJECT_TREE.md
echo "----- generated ${DOCS_PATH} -----"
cat _sync/PROJECT_TREE.md
- name: Open or update the docs PR if the tree changed
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
# Secrets can carry a trailing CR/LF depending on how they were pasted;
# strip line breaks before they land in a URL or Authorization header.
CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')"
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
API="https://${GITEA_HOST}/api/v1/repos/${DOCS_REPO}"
REMOTE="https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${DOCS_REPO}.git"
git clone --depth 1 "${REMOTE}" docs_repo
cd docs_repo
git config user.name "runic-docs-bot"
git config user.email "ci@whitlocktech.com"
mkdir -p "$(dirname "${DOCS_PATH}")"
cp ../_sync/PROJECT_TREE.md "${DOCS_PATH}"
git add "${DOCS_PATH}"
if git diff --cached --quiet; then
echo "PROJECT_TREE.md already up to date — nothing to sync."
exit 0
fi
SHORT_SHA="$(echo "${GITHUB_SHA:-local}" | cut -c1-7)"
git checkout -B "${PR_BRANCH}"
git commit -m "docs(tree): sync ${DOCS_PATH} from ${SELF_REPO}@${SHORT_SHA} [skip ci]"
git push --force "${REMOTE}" "HEAD:${PR_BRANCH}"
# Open a PR only if one isn't already open for this branch (a force-push
# to an existing open PR's head updates it in place).
OPEN="$(curl -sSf -H "Authorization: token ${CI_TOKEN}" \
"${API}/pulls?state=open&limit=50" \
| jq --arg b "${PR_BRANCH}" '[.[] | select(.head.ref == $b)] | length')"
if [ "${OPEN}" = "0" ]; then
curl -sSf -X POST "${API}/pulls" \
-H "Authorization: token ${CI_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg head "${PR_BRANCH}" \
--arg base "main" \
--arg title "docs(tree): sync ${DOCS_PATH}" \
--arg body "Automated project-tree sync from [\`${SELF_REPO}\`](https://${GITEA_HOST}/${SELF_REPO}), regenerated from tracked files on \`main\`. Merge once the layout looks right; the workflow will keep this branch current until then." \
'{head: $head, base: $base, title: $title, body: $body}')" \
>/dev/null
echo "Opened a new docs PR for ${PR_BRANCH}."
else
echo "Existing open docs PR for ${PR_BRANCH} was updated via force-push."
fi

View File

@@ -10,6 +10,11 @@ plugins {
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
jacoco
}
jacoco {
toolVersion = "0.8.12"
}
// Release signing material (PLAN.md §12) is never committed. It is read from, in
@@ -78,6 +83,11 @@ android {
}
buildTypes {
debug {
// Produce a JaCoCo .exec from JVM unit tests so SonarQube receives real
// coverage (§12.1). Debug-only: the scan analyses the debug variant.
enableUnitTestCoverage = true
}
release {
// R8 full-mode minify + resource shrink (§7: no offline cache, so a lean
// release APK). Keep rules live in proguard-rules.pro.
@@ -170,3 +180,35 @@ dependencies {
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
}
// JaCoCo XML coverage from the JVM unit tests, consumed by SonarQube (§12.1). Generated,
// DI (Hilt), and Compose-scaffold classes are excluded so they don't dilute the number;
// pure-@Composable UI is excluded on the Sonar side (sonar.coverage.exclusions) because
// JVM unit tests can't execute composable bodies without Robolectric.
tasks.register<JacocoReport>("jacocoTestReport") {
dependsOn("testDebugUnitTest")
group = "verification"
description = "Generates JaCoCo XML/HTML coverage for the debug unit tests."
reports {
xml.required.set(true)
html.required.set(true)
}
val coverageExcludes = listOf(
"**/R.class", "**/R$*.class", "**/BuildConfig.*", "**/Manifest*.*",
"**/*_Hilt*.*", "**/Hilt_*.*", "**/*_Factory*.*", "**/*_MembersInjector*.*",
"**/*_Impl*.*", "**/di/**", "**/*Module.*", "**/*Module$*.*",
"**/*ComposableSingletons*.*", "**/ComposableSingletons$*.*",
)
val buildDirFile = layout.buildDirectory.get().asFile
classDirectories.setFrom(
fileTree("$buildDirFile/tmp/kotlin-classes/debug") { exclude(coverageExcludes) },
)
sourceDirectories.setFrom(files("src/main/java", "src/main/kotlin"))
executionData.setFrom(
fileTree(buildDirFile) {
include("outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec")
},
)
}

View File

@@ -5,6 +5,7 @@ package com.runicgateway.app.core.auth.sso
import com.runicgateway.app.BuildConfig
import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.core.auth.TrustTokenStore
import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.data.api.SsoApi
import com.runicgateway.app.data.api.dto.MobileSsoExchangeRequest
@@ -49,6 +50,7 @@ class SsoAuthManager @Inject constructor(
private val sessionManager: SessionManager,
private val baseUrlHolder: BaseUrlHolder,
private val pendingStore: PendingSsoStore,
private val trustTokenStore: TrustTokenStore,
) {
/** Why an SSO attempt ended, for a friendly inline message on the login screen. */
@@ -193,6 +195,14 @@ class SsoAuthManager @Inject constructor(
_outcome.value = Outcome.Failed(Failure.SERVER)
return
}
// The user ticked "trust this device" on the TOTP form inside the Custom
// Tab. That tab's cookie already covers future SSO sign-ins; persisting
// the token the exchange handed back is what lets a native PASSWORD login
// on this device skip the code too (TRUSTED_DEVICES_MFA.md). Scoped to the
// username exactly like the password path, so it is never replayed for a
// different account on a shared device. Saved BEFORE onSignedIn so a
// process death mid-callback can't lose it.
body.trustToken?.let { trustTokenStore.save(body.user.username, it) }
sessionManager.onSignedIn(body.accessToken, body.refreshToken, body.user)
_outcome.value = Outcome.Success
return

View File

@@ -0,0 +1,16 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.net
import kotlinx.coroutines.flow.Flow
/**
* The live shard SSE feed as a cold flow of lifecycle + frame events (PLAN.md §6.2,
* §7). Extracted as an interface so consumers (e.g. [com.runicgateway.app.data.repository.ShardRepository])
* depend on the capability, not the OkHttp-backed [ShardStreamClient] — the boards
* can then be unit-tested against a fake stream instead of a real network connection.
*/
interface ShardStream {
fun events(): Flow<ShardStreamEvent>
}

View File

@@ -40,7 +40,7 @@ class ShardStreamClient @Inject constructor(
baseClient: OkHttpClient,
private val baseUrlHolder: BaseUrlHolder,
private val json: Json,
) {
) : ShardStream {
// SSE is a long-lived, mostly-idle connection (keepalive comments every ~25s),
// so the read timeout must be disabled or the idle stream would be killed.
private val sseClient: OkHttpClient = baseClient.newBuilder()
@@ -56,7 +56,7 @@ class ShardStreamClient @Inject constructor(
* drive a live/offline indicator; [ShardStreamEvent.Frame] carries a decoded
* `{ kind, … }` payload the boards merge in place.
*/
fun events(): Flow<ShardStreamEvent> = channelFlow {
override fun events(): Flow<ShardStreamEvent> = channelFlow {
var backoffMs = INITIAL_BACKOFF_MS
while (isActive) {
val url = baseUrlHolder.current?.resolve(STREAM_PATH)

View File

@@ -17,6 +17,7 @@ import com.runicgateway.app.data.api.dto.PageDto
import com.runicgateway.app.data.api.dto.PostDto
import com.runicgateway.app.data.api.dto.PresenceDto
import com.runicgateway.app.data.api.dto.SettingsDto
import com.runicgateway.app.data.api.dto.ShardFeaturesDto
import com.runicgateway.app.data.api.dto.ShardStatusDto
import com.runicgateway.app.data.api.dto.StatusDto
import com.runicgateway.app.data.api.dto.WikiCategoryDto
@@ -93,6 +94,14 @@ interface PublicApi {
suspend fun postContact(@Body body: ContactRequest): ContactResponse
// ── Public shard widgets (§6.2) ──────────────────────────────────────
/**
* Which shard features this caller may reach, so the menu hides entries instead
* of rendering links that 404/403 (§5, M11). Answered per-viewer: an anonymous
* call and a signed-in one can differ.
*/
@GET("api/v1/public/shard/features")
suspend fun getShardFeatures(): ShardFeaturesDto
@GET("api/v1/public/shard/status")
suspend fun getShardStatus(): ShardStatusDto

View File

@@ -60,8 +60,15 @@ data class NotificationStreamsDto(
* `GET/PUT /auth/me/notifications/subscriptions` — the user's opted-in stream ids.
* PUT replaces the full set; unknown ids are dropped server-side and the stored set
* echoed back.
*
* [streams] intentionally has NO default: this DTO doubles as the PUT body, and the
* backend validator requires the `streams` field (`body('streams').isArray()`).
* kotlinx omits a property equal to its default (encodeDefaults=false), so a default
* of `emptyList()` would drop the field when the user clears their LAST subscription,
* sending `{}` → 400 "Validation failed" (the "can't turn off the last one" bug). With
* no default the empty list always serializes as `{"streams":[]}`. Do not re-add a default.
*/
@Serializable
data class NotificationSubscriptionsDto(
val streams: List<String> = emptyList(),
val streams: List<String>,
)

View File

@@ -83,8 +83,47 @@ data class CharProfileDto(
val titles: TitlesDto? = null,
val guild: GuildRefDto? = null,
val governorOf: List<String> = emptyList(),
/**
* Loyalty / points standings (Protocol 3.0 §7.3). Empty for a character that has
* earned nothing anywhere — the shard omits systems the character has no entry in
* — and empty on a shard whose plugin predates 3.0.
*
* Served **ungated**: a character's own standings are self-service data on
* `/player/shard/char/:serial` and do not depend on the public `leaderboards`
* feature being visible. Don't re-gate them app-side.
*/
val points: List<CharPointsDto> = emptyList(),
)
/**
* One point system a character holds a score in (Protocol 3.0 §7.3).
*
* Three shapes here are counter-intuitive, and all three are what a REAL shard sends
* (`docs/link/v3.md` §7.5 — a fake shard emits whatever the spec says it should):
*
* - **[maxPoints] `0` means UNCAPPED, and is the common case**, not an edge case.
* ServUO's idiom for an uncapped system is `double.MaxValue`, which the plugin
* normalises to `0` because the C# cast is unchecked and yielded `long.MinValue`.
* Nothing may divide by it, and a full-width progress bar for an uncapped score
* would imply a completion that doesn't exist.
* - **[nameString] is usually `null`.** Most systems name themselves with a cliloc
* rather than a literal, so humanising [system] (`QueensLoyalty` → "Queens
* Loyalty") is the PRIMARY display path, not a defensive fallback.
* - **[rank] is absent unless the shard runs `Bridge.cfg PointsProfileRank=true`.**
* Absent and "unranked" are different answers, so it renders only when sent.
*/
@Serializable
data class CharPointsDto(
val system: String? = null,
val nameString: String? = null,
val points: Long? = null,
val maxPoints: Long? = null,
val rank: Int? = null,
) {
/** The cap, or null when the system is uncapped (see [maxPoints]). */
val cap: Long? get() = maxPoints?.takeIf { it > 0 }
}
@Serializable
data class CharStatsDto(
val str: Int? = null,
@@ -134,17 +173,45 @@ data class EquipmentDto(
val itemId: Int? = null,
val hue: Int? = null,
val mods: JsonObject? = null,
)
/**
* A player-given name — set for the minority of items someone has renamed, null
* for almost everything else. The shard sends the plain `Item.Name` field; it
* never builds a display name (that call is a packet builder, not a field read).
*/
val name: String? = null,
/**
* The item's type name, resolved from its cliloc id **by the website** against
* its own table (`docs/website/CLILOCS.md`). Null on a shard that has no cliloc
* table configured, which is fully supported — the sheet then falls back to the
* layer, exactly as it did before the table existed.
*/
val clilocName: String? = null,
) {
/**
* What to call this item.
*
* A player-given [name] outranks the resolved type name — "Bob's lucky axe" must
* not be relabelled "hatchet" — and the server applies the same precedence, so
* this only re-states it for an item that arrived with both.
*/
val label: String? get() = name ?: clilocName ?: layer
}
/**
* Display titles (Protocol 2.0). `selected` is the index into `reward` currently
* shown (-1 if none); `reward` entries may be a cliloc number-as-string or a
* literal — numeric ones are skipped without a cliloc table (as the website does).
* Display titles (Protocol 2.0). `selected` is the index into [reward] currently
* shown (-1 if none); [reward] entries may be a cliloc number-as-string or a literal.
*
* [rewardResolved] is the website's **parallel array** with the numeric entries turned
* into words against its cliloc table — same length and order as [reward], with a null
* where an id resolved to nothing. It is absent entirely when no entry was numeric or
* the shard has no cliloc table, so read it positionally and tolerate it being short.
* See `displayTitles` in the character sheet.
*/
@Serializable
data class TitlesDto(
val selected: Int? = null,
val reward: List<String> = emptyList(),
val rewardResolved: List<String?> = emptyList(),
val fameKarma: String? = null,
val skill: String? = null,
)

View File

@@ -15,11 +15,36 @@ import kotlinx.serialization.json.JsonObject
* `*.update` frames on `/public/shard/stream` decode into these same DTOs.
*/
/**
* Which shard surfaces this caller may reach (`GET /public/shard/features`), plus
* the audience rung they resolved to.
*
* Every shard-derived feature is admin-configurable — it can be switched off or
* raised to a higher rung — so the menu cannot be a static list (PLAN.md §5, M11).
* [level] is the SERVER's answer on the `anonymous → logged_in → player → staff →
* admin` ladder and is authoritative: don't re-derive a rung from the session role,
* since `player` means *a linked game account* and staff always satisfy it.
*
* The response reports only what the caller can see, so the list itself never
* discloses a feature they're gated out of.
*/
@Serializable
data class ShardFeaturesDto(
val level: String? = null,
val features: List<String> = emptyList(),
)
/**
* A game actor (player/leader/governor) as embedded in board payloads. Per the wire
* spec (`docs/link/INTEGRATION.md` §1), in-game [serial]s are opaque hex-string keys
* (e.g. `"0x1A2B"`), never numbers, and [webId] is the linked site-user id as a
* string (e.g. `"9931"`) — both are decoded as strings, not parsed.
* (e.g. `"0x1A2B"`), never numbers.
*
* [acct] and [webId] are **locked to the admin rung** by the visibility framework
* (`docs/link/v3.md` §3.4 rule 1) — a game account name and a linked site-user id are
* not in-game-visible the way a character name is, so they are stripped from every
* response below `admin` and no admin setting can loosen that. The fields stay
* declared because an admin session does receive them; nothing below one should
* expect a value.
*/
@Serializable
data class ActorDto(

View File

@@ -28,6 +28,7 @@ class ConnectionRepository @Inject constructor(
private val baseUrlHolder: BaseUrlHolder,
private val sessionManager: SessionManager,
private val trustTokenStore: TrustTokenStore,
private val shardFeaturesRepository: ShardFeaturesRepository,
private val pushManager: com.runicgateway.app.core.push.PushManager,
private val config: com.runicgateway.app.core.AppConfig,
) {
@@ -111,6 +112,10 @@ class ConnectionRepository @Inject constructor(
// The trust token is bound to the old host — drop it so we don't replay it
// against a different shard (it survives a plain logout, but not a host switch).
trustTokenStore.clear()
// Shard visibility is the OLD host's answer. Sign-out alone would not clear it:
// 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()
prefs.clear()
baseUrlHolder.set(null)
}

View File

@@ -0,0 +1,111 @@
/*
* 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
/**
* Which shard surfaces the current viewer may reach, from
* `GET /public/shard/features` (PLAN.md §5, §9 M11).
*
* Every shard-derived feature is admin-configurable — it can be switched off, or its
* audience raised above the caller's rung — so shard navigation can no longer be a
* static list gated on the session role alone. [level] is the server's own answer on
* the `anonymous → logged_in → player → staff → admin` ladder; the app does not
* re-derive it.
*
* **This is presentation only.** The gate is server-side: a disabled feature `404`s
* and an out-of-rung one `403`s whether or not the entry was rendered. That is why an
* unknown answer deliberately **fails open** — see [ShardFeatures] and [canSee].
*/
@Singleton
class ShardFeaturesRepository @Inject constructor(
private val api: PublicApi,
) {
private val _features = MutableStateFlow<ShardFeatures?>(null)
/** The current answer, or `null` while it is unknown (in flight, or the lookup failed). */
val features: StateFlow<ShardFeatures?> = _features.asStateFlow()
// Serializes concurrent refreshes: the shell refreshes on every session change,
// and two overlapping loads would race to publish.
private val mutex = Mutex()
/**
* Re-resolve the visible set. Called on every session change (sign-in, sign-out,
* a role revalidation that actually changed the user), because the answer is
* per-viewer.
*
* A failed lookup clears the cache rather than keeping a stale one: falling back
* to "show everything" is the safe direction here, since the server still gates
* every call.
*/
suspend fun refresh() = mutex.withLock {
_features.value = when (val result = safeApiCall { api.getShardFeatures() }) {
is ApiResult.Ok -> ShardFeatures(
level = result.data.level,
visible = result.data.features.toSet(),
)
// Includes the 404 an older, pre-Protocol-3.0 website returns for this
// route — that site has no visibility framework, so "unknown" is exactly
// the right answer and the menu behaves as it did before M11.
else -> null
}
}
/**
* Drop the cached answer. Called on a Settings → Server switch: the features
* belong to the host that reported them, and a switch between two signed-out
* hosts changes no session, so nothing else would invalidate them.
*/
fun invalidate() {
_features.value = null
}
}
/**
* The resolved visibility answer for one viewer: the rung the server placed them on
* and the shard features they may reach.
*/
data class ShardFeatures(
val level: String?,
val visible: Set<String>,
)
/**
* True when [feature] may be shown — **or when the answer isn't known yet**.
*
* The fail-open default is deliberate and matches the web client (`lib/useShardFeatures.js`):
* the server gates every call regardless, so the cost of guessing wrong is a link that
* briefly `403`s, while the cost of guessing the other way is a navigation drawer that
* flickers its entries in on every cold start.
*/
fun canSee(features: ShardFeatures?, feature: String): Boolean =
features == null || feature in features.visible
/** Feature names as the website's `shardVisibility.js` `FEATURES` map spells them. */
object ShardFeature {
const val STATUS = "status"
const val ACTIVITY = "activity"
const val CHAMPS = "champs"
const val GUILDS = "guilds"
const val GOVERNORS = "governors"
const val HOUSES = "houses"
const val PRESENCE = "presence"
// Added by Protocol 3.0.
const val RULESET = "ruleset"
const val ATLAS = "atlas"
const val LEADERBOARDS = "leaderboards"
const val MARKET = "market"
}

View File

@@ -3,7 +3,7 @@
*/
package com.runicgateway.app.data.repository
import com.runicgateway.app.core.net.ShardStreamClient
import com.runicgateway.app.core.net.ShardStream
import com.runicgateway.app.core.net.ShardStreamEvent
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.safeApiCall
@@ -35,7 +35,7 @@ import javax.inject.Singleton
@Singleton
class ShardRepository @Inject constructor(
private val api: PublicApi,
private val stream: ShardStreamClient,
private val stream: ShardStream,
private val json: Json,
) {
// ── Snapshots ────────────────────────────────────────────────────────

View File

@@ -9,6 +9,8 @@ import com.runicgateway.app.BuildConfig
import com.runicgateway.app.core.net.AuthInterceptor
import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.core.net.HostSelectionInterceptor
import com.runicgateway.app.core.net.ShardStream
import com.runicgateway.app.core.net.ShardStreamClient
import com.runicgateway.app.core.net.TokenAuthenticator
import com.runicgateway.app.core.net.UserAgentInterceptor
import com.runicgateway.app.data.api.AuthApi
@@ -94,6 +96,12 @@ object NetworkModule {
@Singleton
fun providePublicApi(retrofit: Retrofit): PublicApi = retrofit.create(PublicApi::class.java)
/** Expose the live SSE feed as the [ShardStream] capability so repositories depend
* on the interface (unit-testable against a fake), not the OkHttp-backed client. */
@Provides
@Singleton
fun provideShardStream(client: ShardStreamClient): ShardStream = client
@Provides
@Singleton
fun provideAuthApi(retrofit: Retrofit): AuthApi = retrofit.create(AuthApi::class.java)

View File

@@ -112,6 +112,8 @@ fun RunicApp(
val scope = rememberCoroutineScope()
val session by sessionViewModel.session.collectAsStateWithLifecycle()
// What this shard publishes, independently of who the caller is (§5, M11).
val shardFeatures by sessionViewModel.shardFeatures.collectAsStateWithLifecycle()
// Re-validate the cached role each time the app returns to the foreground (§4.3).
LifecycleResumeEffect(Unit) {
@@ -132,7 +134,7 @@ fun RunicApp(
val backStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = backStackEntry?.destination?.route
val isTopLevel = currentRoute in TOP_LEVEL_ROUTES
val entries = visibleEntries(APP_MENU, session)
val entries = visibleEntries(APP_MENU, session, shardFeatures)
ModalNavigationDrawer(
drawerState = drawerState,

View File

@@ -34,6 +34,13 @@ enum class ErrorKind {
/** Shard/sidecar down (503) — shard reads only; render as offline (§6.3). */
SHARD_OFFLINE,
/**
* This shard doesn't publish the surface, or doesn't publish it to this viewer
* (M11). Distinct from [NOT_FOUND] and [SHARD_OFFLINE]: the site is up, the shard
* may well be up, and retrying changes nothing — an admin decides this.
*/
FEATURE_UNAVAILABLE,
/** Any other non-2xx server response. */
SERVER,
}
@@ -52,3 +59,27 @@ fun <T> ApiResult<T>.toUiState(): UiState<T> = when (this) {
httpStatus = status,
)
}
/**
* [toUiState] for a **shard-derived** read, where `404` carries a second meaning.
*
* The website's `requireFeature` gate answers `404` when a feature is switched off —
* deliberately, so the response doesn't disclose that the surface exists — and `403`
* when it's on but the caller is below its audience rung (`docs/link/v3.md` §3.6).
* On these routes a `404` therefore almost never means "no such thing"; it means this
* shard doesn't publish it. Rendering "couldn't be found" with a retry button would
* invite the user to retry something an admin controls.
*
* Kept as a separate mapper rather than folded into [toUiState] because both statuses
* mean something else off the shard surface: `404` is a genuinely missing item (a
* deleted post, an unknown wiki slug) and `403` is an ownership or role refusal on a
* player or admin route, which is not an admin's visibility setting.
*/
fun <T> ApiResult<T>.toShardUiState(): UiState<T> = when (this) {
is ApiResult.HttpError -> if (status == 403 || status == 404) {
UiState.Error(ErrorKind.FEATURE_UNAVAILABLE, httpStatus = status)
} else {
toUiState()
}
else -> toUiState()
}

View File

@@ -30,7 +30,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment

View File

@@ -84,7 +84,6 @@ fun AdminSupportScreen(
replyTo?.let { page ->
RespondDialog(
page = page,
onDismiss = { replyTo = null },
onSend = { message, close ->
viewModel.respond(page.pageId, message, close)
@@ -122,7 +121,6 @@ private fun SupportPageCard(
@Composable
private fun RespondDialog(
page: SupportPageDto,
onDismiss: () -> Unit,
onSend: (message: String, close: Boolean) -> Unit,
) {

View File

@@ -153,18 +153,11 @@ class LoginViewModel @Inject constructor(
fun submit() {
val s = _state.value
if (s.submitting) return
if (s.username.isBlank() || s.password.isBlank()) {
_state.update { it.copy(error = LoginError.INVALID_CREDENTIALS) }
val validationError = validateForSubmit(s)
if (validationError != null) {
_state.update { it.copy(error = validationError) }
return
}
// If 2FA is being requested, the chosen second factor must accompany the resubmit.
if (s.totpRequired) {
val factor = if (s.useRecoveryCode) s.recoveryCode else s.code
if (factor.isBlank()) {
_state.update { it.copy(error = LoginError.BAD_CODE) }
return
}
}
_state.update { it.copy(submitting = true, error = null) }
viewModelScope.launch {
@@ -178,36 +171,50 @@ class LoginViewModel @Inject constructor(
recoveryCode = recoveryCode,
trustDevice = s.trustDevice,
)
when (result) {
is LoginResult.Success ->
// The trusted-device cap (result.trustLimitReached) is an edge case:
// login succeeded but the device wasn't remembered. It's surfaced +
// managed on the Trusted Devices screen rather than blocking sign-in.
_state.update { it.copy(submitting = false, signedIn = true) }
LoginResult.TotpRequired ->
// Reveal the 2FA fields; a wrong code/recovery code re-lands here as BAD_CODE.
_state.update {
val hadFactor = if (it.useRecoveryCode) it.recoveryCode.isNotBlank() else it.code.isNotBlank()
it.copy(
submitting = false,
totpRequired = true,
error = if (hadFactor) LoginError.BAD_CODE else null,
)
}
LoginResult.InvalidCredentials ->
_state.update { it.copy(submitting = false, error = LoginError.INVALID_CREDENTIALS) }
LoginResult.RateLimited ->
_state.update { it.copy(submitting = false, error = LoginError.RATE_LIMITED) }
LoginResult.ServerError ->
_state.update { it.copy(submitting = false, error = LoginError.SERVER) }
LoginResult.NetworkError ->
_state.update { it.copy(submitting = false, error = LoginError.NETWORK) }
}
applyLoginResult(result)
}
}
/** Pre-flight form checks for [submit]; returns the error to surface, or null if ready to send. */
private fun validateForSubmit(s: UiState): LoginError? {
if (s.username.isBlank() || s.password.isBlank()) return LoginError.INVALID_CREDENTIALS
// If 2FA is being requested, the chosen second factor must accompany the resubmit.
if (s.totpRequired) {
val factor = if (s.useRecoveryCode) s.recoveryCode else s.code
if (factor.isBlank()) return LoginError.BAD_CODE
}
return null
}
/** Folds a [LoginResult] back into the UI state (clears [UiState.submitting] on every path). */
private fun applyLoginResult(result: LoginResult) = when (result) {
is LoginResult.Success ->
// The trusted-device cap (result.trustLimitReached) is an edge case:
// login succeeded but the device wasn't remembered. It's surfaced +
// managed on the Trusted Devices screen rather than blocking sign-in.
_state.update { it.copy(submitting = false, signedIn = true) }
LoginResult.TotpRequired ->
// Reveal the 2FA fields; a wrong code/recovery code re-lands here as BAD_CODE.
_state.update {
val hadFactor = if (it.useRecoveryCode) it.recoveryCode.isNotBlank() else it.code.isNotBlank()
it.copy(
submitting = false,
totpRequired = true,
error = if (hadFactor) LoginError.BAD_CODE else null,
)
}
LoginResult.InvalidCredentials ->
_state.update { it.copy(submitting = false, error = LoginError.INVALID_CREDENTIALS) }
LoginResult.RateLimited ->
_state.update { it.copy(submitting = false, error = LoginError.RATE_LIMITED) }
LoginResult.ServerError ->
_state.update { it.copy(submitting = false, error = LoginError.SERVER) }
LoginResult.NetworkError ->
_state.update { it.copy(submitting = false, error = LoginError.NETWORK) }
}
}

View File

@@ -36,6 +36,10 @@ fun LoadingView(modifier: Modifier = Modifier) {
/**
* Whole-screen error state with a friendly, kind-specific message and a Retry
* button (§7). Copy is resolved from string resources so it stays localizable.
*
* [ErrorKind.FEATURE_UNAVAILABLE] renders **without** the button: an admin decides
* whether the shard publishes that surface, so retrying cannot change the answer and
* offering it would read as a transient failure the user could wait out (M11).
*/
@Composable
fun ErrorView(
@@ -53,15 +57,20 @@ fun ErrorView(
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
)
Button(
onClick = onRetry,
modifier = Modifier.padding(top = 16.dp).width(160.dp),
) {
Text(stringResource(R.string.action_retry))
if (isRetryable(kind)) {
Button(
onClick = onRetry,
modifier = Modifier.padding(top = 16.dp).width(160.dp),
) {
Text(stringResource(R.string.action_retry))
}
}
}
}
/** Whether retrying this failure could plausibly succeed. Pure, so it is unit-tested. */
fun isRetryable(kind: ErrorKind): Boolean = kind != ErrorKind.FEATURE_UNAVAILABLE
/** Centered informational message for an empty list (§7). */
@Composable
fun EmptyView(message: String, modifier: Modifier = Modifier) {
@@ -83,5 +92,6 @@ private fun errorMessageRes(kind: ErrorKind): Int = when (kind) {
ErrorKind.NOT_FOUND -> R.string.error_not_found
ErrorKind.RATE_LIMITED -> R.string.error_rate_limited
ErrorKind.SHARD_OFFLINE -> R.string.error_shard_offline
ErrorKind.FEATURE_UNAVAILABLE -> R.string.error_feature_unavailable
ErrorKind.SERVER -> R.string.error_server
}

View File

@@ -6,6 +6,9 @@ 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.ShardFeature
import com.runicgateway.app.data.repository.ShardFeatures
import com.runicgateway.app.data.repository.canSee
/**
* One shared, declarative, access-level navigation definition (PLAN.md §5): a
@@ -21,7 +24,12 @@ enum class MenuAccess {
/** Visible to any signed-in account (§5, "My Account"). */
SIGNED_IN,
/** Visible only to a player — the linked game-data groups (§6.3). */
/**
* The linked game-data groups (§6.3). Visible to any player **or** staff:
* staff are a superset of players (all player abilities plus their staff
* tools), and the backend's player self-service surface is role-agnostic, so
* a signed-in admin/editor/moderator sees + uses their own characters too.
*/
PLAYER,
/** Visible to any staff role (admin/editor/moderator) — the M10 staff surface (§1). */
@@ -35,6 +43,15 @@ data class MenuEntry(
val route: String,
@param:StringRes val labelRes: Int,
val access: MenuAccess = MenuAccess.PUBLIC,
/**
* For a shard-derived surface, the visibility feature it belongs to (M11).
*
* Session role is not the only gate on these: an admin can switch a feature off
* or raise its audience above the caller's rung, so the entry is filtered by
* `GET /public/shard/features` as well as by [access]. `null` means the entry
* isn't shard-derived and only [access] applies.
*/
val feature: String? = null,
)
/**
@@ -46,7 +63,7 @@ val APP_MENU: List<MenuEntry> = listOf(
MenuEntry(Routes.HOME, R.string.menu_home),
MenuEntry(Routes.NEWS, R.string.menu_news),
MenuEntry(Routes.WIKI, R.string.menu_wiki),
MenuEntry(Routes.SHARD, R.string.menu_shard),
MenuEntry(Routes.SHARD, R.string.menu_shard, feature = ShardFeature.STATUS),
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),
@@ -62,16 +79,28 @@ val APP_MENU: List<MenuEntry> = listOf(
)
/**
* The entries the given [session] may see. Pure + side-effect-free so the access
* gating is unit-tested without Compose.
* 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.
*
* Two independent filters, and both must pass:
*
* - [MenuEntry.access] against the session — who the caller is.
* - [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.
*/
fun visibleEntries(entries: List<MenuEntry>, session: Session): List<MenuEntry> =
fun visibleEntries(
entries: List<MenuEntry>,
session: Session,
features: ShardFeatures? = null,
): List<MenuEntry> =
entries.filter { entry ->
when (entry.access) {
val allowedByRole = when (entry.access) {
MenuAccess.PUBLIC -> true
MenuAccess.SIGNED_IN -> session is Session.SignedIn
MenuAccess.PLAYER -> session is Session.SignedIn && session.user.isPlayer
MenuAccess.PLAYER -> session is Session.SignedIn && (session.user.isPlayer || session.user.isStaff)
MenuAccess.STAFF -> session is Session.SignedIn && session.user.isStaff
MenuAccess.MODERATOR -> session is Session.SignedIn && session.user.isModerator
}
allowedByRole && (entry.feature == null || canSee(features, entry.feature))
}

View File

@@ -26,6 +26,7 @@ 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.CharPointsDto
import com.runicgateway.app.data.api.dto.CharProfileDto
import com.runicgateway.app.data.api.dto.CharStatsDto
import com.runicgateway.app.data.api.dto.EquipmentDto
@@ -71,6 +72,7 @@ private fun CharacterSheet(char: CharProfileDto, modifier: Modifier = Modifier)
char.stats?.let { AttributesBlock(it) }
char.stats?.resist?.let { ResistancesBlock(it) }
SkillsBlock(char.skills)
PointsBlock(displayPoints(char))
EquipmentBlock(char.equipment)
}
}
@@ -215,6 +217,47 @@ private fun SkillsBlock(skills: List<SkillDto>) {
}
}
/**
* Loyalty & points standings (Protocol 3.0 §7.3). Renders nothing at all for a
* character that has earned nothing anywhere, which is a normal state.
*
* Only a system with a real cap gets a meter: an uncapped score
* ([CharPointsDto.maxPoints] `0`, the common case on a real shard) has nothing to be
* a fraction of, and a full-width bar would imply a completion that doesn't exist.
*/
@Composable
private fun PointsBlock(points: List<CharPointsDto>) {
if (points.isEmpty()) return
SheetCard(R.string.player_char_points) {
points.forEach { entry ->
val cap = entry.cap
val score = entry.points ?: 0L
Column(Modifier.padding(vertical = 5.dp)) {
Row(
Modifier.fillMaxWidth().padding(bottom = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
// `rank` is absent unless the shard opts in; absent and
// "unranked" are different, so the suffix only appears when sent.
entry.rank?.let { stringResource(R.string.player_char_points_ranked, pointsLabel(entry), it) }
?: pointsLabel(entry),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
cap?.let { stringResource(R.string.player_char_points_of, score, it) } ?: score.toString(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Medium,
)
}
if (cap != null) StatBar((score.toDouble() / cap).coerceIn(0.0, 1.0).toFloat())
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun EquipmentBlock(equipment: List<EquipmentDto>) {
@@ -223,7 +266,7 @@ private fun EquipmentBlock(equipment: List<EquipmentDto>) {
equipment.forEach { item ->
Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
Text(
item.layer ?: stringResource(R.string.player_char_item),
item.label ?: stringResource(R.string.player_char_item),
style = MaterialTheme.typography.bodyLarge,
)
val meta = listOfNotNull(
@@ -266,23 +309,58 @@ internal fun formatSkill(value: Double): String =
/**
* The human-readable title chips for a [TitlesDto] (parity with the website's
* `CharacterSheet.jsx#displayTitles`): fame/karma, skill title, and the selected
* reward title — but only if it is a literal string, not a bare cliloc number
* (the app ships no cliloc table). De-duplicated, blanks dropped.
* reward title.
*
* Reward entries arrive as either a literal or a cliloc number in string form. The
* server now resolves the numeric ones into `rewardResolved`, a **parallel** array
* (see `docs/website/CLILOCS.md`), so the mapping below is index-preserving: an entry
* that didn't resolve becomes null and is skipped, but must not shift the `selected`
* index onto its neighbour. A number with no resolution is still skipped rather than
* rendered as a raw id, which is also the whole behavior on a shard that configures
* no cliloc table.
*
* Falling back to the first title that resolved (rather than showing nothing) matters
* when the *selected* one is the unresolved entry. De-duplicated, blanks dropped.
*/
internal fun displayTitles(titles: TitlesDto?): List<String> {
if (titles == null) return emptyList()
val out = mutableListOf<String>()
titles.fameKarma?.let { out.add(it) }
titles.skill?.let { out.add(it) }
val reward = titles.reward
val sel = titles.selected ?: -1
val candidate = when {
sel in reward.indices -> reward[sel]
else -> reward.firstOrNull { it.isNotBlank() && !it.all(Char::isDigit) }
val reward = titles.reward.mapIndexed { i, raw ->
titles.rewardResolved.getOrNull(i)
?: raw.takeUnless { it.isBlank() || it.all(Char::isDigit) }
}
if (candidate != null && candidate.isNotBlank() && !candidate.all(Char::isDigit)) out.add(candidate)
val candidate = reward.getOrNull(titles.selected ?: -1) ?: reward.firstNotNullOfOrNull { it }
if (!candidate.isNullOrBlank()) out.add(candidate)
return out.filter { it.isNotBlank() }.distinct()
}
/**
* A point system's display name: the shard's own [CharPointsDto.nameString] when it
* has one, else the humanised `PointsType` key.
*
* The fallback is the PRIMARY path, not a defensive nicety — most systems name
* themselves with a cliloc, so `nameString` comes back null for four of five boards
* on a real shard (`docs/link/v3.md` §7.5). Parity with the website's
* `humanisePoints`.
*/
internal fun pointsLabel(entry: CharPointsDto): String {
entry.nameString?.takeIf { it.isNotBlank() }?.let { return it }
val key = entry.system.orEmpty()
return key
.replace(Regex("([a-z0-9])([A-Z])"), "$1 $2")
.replaceFirstChar { it.uppercaseChar() }
}
/**
* The points block, best standing first, dropping systems the character has no score
* in. Guarded for an older shard plugin that sends no `points` block at all.
*/
internal fun displayPoints(char: CharProfileDto): List<CharPointsDto> =
char.points
.filter { (it.points ?: 0L) > 0L }
.sortedByDescending { it.points ?: 0L }
private fun jsonText(element: kotlinx.serialization.json.JsonElement): String =
runCatching { element.jsonPrimitive.content }.getOrElse { element.toString() }

View File

@@ -8,6 +8,8 @@ import androidx.lifecycle.viewModelScope
import com.runicgateway.app.core.auth.Session
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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
@@ -23,10 +25,28 @@ import javax.inject.Inject
class SessionViewModel @Inject constructor(
sessionManager: SessionManager,
private val authRepository: AuthRepository,
shardFeaturesRepository: ShardFeaturesRepository,
) : ViewModel() {
val session: StateFlow<Session> = sessionManager.state
/**
* Which shard features this viewer may reach (M11). Held here beside [session]
* because it answers the same question for the same consumer: what the shared
* menu reveals. Role and feature config are independent gates — see
* [com.runicgateway.app.ui.navigation.visibleEntries].
*/
val shardFeatures: StateFlow<ShardFeatures?> = shardFeaturesRepository.features
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
// the same user does not refetch — only a real sign-in/out/role change does.
viewModelScope.launch {
session.collect { shardFeaturesRepository.refresh() }
}
}
/** Re-validate the cached role against the backend on app resume. */
fun revalidate() {
viewModelScope.launch { authRepository.revalidate() }

View File

@@ -10,7 +10,7 @@ import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.dto.ChampDto
import com.runicgateway.app.data.repository.ShardRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.toUiState
import com.runicgateway.app.ui.toShardUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -49,7 +49,7 @@ class ChampsViewModel @Inject constructor(
board.seed(result.data)
publish()
}
else -> _state.value = result.toUiState()
else -> _state.value = result.toShardUiState()
}
}
}

View File

@@ -11,7 +11,7 @@ import com.runicgateway.app.data.api.dto.GovernorDto
import com.runicgateway.app.data.api.dto.GovernorTermDto
import com.runicgateway.app.data.repository.ShardRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.toUiState
import com.runicgateway.app.ui.toShardUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -55,7 +55,7 @@ class GovernorsViewModel @Inject constructor(
board.seed(result.data)
publish()
}
else -> _state.value = result.toUiState()
else -> _state.value = result.toShardUiState()
}
}
}

View File

@@ -10,7 +10,7 @@ import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.dto.GuildDto
import com.runicgateway.app.data.repository.ShardRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.toUiState
import com.runicgateway.app.ui.toShardUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -50,7 +50,7 @@ class GuildsViewModel @Inject constructor(
board.seed(result.data)
publish()
}
else -> _state.value = result.toUiState()
else -> _state.value = result.toShardUiState()
}
}
}

View File

@@ -10,7 +10,7 @@ import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.dto.HouseDto
import com.runicgateway.app.data.repository.ShardRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.toUiState
import com.runicgateway.app.ui.toShardUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -50,7 +50,7 @@ class HousesViewModel @Inject constructor(
board.seed(result.data)
publish()
}
else -> _state.value = result.toUiState()
else -> _state.value = result.toShardUiState()
}
}
}

View File

@@ -32,12 +32,33 @@ import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.FeatureCard
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.data.repository.ShardFeature
import com.runicgateway.app.data.repository.ShardFeatures
import com.runicgateway.app.data.repository.canSee
import com.runicgateway.app.ui.components.PillTone
import com.runicgateway.app.ui.components.SectionLabel
import com.runicgateway.app.ui.components.StatusPill
/** Board destinations reachable from the hub. */
enum class ShardBoard { CHAMPS, GUILDS, GOVERNORS, HOUSES }
/**
* Board destinations reachable from the hub, each tagged with the visibility feature
* that governs it (M11). An admin can switch any of these off or raise its audience,
* so the hub's board list is filtered the same way the drawer is — a tile whose
* feature the caller can't see would only lead to a `404`/`403`.
*/
enum class ShardBoard(val feature: String) {
CHAMPS(ShardFeature.CHAMPS),
GUILDS(ShardFeature.GUILDS),
GOVERNORS(ShardFeature.GOVERNORS),
HOUSES(ShardFeature.HOUSES),
}
/**
* The boards this viewer may reach. Pure + side-effect-free so the gating is
* unit-tested without Compose, exactly like `visibleEntries` for the drawer. An
* unknown answer shows every board — the server gates regardless (see [canSee]).
*/
fun visibleBoards(features: ShardFeatures?): List<ShardBoard> =
ShardBoard.entries.filter { canSee(features, it.feature) }
/**
* The Shard hub (PLAN.md §6.2): live connection status, online count + latest
@@ -54,6 +75,7 @@ fun ShardScreen(
val state by viewModel.state.collectAsStateWithLifecycle()
val feed by viewModel.feed.collectAsStateWithLifecycle()
val connected by viewModel.connected.collectAsStateWithLifecycle()
val features by viewModel.shardFeatures.collectAsStateWithLifecycle()
when (val s = state) {
is UiState.Loading -> LoadingView(modifier)
@@ -62,6 +84,7 @@ fun ShardScreen(
hub = s.data,
feed = feed,
connected = connected,
features = features,
onOpenBoard = onOpenBoard,
modifier = modifier,
)
@@ -73,6 +96,7 @@ private fun HubContent(
hub: ShardHub,
feed: List<FeedLine>,
connected: Boolean,
features: ShardFeatures?,
onOpenBoard: (ShardBoard) -> Unit,
modifier: Modifier = Modifier,
) {
@@ -82,7 +106,7 @@ private fun HubContent(
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 16.dp),
) {
item { StatusCard(hub.status, hub.presence?.count) }
item { BoardsCard(onOpenBoard) }
item { BoardsCard(features, onOpenBoard) }
if (hub.online.isNotEmpty()) {
item { SectionHeader(stringResource(R.string.shard_section_staff)) }
@@ -159,13 +183,16 @@ private fun StatusCard(status: ShardStatusDto, presenceCount: Int?) {
}
@Composable
private fun BoardsCard(onOpenBoard: (ShardBoard) -> Unit) {
val boards = listOf(
ShardBoard.CHAMPS to R.string.shard_nav_champs,
ShardBoard.GUILDS to R.string.shard_nav_guilds,
ShardBoard.GOVERNORS to R.string.shard_nav_governors,
ShardBoard.HOUSES to R.string.shard_nav_houses,
)
private fun BoardsCard(features: ShardFeatures?, onOpenBoard: (ShardBoard) -> Unit) {
val boards = visibleBoards(features).map { board ->
board to when (board) {
ShardBoard.CHAMPS -> R.string.shard_nav_champs
ShardBoard.GUILDS -> R.string.shard_nav_guilds
ShardBoard.GOVERNORS -> R.string.shard_nav_governors
ShardBoard.HOUSES -> R.string.shard_nav_houses
}
}
if (boards.isEmpty()) return
Card(Modifier.fillMaxWidth()) {
Column {
boards.forEachIndexed { index, (board, labelRes) ->

View File

@@ -10,9 +10,11 @@ import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.dto.OnlineStaffDto
import com.runicgateway.app.data.api.dto.PresenceDto
import com.runicgateway.app.data.api.dto.ShardStatusDto
import com.runicgateway.app.data.repository.ShardFeatures
import com.runicgateway.app.data.repository.ShardFeaturesRepository
import com.runicgateway.app.data.repository.ShardRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.toUiState
import com.runicgateway.app.ui.toShardUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -39,11 +41,18 @@ data class ShardHub(
@HiltViewModel
class ShardViewModel @Inject constructor(
private val repository: ShardRepository,
shardFeaturesRepository: ShardFeaturesRepository,
) : ViewModel() {
private val _state = MutableStateFlow<UiState<ShardHub>>(UiState.Loading)
val state: StateFlow<UiState<ShardHub>> = _state.asStateFlow()
/**
* Which boards to offer (M11). Read-only here — the app shell refreshes this on
* every session change, and the hub only filters its tiles with it.
*/
val shardFeatures: StateFlow<ShardFeatures?> = shardFeaturesRepository.features
private val _feed = MutableStateFlow<List<FeedLine>>(emptyList())
val feed: StateFlow<List<FeedLine>> = _feed.asStateFlow()
@@ -69,8 +78,8 @@ class ShardViewModel @Inject constructor(
seedFeed()
}
// Both error variants are ApiResult<Nothing>, so their UiState is Nothing-typed.
is ApiResult.HttpError -> _state.value = status.toUiState()
is ApiResult.NetworkError -> _state.value = status.toUiState()
is ApiResult.HttpError -> _state.value = status.toShardUiState()
is ApiResult.NetworkError -> _state.value = status.toShardUiState()
}
}
}

View File

@@ -16,6 +16,7 @@
<string name="error_not_found">This content couldn\'t be found.</string>
<string name="error_rate_limited">Too many requests. Please try again in a moment.</string>
<string name="error_shard_offline">The shard is offline right now.</string>
<string name="error_feature_unavailable">This shard doesn\'t publish this here.</string>
<string name="error_server">Something went wrong on the server. Please try again.</string>
<!-- ── First-run connect (§3) ──────────────────────────────────────── -->
@@ -290,6 +291,11 @@
<string name="player_char_pois">Poison</string>
<string name="player_char_energy">Energy</string>
<string name="player_char_skills">Skills</string>
<string name="player_char_points">Loyalty &amp; Points</string>
<!-- A point system's name followed by the character's rank on that board, e.g. "Queens Loyalty · #3". -->
<string name="player_char_points_ranked">%1$s · #%2$d</string>
<!-- A score against its cap. Only shown for capped systems; an uncapped score shows the number alone. -->
<string name="player_char_points_of">%1$d / %2$d</string>
<string name="player_char_equipment">Equipment</string>
<string name="player_char_item">Item</string>
<string name="player_char_item_id">id %1$d</string>

View File

@@ -7,6 +7,7 @@ import com.runicgateway.app.core.auth.Session
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.core.auth.TrustTokenStore
import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.data.api.SsoApi
import com.runicgateway.app.data.api.dto.MobileSsoExchangeRequest
@@ -45,6 +46,17 @@ class SsoAuthManagerTest {
override fun clear() { pending = null }
}
/** In-memory stand-in for the encrypted trust-token store, scoped by username
* the same way the production impl is. */
private class FakeTrustTokenStore : TrustTokenStore {
var owner: String? = null
var token: String? = null
override fun tokenFor(username: String): String? =
if (owner.equals(username, ignoreCase = true)) token else null
override fun save(username: String, token: String) { owner = username; this.token = token }
override fun clear() { owner = null; token = null }
}
/** Records the exchange it was called with and returns a scripted response. */
private class FakeSsoApi(
private val exchangeResult: () -> Response<MobileTokenResponse>,
@@ -76,10 +88,11 @@ class SsoAuthManagerTest {
session: SessionManager,
base: String? = "https://shard.example.com/",
store: PendingSsoStore = FakePendingSsoStore(),
trust: TrustTokenStore = FakeTrustTokenStore(),
): SsoAuthManager {
val holder = BaseUrlHolder()
if (base != null) holder.set(base.toHttpUrl())
return SsoAuthManager(api, session, holder, store)
return SsoAuthManager(api, session, holder, store, trust)
}
/** Build a start URL and pull the generated `state` back out of it. */
@@ -120,6 +133,38 @@ class SsoAuthManagerTest {
assertEquals(SsoAuthManager.Outcome.Success, mgr.outcome.value)
}
// Trusted devices over SSO (TRUSTED_DEVICES_MFA.md). Ticking "trust this device"
// on the TOTP form inside the Custom Tab trusts that browser via cookie; the
// exchange additionally hands the APP its own token so a native password login
// on this device skips the code too. Before this, SSO ignored trust entirely.
@Test fun `a trustToken on the exchange response is persisted for the signed-in user`() = runTest {
val api = FakeSsoApi { Response.success(tokenPair().copy(trustToken = "opaque-trust")) }
val session = SessionManager(FakeTokenStore())
val trust = FakeTrustTokenStore()
val mgr = managerWith(api, session, trust = trust)
val state = startAndState(mgr)
mgr.complete(state = state, code = "auth-code-1", error = null)
assertEquals(SsoAuthManager.Outcome.Success, mgr.outcome.value)
assertEquals("opaque-trust", trust.tokenFor("alice"))
// Scoped to the account that minted it — never replayed for someone else.
assertNull(trust.tokenFor("mallory"))
}
@Test fun `no trustToken on the response leaves the store untouched`() = runTest {
val api = FakeSsoApi { Response.success(tokenPair()) }
val session = SessionManager(FakeTokenStore())
val trust = FakeTrustTokenStore()
val mgr = managerWith(api, session, trust = trust)
val state = startAndState(mgr)
mgr.complete(state = state, code = "auth-code-1", error = null)
assertEquals(SsoAuthManager.Outcome.Success, mgr.outcome.value)
assertNull(trust.tokenFor("alice"))
}
@Test fun `state mismatch fails without exchanging`() = runTest {
val api = FakeSsoApi { Response.success(tokenPair()) }
val session = SessionManager(FakeTokenStore())

View File

@@ -0,0 +1,41 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.result
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The [ApiResult] helpers: [map] transforms an [ApiResult.Ok] and passes the two
* failure variants through unchanged; [isShardUnavailable] is the 503 "shard down"
* signal the player screens render as offline.
*/
class ApiResultExtrasTest {
@Test fun mapTransformsOkBody() {
val mapped = ApiResult.Ok(listOf(1, 2, 3)).map { it.size }
assertEquals(ApiResult.Ok(3), mapped)
}
@Test fun mapPassesFailuresThroughUnchanged() {
val http: ApiResult<Int> = ApiResult.HttpError(500, "boom")
assertSame(http, http.map { it + 1 })
val cause = RuntimeException("offline")
val network: ApiResult<Int> = ApiResult.NetworkError(cause)
val out = network.map { it + 1 }
assertTrue(out is ApiResult.NetworkError)
assertSame(cause, (out as ApiResult.NetworkError).cause)
}
@Test fun isShardUnavailableOnlyForHttp503() {
assertTrue(ApiResult.HttpError(503).isShardUnavailable())
assertFalse(ApiResult.HttpError(500).isShardUnavailable())
assertFalse(ApiResult.Ok(Unit).isShardUnavailable())
assertFalse(ApiResult.NetworkError(RuntimeException()).isShardUnavailable())
}
}

View File

@@ -0,0 +1,127 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Decode/encode tests for the staff-operations DTOs (`/admin/…`, PLAN.md §6.4).
* Covers the snake_case `@SerialName` mappings, the `AdminPostDto.isPublished`
* tinyint bridge, nested dashboard shapes, and the request bodies the app encodes.
*/
class AdminDtoTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
@Test fun dashboardDecodesNestedCountsAndActivity() {
val dto = json.decodeFromString<AdminDashboardDto>(
"""{
"site_mode":"maintenance",
"last_change":{"at":"2026-07-20T10:00:00Z","by":"admin"},
"counts":{"posts":{"news":4,"newsletter":1},"users":37},
"recent_activity":[
{"id":9,"username":"mod","action":"post.publish",
"detail":{"postId":12},"created_at":"2026-07-22T09:00:00Z"}
]
}""",
)
assertEquals("maintenance", dto.siteMode)
assertEquals("admin", dto.lastChange.by)
assertEquals(4, dto.counts.posts["news"])
assertEquals(37, dto.counts.users)
assertEquals(1, dto.recentActivity.size)
assertEquals("post.publish", dto.recentActivity[0].action)
// `detail` is provider-shaped JSON kept as a raw element.
assertEquals(12, dto.recentActivity[0].detail!!.jsonObject["postId"]!!.jsonPrimitive.int)
}
@Test fun dashboardDefaultsWhenKeysAbsent() {
val dto = json.decodeFromString<AdminDashboardDto>("{}")
assertEquals("live", dto.siteMode)
assertTrue(dto.counts.posts.isEmpty())
assertTrue(dto.recentActivity.isEmpty())
}
@Test fun adminPostBridgesPublishedTinyintToBoolean() {
val published = json.decodeFromString<AdminPostDto>(
"""{"id":1,"category":"news","title":"Hi","published":1,"published_at":"2026-07-21T00:00:00Z"}""",
)
assertTrue(published.isPublished)
assertEquals("2026-07-21T00:00:00Z", published.publishedAt)
val draft = json.decodeFromString<AdminPostDto>("""{"id":2,"title":"Draft","published":0}""")
assertFalse(draft.isPublished)
}
@Test fun adminWikiCategoryAndTagDecodeCounts() {
val cat = json.decodeFromString<AdminWikiCategoryDto>(
"""{"id":3,"slug":"lore","title":"Lore","sort_order":2,"page_count":5,"published_count":4}""",
)
assertEquals(2, cat.sortOrder)
assertEquals(5, cat.pageCount)
assertEquals(4, cat.publishedCount)
val tag = json.decodeFromString<AdminWikiTagDto>("""{"id":8,"slug":"pvp","label":"PvP","published_count":11}""")
assertEquals("PvP", tag.label)
assertEquals(11, tag.publishedCount)
}
@Test fun supportPageDecodesSenderActor() {
val dto = json.decodeFromString<SupportPageDto>(
"""{"pageId":"0x1A2B","type":"other","message":"stuck",
"handled":false,"sender":{"name":"Gwen","account":"gwen01"}}""",
)
assertEquals("0x1A2B", dto.pageId)
assertEquals("Gwen", dto.sender?.name)
assertEquals("gwen01", dto.sender?.account)
assertEquals(false, dto.handled)
}
@Test fun supportPageToleratesMissingSender() {
val dto = json.decodeFromString<SupportPageDto>("""{"pageId":"0x01"}""")
assertNull(dto.sender)
assertNull(dto.type)
}
@Test fun siteModeStateDecodesAudit() {
val dto = json.decodeFromString<SiteModeStateDto>(
"""{"site_mode":"maintenance","changed_at":"2026-07-22T08:00:00Z","changed_by":"admin"}""",
)
assertEquals("maintenance", dto.siteMode)
assertEquals("admin", dto.changedBy)
}
@Test fun requestBodiesEncodeWithSnakeCaseKeys() {
assertTrue(json.encodeToString(SiteModeRequest("maintenance")).contains("\"mode\":\"maintenance\""))
assertTrue(json.encodeToString(PublishRequest(true)).contains("\"published\":true"))
assertTrue(json.encodeToString(UnbanRequest("gwen01")).contains("\"account\":\"gwen01\""))
assertTrue(json.encodeToString(BroadcastRequest("hello", hue = 33)).contains("\"hue\":33"))
assertTrue(json.encodeToString(PageRespondRequest("done", close = true)).contains("\"close\":true"))
assertTrue(json.encodeToString(WikiCategoryRequest(slug = "lore", title = "Lore", sortOrder = 1))
.contains("\"sort_order\":1"))
val post = json.encodeToString(PostCreateRequest(category = "news", title = "T", imageUrl = "/img.png"))
assertTrue(post.contains("\"image_url\":\"/img.png\""))
assertTrue(post.contains("\"category\":\"news\""))
val ban = json.encodeToString(BanRequest(account = "x", durationSec = 3600, reason = "afk"))
assertTrue(ban.contains("\"durationSec\":3600"))
val kick = json.encodeToString(KickRequest(serial = "0xFF"))
assertTrue(kick.contains("\"serial\":\"0xFF\""))
}
}

View File

@@ -0,0 +1,68 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Encode/decode tests for the mobile bearer-auth request bodies (`/auth/mobile/…`)
* and the token pair — the snake_case `device_name`, the omit-nulls behaviour, and
* the trusted-device outcome fields on the login response.
*/
class AuthRequestDtoTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
@Test fun loginRequestEncodesSnakeCaseDeviceNameAndOmitsNulls() {
val body = json.encodeToString(
MobileLoginRequest(username = "gwen", password = "pw", trustDevice = true, device_name = "Pixel 8"),
)
assertTrue(body.contains("\"username\":\"gwen\""))
assertTrue(body.contains("\"device_name\":\"Pixel 8\""))
assertTrue(body.contains("\"trustDevice\":true"))
assertFalse(body.contains("\"code\"")) // null omitted (explicitNulls = false)
}
@Test fun loginRequestCarriesSecondFactorOnRetry() {
val withCode = json.encodeToString(MobileLoginRequest("u", "p", code = "123456"))
assertTrue(withCode.contains("\"code\":\"123456\""))
val withRecovery = json.encodeToString(MobileLoginRequest("u", "p", recoveryCode = "aaaa-1111"))
assertTrue(withRecovery.contains("\"recoveryCode\":\"aaaa-1111\""))
}
@Test fun refreshAndLogoutBodiesEncode() {
assertTrue(json.encodeToString(MobileRefreshRequest("rt")).contains("\"refreshToken\":\"rt\""))
assertTrue(json.encodeToString(MobileLogoutRequest(all = true)).contains("\"all\":true"))
}
@Test fun tokenResponseDecodesTrustOutcome() {
val dto = json.decodeFromString<MobileTokenResponse>(
"""{"accessToken":"a","refreshToken":"r","expiresIn":"15m",
"user":{"id":1,"username":"gwen","role":"player"},
"trustToken":"opaque"}""",
)
assertEquals("a", dto.accessToken)
assertEquals("opaque", dto.trustToken)
assertFalse(dto.trustLimitReached)
assertEquals("gwen", dto.user.username)
}
@Test fun tokenResponseDecodesTrustLimitReached() {
val dto = json.decodeFromString<MobileTokenResponse>(
"""{"accessToken":"a","refreshToken":"r","user":{"id":1,"username":"g","role":"player"},
"trustLimitReached":true,"devices":[{"id":1,"platform":"web","deviceName":"FF"}]}""",
)
assertTrue(dto.trustLimitReached)
assertEquals(1, dto.devices.size)
}
}

View File

@@ -0,0 +1,82 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Decode tests for the news post + CMS page + contact DTOs (`/public/posts…`,
* `/public/pages/:slug`, `/public/contact`). One [PostDto] shape serves both the
* list (no body) and detail (with body); a [PageDto] keeps block props as raw JSON.
*/
class ContentDtoTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
@Test fun postDetailDecodesBodyAndImage() {
val dto = json.decodeFromString<PostDto>(
"""{"id":10,"category":"news","title":"Update","slug":"update",
"excerpt":"e","body":"<p>full</p>","image_url":"/i.png",
"published_at":"2026-07-21T00:00:00Z","created_at":"2026-07-20T00:00:00Z"}""",
)
assertEquals(10L, dto.id)
assertEquals("<p>full</p>", dto.body)
assertEquals("/i.png", dto.imageUrl)
assertEquals("2026-07-21T00:00:00Z", dto.publishedAt)
}
@Test fun postListRowToleratesMissingBody() {
val dto = json.decodeFromString<PostDto>("""{"id":11,"category":"newsletter","title":"N"}""")
assertNull(dto.body)
assertNull(dto.imageUrl)
}
@Test fun pageDecodesBlocksWithRawProps() {
val dto = json.decodeFromString<PageDto>(
"""{
"id":3,"slug":"about","title":"About","status":"published",
"blocks":[
{"type":"heading","props":{"text":"Welcome","level":1},"visible":true},
{"type":"divider","props":{}}
],
"publishedAt":"2026-07-01T00:00:00Z"
}""",
)
assertEquals("about", dto.slug)
assertEquals(2, dto.blocks.size)
assertEquals("heading", dto.blocks[0].type)
// props stay a raw JSON object the renderer reads by key.
assertEquals("Welcome", dto.blocks[0].props["text"]!!.jsonPrimitive.content)
assertTrue(dto.blocks[1].visible) // default true when absent
}
@Test fun contactResponseSentAndFallbackVariants() {
val sent = json.decodeFromString<ContactResponse>("""{"sent":true}""")
assertTrue(sent.sent)
assertNull(sent.fallback)
val fallback = json.decodeFromString<ContactResponse>(
"""{"sent":false,"fallback":"mailto","email":"a@b.c"}""",
)
assertEquals("mailto", fallback.fallback)
assertEquals("a@b.c", fallback.email)
}
@Test fun contactRequestEncodesAllFields() {
val body = json.encodeToString(ContactRequest(name = "Gwen", email = "g@x.c", message = "hi"))
assertTrue(body.contains("\"name\":\"Gwen\""))
assertTrue(body.contains("\"email\":\"g@x.c\""))
assertTrue(body.contains("\"message\":\"hi\""))
}
}

View File

@@ -3,6 +3,7 @@
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -58,6 +59,15 @@ class NotificationsDtoTest {
assertEquals(listOf("news.post", "champ.start"), dto.streams)
}
@Test fun emptySubscriptionsStillSerializeStreamsField() {
// Regression: clearing the LAST subscription sends an empty set. The backend
// validator requires `streams`, so it must be present as `[]`, not omitted.
// Uses the production Json config (no encodeDefaults) to prove the field is
// always emitted because the DTO field has no default.
val body = json.encodeToString(NotificationSubscriptionsDto(emptyList()))
assertEquals("""{"streams":[]}""", body)
}
@Test fun settingsPushBlockDecodes() {
val dto = json.decodeFromString<SettingsDto>(
"""{"site_title":"Shard","brand":{"name":"Shard"},"push":{"ntfyUrl":"https://ntfy.shard.tld"}}""",

View File

@@ -0,0 +1,115 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Decode/encode tests for the player self-service game-data DTOs
* (`/player/shard/…`): account linking, roster, the full character sheet, vendors,
* sales, and own-houses. Only the fields the text-only v1 renders are asserted.
*/
class PlayerGameDataDtoTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
@Test fun linkRequestAndResultRoundTrip() {
assertTrue(json.encodeToString(ShardLinkRequest("ABC123")).contains("\"code\":\"ABC123\""))
val result = json.decodeFromString<ShardLinkResultDto>("""{"linked":true,"account":"acct1"}""")
assertTrue(result.linked)
assertEquals("acct1", result.account)
assertTrue(json.encodeToString(CreateGameAccountRequest("acct1", "pw")).contains("\"account\":\"acct1\""))
}
@Test fun linkedAccountDecodes() {
val dto = json.decodeFromString<ShardLinkDto>(
"""{"account":"acct1","userId":42,"charName":"Gwen","linkedAt":"2026-07-20T00:00:00Z"}""",
)
assertEquals("acct1", dto.account)
assertEquals(42L, dto.userId)
}
@Test fun rosterDecodesCharacters() {
val dto = json.decodeFromString<RosterDto>(
"""{"acct":"acct1","chars":[
{"slot":0,"serial":"0x24C","name":"Gwen","body":401,"online":true},
{"slot":1,"serial":"0x24D","name":"Alt","online":false}]}""",
)
assertEquals(2, dto.chars.size)
assertTrue(dto.chars[0].online)
assertEquals("0x24C", dto.chars[0].serial)
}
@Test fun charSheetDecodesStatsSkillsEquipmentTitlesGuild() {
val dto = json.decodeFromString<CharProfileDto>(
"""{
"serial":"0x24C","name":"Gwen","title":"the Brave","online":true,"acct":"acct1",
"stats":{"str":100,"dex":90,"int":80,"hits":95,"hitsMax":100,
"resist":{"phys":70,"fire":50,"cold":45,"pois":40,"energy":35}},
"skills":[{"n":"Swords","base":100.0,"value":110.0,"cap":120.0}],
"equipment":[{"serial":"0x9","layer":"OneHanded","itemId":5044,"hue":0}],
"titles":{"selected":0,"reward":["1049643"],"fameKarma":"Glorious"},
"guild":{"name":"Knights","abbr":"KoT"},
"governorOf":["Britain"]
}""",
)
assertEquals("Gwen", dto.name)
assertEquals(100, dto.stats!!.str)
assertEquals(70, dto.stats!!.resist!!.phys)
assertEquals(110.0, dto.skills.first().value!!, 0.0)
assertEquals("OneHanded", dto.equipment.first().layer)
assertEquals("Glorious", dto.titles!!.fameKarma)
assertEquals("Knights", dto.guild!!.name)
assertEquals(listOf("Britain"), dto.governorOf)
}
@Test fun charSheetToleratesMinimalPayload() {
val dto = json.decodeFromString<CharProfileDto>("""{"serial":"0x1","name":"Bare"}""")
assertEquals(null, dto.stats)
assertTrue(dto.skills.isEmpty())
assertTrue(dto.equipment.isEmpty())
assertFalse(dto.online)
}
@Test fun vendorSnapshotAndListingsDecode() {
val dto = json.decodeFromString<VendorSnapshotDto>(
"""{"acct":"acct1","vendors":[
{"serial":"0x9","shopName":"Wares","holdGold":5000,"map":"Felucca","x":1,"y":2,
"listings":[{"serial":"0xA","itemId":3862,"amount":5,"price":250,"forSale":true}]}]}""",
)
val vendor = dto.vendors.first()
assertEquals("Wares", vendor.shopName)
assertEquals(5000L, vendor.holdGold)
val listing = vendor.listings.first()
assertEquals(250L, listing.price)
assertTrue(listing.forSale)
}
@Test fun vendorSaleDecodes() {
val dto = json.decodeFromString<VendorSaleDto>(
"""{"t":1700000000000,"itemType":"katana","amount":1,"price":1000,"commission":50,"ownerAcct":"acct1"}""",
)
assertEquals(1000L, dto.price)
assertEquals(50, dto.commission)
}
@Test fun playerHouseDecodesDecayFields() {
val dto = json.decodeFromString<PlayerHouseDto>(
"""{"serial":"0x40001","stage":"LikeNew","region":"Britain","name":"Keep",
"isIdoc":false,"builtOn":"2026-01-01T00:00:00Z","lastRefreshed":"2026-07-22T00:00:00Z"}""",
)
assertEquals("LikeNew", dto.stage)
assertEquals("Keep", dto.name)
assertFalse(dto.isIdoc)
}
}

View File

@@ -7,6 +7,7 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -99,4 +100,57 @@ class PlayerShardDtoTest {
assertTrue(dto.linked)
assertEquals("whitlocktech", dto.account)
}
// ── Protocol 3.0 additions to char.profile ───────────────────────────
@Test fun charProfileDecodesThePointsBlock() {
// Shaped like a real shard's reply: an uncapped board (maxPoints 0), a
// cliloc-named board (nameString null), and no `rank` unless opted in.
val dto = json.decodeFromString<CharProfileDto>(
"""{"serial":"0x24C","name":"Darrow",
"points":[{"system":"QueensLoyalty","nameString":"Queen's Loyalty",
"points":29500,"maxPoints":30000,"rank":3},
{"system":"VoidPool","nameString":null,"points":180,"maxPoints":0}]}""",
)
assertEquals(2, dto.points.size)
val queens = dto.points[0]
assertEquals("Queen's Loyalty", queens.nameString)
assertEquals(29500L, queens.points)
assertEquals(30000L, queens.cap)
assertEquals(3, queens.rank)
val voidPool = dto.points[1]
assertNull("maxPoints 0 means uncapped, not a zero cap", voidPool.cap)
assertNull("rank is absent unless the shard opts in", voidPool.rank)
assertNull(voidPool.nameString)
}
@Test fun charProfileWithoutAPointsBlockDecodesToEmpty() {
// A shard plugin that predates Protocol 3.0 sends no `points` key at all.
val dto = json.decodeFromString<CharProfileDto>("""{"serial":"0x24C","name":"Darrow"}""")
assertEquals(emptyList<CharPointsDto>(), dto.points)
}
@Test fun equipmentDecodesTheServerResolvedClilocName() {
val dto = json.decodeFromString<CharProfileDto>(
"""{"serial":"0x24C",
"equipment":[{"serial":"0x40","layer":"OneHanded","itemId":5040,"cliloc":1023721,
"clilocName":"hatchet"},
{"serial":"0x41","layer":"Shirt","name":"Bob's lucky shirt",
"clilocName":"fancy shirt"}]}""",
)
assertEquals("hatchet", dto.equipment[0].label)
assertEquals("Bob's lucky shirt", dto.equipment[1].label)
}
@Test fun titlesDecodeTheParallelResolvedArrayIncludingItsNulls() {
// rewardResolved carries a null where the cliloc table had nothing; the array
// must stay positionally aligned with `reward`.
val dto = json.decodeFromString<TitlesDto>(
"""{"selected":1,"reward":["1049565","1049566"],
"rewardResolved":[null,"Knight of Trinsic"]}""",
)
assertEquals(listOf("1049565", "1049566"), dto.reward)
assertEquals(listOf(null, "Knight of Trinsic"), dto.rewardResolved)
}
}

View File

@@ -0,0 +1,73 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Decode tests for the public site/identity DTOs (`/public/status`,
* `/public/settings`). Covers the `StatusDto.isMaintenance` derivation, nested
* branding/registration/push blocks, and the additive-field tolerance.
*/
class PublicDtoTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
@Test fun statusDecodesVersionAndMaintenanceFlag() {
val dto = json.decodeFromString<StatusDto>(
"""{"mode":"MAINTENANCE","status_message":"back soon",
"version":{"service":"web","api":"v1","server":"1.2.3"}}""",
)
assertTrue(dto.isMaintenance) // case-insensitive
assertEquals("back soon", dto.statusMessage)
assertEquals("1.2.3", dto.version.server)
}
@Test fun liveStatusIsNotMaintenance() {
assertFalse(json.decodeFromString<StatusDto>("""{"mode":"live"}""").isMaintenance)
}
@Test fun statusDefaultsWhenEmpty() {
val dto = json.decodeFromString<StatusDto>("{}")
assertEquals("live", dto.mode)
assertFalse(dto.isMaintenance)
assertEquals("", dto.version.api)
}
@Test fun settingsDecodesBrandRegistrationAndPush() {
val dto = json.decodeFromString<SettingsDto>(
"""{
"site_title":"UOMysticmoon","status_message":"welcome",
"registration":{"password":true,"sso":false},
"gameAccountSignup":true,
"brand":{"name":"UOMysticmoon","shortName":"UOM","accent":"#7f99bd",
"logo":"/logo.png","hero":"/hero.png","contactEmail":"a@b.c","url":"https://x"},
"push":{"ntfyUrl":"https://ntfy.example.com"}
}""",
)
assertEquals("UOMysticmoon", dto.siteTitle)
assertTrue(dto.registration.password)
assertFalse(dto.registration.sso)
assertTrue(dto.gameAccountSignup)
assertEquals("#7f99bd", dto.brand.accent)
assertEquals("/logo.png", dto.brand.logo)
assertEquals("https://ntfy.example.com", dto.push.ntfyUrl)
}
@Test fun settingsDefaultsOnOlderBackend() {
// A backend that predates push/branding: nested blocks fall back to defaults.
val dto = json.decodeFromString<SettingsDto>("""{"site_title":"Bare"}""")
assertFalse(dto.registration.password)
assertEquals("", dto.brand.name)
assertEquals(null, dto.push.ntfyUrl)
}
}

View File

@@ -0,0 +1,104 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Decode tests for the public shard board DTOs (`/public/shard/…`), covering the
* computed helpers ([ActorDto.label], [ShardStatusDto.isOnline]) and the
* permissive board payloads (champ/guild/governor/house/presence).
*/
class ShardBoardDtoTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
@Test fun actorLabelPrefersNameThenAcctThenFallback() {
assertEquals("Gwen", json.decodeFromString<ActorDto>("""{"name":"Gwen","acct":"g01"}""").label)
assertEquals("g01", json.decodeFromString<ActorDto>("""{"acct":"g01"}""").label)
assertEquals("Someone", json.decodeFromString<ActorDto>("{}").label)
}
@Test fun shardStatusIsOnlineOnlyWhenEnabledAndPluginConnected() {
val online = json.decodeFromString<ShardStatusDto>(
"""{"enabled":true,"pluginConnected":true,"onlineCount":42,
"economy":{"accounts":10,"gold":123456.0,"t":1000}}""",
)
assertTrue(online.isOnline)
assertEquals(42, online.onlineCount)
assertEquals(123456.0, online.economy!!.gold!!, 0.0)
assertFalse(json.decodeFromString<ShardStatusDto>("""{"enabled":true,"pluginConnected":false}""").isOnline)
assertFalse(json.decodeFromString<ShardStatusDto>("{}").isOnline)
}
@Test fun champDecodesBossAndProgressFields() {
val dto = json.decodeFromString<ChampDto>(
"""{"serial":"0x1","category":"champion","name":"Rikktor","active":true,
"bossUp":true,"boss":"Rikktor","level":3,"maxLevel":16,"kills":10,"maxKills":100,
"hits":5000,"hitsMax":9000,"map":"Felucca","x":1,"y":2,"z":0}""",
)
assertTrue(dto.active)
assertTrue(dto.bossUp)
assertEquals(16, dto.maxLevel)
assertEquals(5000L, dto.hits)
}
@Test fun guildDecodesLeaderActor() {
val dto = json.decodeFromString<GuildDto>(
"""{"id":7,"name":"Knights","abbr":"KoT","members":12,"online":3,
"leader":{"name":"Arthur","webId":"9931"}}""",
)
assertEquals("Knights", dto.name)
assertEquals("Arthur", dto.leader!!.label)
assertEquals("9931", dto.leader!!.webId)
}
@Test fun governorAndTermDecode() {
val gov = json.decodeFromString<GovernorDto>(
"""{"city":"Britain","governor":{"name":"Dawn"},"electionPhase":"campaign"}""",
)
assertEquals("Britain", gov.city)
assertEquals("Dawn", gov.governor!!.label)
val term = json.decodeFromString<GovernorTermDto>(
"""{"city":"Britain","governor":{"name":"Dawn"},"startedAt":1000,"endedAt":2000,"votes":50}""",
)
assertEquals(50, term.votes)
assertEquals(2000L, term.endedAt)
}
@Test fun houseAndPresenceAndStaffDecode() {
val house = json.decodeFromString<HouseDto>(
"""{"serial":"0x40","name":"Tower","region":"Britain","isIdoc":true,"x":5,"y":6}""",
)
assertTrue(house.isIdoc)
assertEquals("Tower", house.name)
val presence = json.decodeFromString<PresenceDto>(
"""{"count":30,"byFacet":{"Felucca":10,"Trammel":20},"byRegion":{"Britain":5}}""",
)
assertEquals(30, presence.count)
assertEquals(10, presence.byFacet["Felucca"])
val staff = json.decodeFromString<OnlineStaffDto>("""{"serial":"0x2","name":"GM Bob","map":"Felucca","x":1,"y":2,"z":0}""")
assertEquals("GM Bob", staff.name)
}
@Test fun feedEventDecodesPayloadObject() {
val ev = json.decodeFromString<FeedEventDto>(
"""{"id":9,"kind":"champ.spawn","t":1234,"payload":{"name":"Rikktor"},"createdAt":"2026-07-22T00:00:00Z"}""",
)
assertEquals("champ.spawn", ev.kind)
assertTrue(ev.payload!!.containsKey("name"))
}
}

View File

@@ -111,4 +111,30 @@ class ShardDtoTest {
assertEquals("bob", ActorDto(acct = "bob").label)
assertEquals("Someone", ActorDto().label)
}
@Test fun actorArrivesWithoutAcctOrWebIdBelowTheAdminRung() {
// Those two fields are locked to `admin` by the visibility framework and are
// stripped from every response below it — the app must decode their absence,
// not depend on them (docs/link/v3.md §3.4 rule 1).
val dto = json.decodeFromString<ActorDto>("""{"serial":"0x24C","name":"Darrow","player":true}""")
assertEquals("Darrow", dto.label)
assertNull(dto.acct)
assertNull(dto.webId)
}
@Test fun shardFeaturesDecodesTheRungAndVisibleSet() {
val dto = json.decodeFromString<ShardFeaturesDto>(
"""{"level":"player","features":["status","champs","guilds","market"]}""",
)
assertEquals("player", dto.level)
assertTrue(dto.features.contains("market"))
assertEquals(4, dto.features.size)
}
@Test fun shardFeaturesDecodesAnEmptySet() {
// A fully-gated shard: every feature switched off for this viewer. Distinct
// from the lookup failing, which the repository represents as null.
val dto = json.decodeFromString<ShardFeaturesDto>("""{"level":"anonymous","features":[]}""")
assertEquals(emptyList<String>(), dto.features)
}
}

View File

@@ -0,0 +1,46 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Decode/encode tests for the Mobile SSO bridge DTOs (PLAN.md §4.2). Provider
* discovery is public (never secrets); the exchange body uses snake_case
* `code_verifier` to match the backend.
*/
class SsoDtoTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
@Test fun providerDecodesWithOptionalFields() {
val dto = json.decodeFromString<SsoProviderDto>(
"""{"id":"discord","name":"Discord","icon":"discord","loginUrl":"/auth/discord","priority":2}""",
)
assertEquals("discord", dto.id)
assertEquals("Discord", dto.name)
assertEquals(2, dto.priority)
}
@Test fun providerToleratesMissingOptionals() {
val dto = json.decodeFromString<SsoProviderDto>("""{"id":"oidc","name":"Corp SSO"}""")
assertNull(dto.icon)
assertNull(dto.priority)
}
@Test fun exchangeRequestEncodesSnakeCaseVerifier() {
val body = json.encodeToString(MobileSsoExchangeRequest(code = "abc123", codeVerifier = "v-e-r-i-f-i-e-r"))
assertTrue(body.contains("\"code\":\"abc123\""))
assertTrue(body.contains("\"code_verifier\":\"v-e-r-i-f-i-e-r\""))
}
}

View File

@@ -0,0 +1,66 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Decode tests for the wiki DTOs (`/public/wiki*`). Summary rows omit the body;
* the detail page carries tags, backlinks, and unresolved ("red") link targets.
*/
class WikiDtoTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
@Test fun summaryRowDecodesWithCategory() {
val dto = json.decodeFromString<WikiSummaryDto>(
"""{"id":5,"slug":"pvp","title":"PvP","excerpt":"combat",
"category_slug":"systems","category_title":"Systems","updated_at":"2026-07-20T00:00:00Z"}""",
)
assertEquals(5L, dto.id)
assertEquals("systems", dto.categorySlug)
assertEquals("Systems", dto.categoryTitle)
assertEquals("combat", dto.excerpt)
}
@Test fun pageDecodesTagsBacklinksAndMissingLinks() {
val dto = json.decodeFromString<WikiPageDto>(
"""{
"id":9,"slug":"housing","title":"Housing","body":"<p>text</p>",
"category_slug":"systems","category_title":"Systems",
"tags":[{"slug":"idoc","label":"IDOC"}],
"backlinks":[{"slug":"pvp","title":"PvP"}],
"missing_links":["nonexistent-page"]
}""",
)
assertEquals("<p>text</p>", dto.body)
assertEquals(1, dto.tags.size)
assertEquals("IDOC", dto.tags[0].label)
assertEquals("pvp", dto.backlinks[0].slug)
assertEquals(listOf("nonexistent-page"), dto.missingLinks)
}
@Test fun pageDefaultsCollectionsWhenAbsent() {
val dto = json.decodeFromString<WikiPageDto>("""{"id":1,"slug":"x","title":"X"}""")
assertTrue(dto.tags.isEmpty())
assertTrue(dto.backlinks.isEmpty())
assertTrue(dto.missingLinks.isEmpty())
}
@Test fun categoryAndTagDecodePublishedCounts() {
val cat = json.decodeFromString<WikiCategoryDto>(
"""{"id":2,"slug":"systems","title":"Systems","description":"d","published_count":12}""",
)
assertEquals(12L, cat.publishedCount)
val tag = json.decodeFromString<WikiTagDto>("""{"id":4,"slug":"idoc","label":"IDOC","published_count":3}""")
assertEquals(3L, tag.publishedCount)
}
}

View File

@@ -0,0 +1,88 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.fake
import com.runicgateway.app.data.api.AdminApi
import com.runicgateway.app.data.api.dto.AdminDashboardDto
import com.runicgateway.app.data.api.dto.AdminPostDto
import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto
import com.runicgateway.app.data.api.dto.AdminWikiTagDto
import com.runicgateway.app.data.api.dto.BanRequest
import com.runicgateway.app.data.api.dto.BroadcastRequest
import com.runicgateway.app.data.api.dto.KickRequest
import com.runicgateway.app.data.api.dto.PageRespondRequest
import com.runicgateway.app.data.api.dto.PostCreateRequest
import com.runicgateway.app.data.api.dto.PublishRequest
import com.runicgateway.app.data.api.dto.SiteModeRequest
import com.runicgateway.app.data.api.dto.SiteModeStateDto
import com.runicgateway.app.data.api.dto.SupportPageDto
import com.runicgateway.app.data.api.dto.UnbanRequest
import com.runicgateway.app.data.api.dto.WikiCategoryRequest
import com.runicgateway.app.util.okUnit
import retrofit2.Response
/**
* A configurable fake of [AdminApi] for the staff-ops repository/ViewModel tests.
* Read endpoints return their `var`; write endpoints returning `Response<Unit>`
* return [unitResponse] (default 200) so a test can drive the 200 / 403 / 503 copy
* branches. Set [error] to throw from every call (network / decode failure paths).
*/
class FakeAdminApi : AdminApi {
var error: Throwable? = null
var dashboard: AdminDashboardDto = AdminDashboardDto()
var siteMode: SiteModeStateDto = SiteModeStateDto()
var posts: List<AdminPostDto> = emptyList()
var createdPost: AdminPostDto = AdminPostDto(id = 0)
var publishedPost: AdminPostDto = AdminPostDto(id = 0)
var wikiCategories: List<AdminWikiCategoryDto> = emptyList()
var createdCategory: AdminWikiCategoryDto = AdminWikiCategoryDto(id = 0)
var wikiTags: List<AdminWikiTagDto> = emptyList()
var supportPages: List<SupportPageDto> = emptyList()
/** Response returned by the bodyless write endpoints (kick/ban/delete/respond/…). */
var unitResponse: Response<Unit> = okUnit()
/** Bodies seen by write calls, so a test can assert what was sent. */
var lastPostCreate: PostCreateRequest? = null
var lastBan: BanRequest? = null
var lastRespond: Pair<String, PageRespondRequest>? = null
private fun <T> reply(value: T): T {
error?.let { throw it }
return value
}
override suspend fun dashboard(): AdminDashboardDto = reply(dashboard)
override suspend fun setSiteMode(body: SiteModeRequest): SiteModeStateDto = reply(siteMode)
override suspend fun posts(): List<AdminPostDto> = reply(posts)
override suspend fun createPost(body: PostCreateRequest): AdminPostDto {
lastPostCreate = body
return reply(createdPost)
}
override suspend fun publishPost(id: Long, body: PublishRequest): AdminPostDto = reply(publishedPost)
override suspend fun deletePost(id: Long): Response<Unit> = reply(unitResponse)
override suspend fun wikiCategories(): List<AdminWikiCategoryDto> = reply(wikiCategories)
override suspend fun createWikiCategory(body: WikiCategoryRequest): AdminWikiCategoryDto = reply(createdCategory)
override suspend fun deleteWikiCategory(id: Long): Response<Unit> = reply(unitResponse)
override suspend fun wikiTags(): List<AdminWikiTagDto> = reply(wikiTags)
override suspend fun kick(body: KickRequest): Response<Unit> = reply(unitResponse)
override suspend fun ban(body: BanRequest): Response<Unit> {
lastBan = body
return reply(unitResponse)
}
override suspend fun unban(body: UnbanRequest): Response<Unit> = reply(unitResponse)
override suspend fun broadcast(body: BroadcastRequest): Response<Unit> = reply(unitResponse)
override suspend fun supportPages(): List<SupportPageDto> = reply(supportPages)
override suspend fun respondPage(id: String, body: PageRespondRequest): Response<Unit> {
lastRespond = id to body
return reply(unitResponse)
}
override suspend fun closePage(id: String): Response<Unit> = reply(unitResponse)
}

View File

@@ -0,0 +1,47 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.fake
import com.runicgateway.app.data.api.PlayerShardApi
import com.runicgateway.app.data.api.dto.CharProfileDto
import com.runicgateway.app.data.api.dto.CreateGameAccountRequest
import com.runicgateway.app.data.api.dto.PlayerHouseDto
import com.runicgateway.app.data.api.dto.RosterDto
import com.runicgateway.app.data.api.dto.ShardLinkDto
import com.runicgateway.app.data.api.dto.ShardLinkRequest
import com.runicgateway.app.data.api.dto.ShardLinkResultDto
import com.runicgateway.app.data.api.dto.VendorSaleDto
import com.runicgateway.app.data.api.dto.VendorSnapshotDto
/**
* A configurable fake of [PlayerShardApi] for the player self-service repository /
* ViewModel tests. Set the relevant `var`; set [error] to throw from every call
* (drives the `503 shard offline` / `403 not-linked` / network paths).
*/
class FakePlayerShardApi : PlayerShardApi {
var error: Throwable? = null
var linkResult: ShardLinkResultDto = ShardLinkResultDto()
var accounts: List<ShardLinkDto> = emptyList()
var roster: RosterDto = RosterDto()
var char: CharProfileDto = CharProfileDto()
var vendors: VendorSnapshotDto = VendorSnapshotDto()
var sales: List<VendorSaleDto> = emptyList()
var houses: List<PlayerHouseDto> = emptyList()
private fun <T> reply(value: T): T {
error?.let { throw it }
return value
}
override suspend fun link(body: ShardLinkRequest): ShardLinkResultDto = reply(linkResult)
override suspend fun createAccount(body: CreateGameAccountRequest): ShardLinkResultDto = reply(linkResult)
override suspend fun accounts(): List<ShardLinkDto> = reply(accounts)
override suspend fun roster(account: String): RosterDto = reply(roster)
override suspend fun char(serial: String): CharProfileDto = reply(char)
override suspend fun vendors(account: String): VendorSnapshotDto = reply(vendors)
override suspend fun sales(): List<VendorSaleDto> = reply(sales)
override suspend fun houses(): List<PlayerHouseDto> = reply(houses)
}

View File

@@ -0,0 +1,101 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.fake
import com.runicgateway.app.data.api.PublicApi
import com.runicgateway.app.data.api.dto.ChampDto
import com.runicgateway.app.data.api.dto.ContactRequest
import com.runicgateway.app.data.api.dto.ContactResponse
import com.runicgateway.app.data.api.dto.EconomySampleDto
import com.runicgateway.app.data.api.dto.FeedEventDto
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.OnlineStaffDto
import com.runicgateway.app.data.api.dto.PageDto
import com.runicgateway.app.data.api.dto.PostDto
import com.runicgateway.app.data.api.dto.PresenceDto
import com.runicgateway.app.data.api.dto.SettingsDto
import com.runicgateway.app.data.api.dto.ShardFeaturesDto
import com.runicgateway.app.data.api.dto.ShardStatusDto
import com.runicgateway.app.data.api.dto.StatusDto
import com.runicgateway.app.data.api.dto.WikiCategoryDto
import com.runicgateway.app.data.api.dto.WikiPageDto
import com.runicgateway.app.data.api.dto.WikiSummaryDto
import com.runicgateway.app.data.api.dto.WikiTagDto
/**
* A configurable fake of [PublicApi] for repository/ViewModel tests. Set the
* relevant `var` to the body a call should return; set [error] to make every call
* throw (drives the `ApiResult.HttpError` / `NetworkError` paths). Defaults are
* empty/neutral so a call an assertion doesn't care about never crashes.
*/
class FakePublicApi : PublicApi {
/** When non-null, every call throws this (use `httpError(code)` or an IOException). */
var error: Throwable? = null
var status: StatusDto = StatusDto()
var settings: SettingsDto = SettingsDto()
var posts: List<PostDto> = emptyList()
var post: PostDto = PostDto(id = 0)
var page: PageDto = PageDto(id = 0)
var wikiPages: List<WikiSummaryDto> = emptyList()
var wikiCategories: List<WikiCategoryDto> = emptyList()
var wikiTags: List<WikiTagDto> = emptyList()
var wikiPage: WikiPageDto = WikiPageDto(id = 0)
var contactResponse: ContactResponse = ContactResponse(sent = true)
var shardStatus: ShardStatusDto = ShardStatusDto()
var shardFeed: List<FeedEventDto> = emptyList()
var shardEconomy: List<EconomySampleDto> = emptyList()
var shardOnline: List<OnlineStaffDto> = emptyList()
var shardPresence: PresenceDto = PresenceDto()
var champs: List<ChampDto> = emptyList()
var guilds: List<GuildDto> = emptyList()
var governors: List<GovernorDto> = emptyList()
var governorHistory: List<GovernorTermDto> = emptyList()
var houses: List<HouseDto> = emptyList()
var shardFeatures: ShardFeaturesDto = ShardFeaturesDto()
/** Last contact request body seen (so a test can assert it was trimmed/forwarded). */
var lastContact: ContactRequest? = null
private fun <T> reply(value: T): T {
error?.let { throw it }
return value
}
override suspend fun probeStatus(absoluteStatusUrl: String): StatusDto = reply(status)
override suspend fun getStatus(): StatusDto = reply(status)
override suspend fun getSettings(): SettingsDto = reply(settings)
override suspend fun getPosts(category: String): List<PostDto> = reply(posts)
override suspend fun getPost(category: String, idOrSlug: String): PostDto = reply(post)
override suspend fun getPage(slug: String): PageDto = reply(page)
override suspend fun getWikiPages(query: String?, category: String?, tag: String?): List<WikiSummaryDto> =
reply(wikiPages)
override suspend fun getWikiCategories(): List<WikiCategoryDto> = reply(wikiCategories)
override suspend fun getWikiTags(): List<WikiTagDto> = reply(wikiTags)
override suspend fun getWikiPage(slug: String): WikiPageDto = reply(wikiPage)
override suspend fun postContact(body: ContactRequest): ContactResponse {
lastContact = body
return reply(contactResponse)
}
override suspend fun getShardFeatures(): ShardFeaturesDto = reply(shardFeatures)
override suspend fun getShardStatus(): ShardStatusDto = reply(shardStatus)
override suspend fun getShardFeed(kind: String?, limit: Int?): List<FeedEventDto> = reply(shardFeed)
override suspend fun getShardEconomy(limit: Int?): List<EconomySampleDto> = reply(shardEconomy)
override suspend fun getShardOnline(): List<OnlineStaffDto> = reply(shardOnline)
override suspend fun getShardPresence(): PresenceDto = reply(shardPresence)
override suspend fun getShardChamps(): List<ChampDto> = reply(champs)
override suspend fun getShardGuilds(): List<GuildDto> = reply(guilds)
override suspend fun getShardGovernors(): List<GovernorDto> = reply(governors)
override suspend fun getShardGovernorHistory(city: String, limit: Int?): List<GovernorTermDto> =
reply(governorHistory)
override suspend fun getShardHouses(): List<HouseDto> = reply(houses)
}

View File

@@ -0,0 +1,19 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.fake
import com.runicgateway.app.core.net.ShardStream
import com.runicgateway.app.core.net.ShardStreamEvent
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
/**
* A finite fake of the live [ShardStream] for board-ViewModel tests: it emits the
* given [events] once and completes, so the ViewModel's `collectLive()` finishes
* immediately (no perpetual reconnect loop) and any live-frame handling it triggers
* is exercised deterministically.
*/
class FakeShardStream(private val events: List<ShardStreamEvent> = emptyList()) : ShardStream {
override fun events(): Flow<ShardStreamEvent> = flowOf(*events.toTypedArray())
}

View File

@@ -0,0 +1,104 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.repository
import com.runicgateway.app.data.api.dto.ShardFeaturesDto
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.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.IOException
/**
* The shard-visibility lookup (PLAN.md §9 M11). The behavior worth pinning is the
* FAIL-OPEN direction: an unknown answer must show every entry, because the server
* gates every call regardless and the alternative is a menu that flickers in.
*/
class ShardFeaturesRepositoryTest {
private val api = FakePublicApi()
private val repository = ShardFeaturesRepository(api)
@Test fun refreshPublishesTheVisibleSetAndTheServersRung() = runTest {
api.shardFeatures = ShardFeaturesDto(
level = "player",
features = listOf("status", "champs", "market"),
)
repository.refresh()
val features = repository.features.value
assertEquals("player", features?.level)
assertEquals(setOf("status", "champs", "market"), features?.visible)
}
@Test fun aFeatureTheServerOmittedIsNotVisible() = runTest {
api.shardFeatures = ShardFeaturesDto(level = "anonymous", features = listOf("status"))
repository.refresh()
assertTrue(canSee(repository.features.value, ShardFeature.STATUS))
assertFalse(canSee(repository.features.value, ShardFeature.MARKET))
}
@Test fun aFailedLookupFallsBackToUnknownRatherThanEmpty() = runTest {
// Empty and unknown are opposite answers: empty hides everything, unknown
// shows everything. A failure must never be read as "this shard publishes
// nothing".
api.error = IOException("offline")
repository.refresh()
assertNull(repository.features.value)
assertTrue(canSee(repository.features.value, ShardFeature.MARKET))
}
@Test fun aPreProtocol3WebsiteIs404AndReadsAsUnknown() = runTest {
// The route does not exist before Protocol 3.0. That site has no visibility
// framework at all, so "unknown" is exactly right and the menu behaves as it
// did before M11.
api.error = httpError(404)
repository.refresh()
assertNull(repository.features.value)
assertTrue(canSee(repository.features.value, ShardFeature.CHAMPS))
}
@Test fun aFailedRefreshClearsAPreviouslyGoodAnswer() = runTest {
api.shardFeatures = ShardFeaturesDto(level = "admin", features = listOf("status"))
repository.refresh()
assertEquals(setOf("status"), repository.features.value?.visible)
// Signing out and failing to re-resolve must not leave the previous viewer's
// (possibly wider) answer in place.
api.error = httpError(500)
repository.refresh()
assertNull(repository.features.value)
}
@Test fun invalidateDropsTheCachedAnswer() = runTest {
api.shardFeatures = ShardFeaturesDto(level = "staff", features = listOf("houses"))
repository.refresh()
assertEquals("staff", repository.features.value?.level)
// A Settings → Server switch: the answer belonged to the old host.
repository.invalidate()
assertNull(repository.features.value)
}
@Test fun canSeeTreatsUnknownAsVisibleAndEmptyAsHidden() {
assertTrue("unknown must fail open", canSee(null, ShardFeature.RULESET))
assertFalse(
"an explicit empty set hides everything",
canSee(ShardFeatures(level = "anonymous", visible = emptySet()), ShardFeature.RULESET),
)
}
}

View File

@@ -0,0 +1,120 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui
import androidx.lifecycle.SavedStateHandle
import com.runicgateway.app.data.api.dto.PageDto
import com.runicgateway.app.data.api.dto.PostDto
import com.runicgateway.app.data.api.dto.StatusDto
import com.runicgateway.app.data.api.dto.WikiPageDto
import com.runicgateway.app.data.api.dto.WikiSummaryDto
import com.runicgateway.app.data.api.fake.FakePublicApi
import com.runicgateway.app.data.repository.ContentRepository
import com.runicgateway.app.data.repository.SettingsRepository
import com.runicgateway.app.data.repository.WikiRepository
import com.runicgateway.app.ui.home.HomeViewModel
import com.runicgateway.app.ui.navigation.Routes
import com.runicgateway.app.ui.news.NewsViewModel
import com.runicgateway.app.ui.news.PostViewModel
import com.runicgateway.app.ui.page.PageViewModel
import com.runicgateway.app.ui.wiki.WikiPageViewModel
import com.runicgateway.app.ui.wiki.WikiViewModel
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.httpError
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
/** ViewModels over the public content APIs (news, CMS pages, wiki, home status). */
class ContentViewModelTest {
@get:Rule val mainDispatcher = MainDispatcherRule()
private val api = FakePublicApi()
private val content = ContentRepository(api)
private val wiki = WikiRepository(api)
private val settings = SettingsRepository(api)
// ── News hub ──────────────────────────────────────────────────────────
@Test fun newsLoadsSelectedCategory() {
api.posts = listOf(PostDto(id = 1, category = "news", title = "Hi"))
val vm = NewsViewModel(content)
assertTrue(vm.state.value is UiState.Success)
assertEquals(1, (vm.state.value as UiState.Success).data.size)
}
@Test fun newsSelectCategoryReloads() {
val vm = NewsViewModel(content)
api.posts = listOf(PostDto(id = 2, category = "newsletter", title = "N"))
vm.selectCategory(ContentRepository.PostCategory.NEWSLETTER)
assertEquals(ContentRepository.PostCategory.NEWSLETTER, vm.category.value)
assertEquals(1, (vm.state.value as UiState.Success).data.size)
}
@Test fun newsServerErrorIsUiError() {
api.error = httpError(500)
assertTrue(NewsViewModel(content).state.value is UiState.Error)
}
// ── Post detail (SavedStateHandle args) ─────────────────────────────────
@Test fun postDetailLoadsForKnownCategory() {
api.post = PostDto(id = 7, category = "news", title = "Update", body = "<p>x</p>")
val handle = SavedStateHandle(
mapOf(Routes.Args.CATEGORY to "news", Routes.Args.ID_OR_SLUG to "update"),
)
val vm = PostViewModel(content, handle)
assertEquals("Update", (vm.state.value as UiState.Success).data.title)
}
@Test fun postDetailUnknownCategoryIsNotFoundWithoutApiCall() {
val handle = SavedStateHandle(
mapOf(Routes.Args.CATEGORY to "bogus", Routes.Args.ID_OR_SLUG to "x"),
)
val state = PostViewModel(content, handle).state.value
assertTrue(state is UiState.Error)
assertEquals(ErrorKind.NOT_FOUND, (state as UiState.Error).kind)
}
// ── CMS page ────────────────────────────────────────────────────────────
@Test fun pageLoadsBySlug() {
api.page = PageDto(id = 3, slug = "about", title = "About")
val vm = PageViewModel(content, SavedStateHandle(mapOf(Routes.Args.SLUG to "about")))
assertEquals("About", (vm.state.value as UiState.Success).data.title)
}
@Test fun pageNotFoundIsUiError() {
api.error = httpError(404)
val vm = PageViewModel(content, SavedStateHandle(mapOf(Routes.Args.SLUG to "missing")))
assertEquals(ErrorKind.NOT_FOUND, (vm.state.value as UiState.Error).kind)
}
// ── Wiki index + detail ─────────────────────────────────────────────────
@Test fun wikiIndexLoadsAndTracksQuery() {
api.wikiPages = listOf(WikiSummaryDto(id = 1, slug = "pvp", title = "PvP"))
val vm = WikiViewModel(wiki)
assertTrue(vm.state.value is UiState.Success)
vm.onQueryChange("housing")
assertEquals("housing", vm.query.value)
}
@Test fun wikiPageLoadsBySlug() {
api.wikiPage = WikiPageDto(id = 9, slug = "housing", title = "Housing", body = "b")
val vm = WikiPageViewModel(wiki, SavedStateHandle(mapOf(Routes.Args.SLUG to "housing")))
assertEquals("Housing", (vm.state.value as UiState.Success).data.title)
}
// ── Home status ─────────────────────────────────────────────────────────
@Test fun homeLoadsStatus() {
api.status = StatusDto(mode = "maintenance")
val vm = HomeViewModel(settings)
assertTrue((vm.state.value as UiState.Success).data.isMaintenance)
}
@Test fun homeNetworkErrorIsUiError() {
api.error = java.io.IOException("offline")
val state = HomeViewModel(settings).state.value
assertEquals(ErrorKind.NETWORK, (state as UiState.Error).kind)
}
}

View File

@@ -4,7 +4,9 @@
package com.runicgateway.app.ui
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.ui.components.isRetryable
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.IOException
@@ -34,6 +36,44 @@ class UiStateTest {
assertTrue(ApiResult.HttpError(503).let { it.status == 503 })
}
// ── Shard reads: 404/403 mean "this shard doesn't publish it" (M11) ──
@Test fun shardReadsTreat404And403AsFeatureUnavailable() {
// requireFeature answers 404 for a disabled feature (deliberately not
// disclosing that it exists) and 403 for a viewer below its audience rung.
assertEquals(ErrorKind.FEATURE_UNAVAILABLE, shardKindOf(404))
assertEquals(ErrorKind.FEATURE_UNAVAILABLE, shardKindOf(403))
}
@Test fun shardReadsLeaveEveryOtherStatusAlone() {
assertEquals(ErrorKind.SHARD_OFFLINE, shardKindOf(503))
assertEquals(ErrorKind.RATE_LIMITED, shardKindOf(429))
assertEquals(ErrorKind.SERVER, shardKindOf(500))
assertEquals(
ErrorKind.NETWORK,
(ApiResult.NetworkError(IOException()).toShardUiState() as UiState.Error).kind,
)
assertEquals(UiState.Success("hi"), ApiResult.Ok("hi").toShardUiState())
}
@Test fun nonShardReadsKeep404AsNotFound() {
// The remap is scoped to shard routes on purpose: off them, a 404 is still a
// deleted post or an unknown wiki slug.
assertEquals(ErrorKind.NOT_FOUND, kindOf(404))
}
@Test fun anUnavailableFeatureIsNotRetryable() {
// An admin controls this, so a retry button would read as a transient failure
// the user could wait out.
assertFalse(isRetryable(ErrorKind.FEATURE_UNAVAILABLE))
for (kind in ErrorKind.entries.filter { it != ErrorKind.FEATURE_UNAVAILABLE }) {
assertTrue("$kind should offer a retry", isRetryable(kind))
}
}
private fun kindOf(status: Int): ErrorKind =
(ApiResult.HttpError(status).toUiState() as UiState.Error).kind
private fun shardKindOf(status: Int): ErrorKind =
(ApiResult.HttpError(status).toShardUiState() as UiState.Error).kind
}

View File

@@ -0,0 +1,78 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.admin
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.AdminPostDto
import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto
import com.runicgateway.app.data.api.dto.AdminWikiTagDto
import com.runicgateway.app.data.api.fake.FakeAdminApi
import com.runicgateway.app.data.repository.AdminRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.errorUnit
import com.runicgateway.app.util.httpError
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
class AdminContentViewModelTest {
@get:Rule val mainDispatcher = MainDispatcherRule()
private val api = FakeAdminApi()
private fun viewModel() = AdminContentViewModel(AdminRepository(api))
@Test fun loadsPostsAndWikiOnInit() {
api.posts = listOf(AdminPostDto(id = 1, title = "A", published = 1))
api.wikiCategories = listOf(AdminWikiCategoryDto(id = 2, slug = "lore", title = "Lore"))
api.wikiTags = listOf(AdminWikiTagDto(id = 3, slug = "pvp", label = "PvP"))
val vm = viewModel()
assertTrue(vm.state.value.posts is UiState.Success)
assertEquals(1, (vm.state.value.posts as UiState.Success).data.size)
assertEquals(1, vm.state.value.tags.size)
}
@Test fun createPostRejectsBlankTitleWithoutCallingApi() {
val vm = viewModel()
vm.createPost(category = "news", title = " ", excerpt = "", body = "", published = false)
assertFalse(vm.state.value.feedback!!.ok)
assertEquals(R.string.admin_content_title_required, vm.state.value.feedback!!.messageRes)
assertEquals(null, api.lastPostCreate) // never reached the API
}
@Test fun createPostTrimsAndNullsBlanksThenReloads() {
val vm = viewModel()
vm.createPost(category = "news", title = " Hello ", excerpt = "", body = "b", published = true)
val sent = api.lastPostCreate!!
assertEquals("Hello", sent.title)
assertEquals(null, sent.excerpt) // blank -> null
assertEquals("b", sent.body)
assertTrue(vm.state.value.feedback!!.ok)
assertFalse(vm.state.value.busy)
}
@Test fun togglePublishForbiddenSurfacesForbiddenCopy() {
api.unitResponse = errorUnit(403)
api.error = httpError(403)
val vm = viewModel()
vm.togglePublish(AdminPostDto(id = 5, title = "x", published = 1))
assertEquals(R.string.admin_forbidden, vm.state.value.feedback!!.messageRes)
}
@Test fun createCategoryRejectsBlankFields() {
val vm = viewModel()
vm.createCategory(slug = "", title = "", description = "", sortOrder = null)
assertEquals(R.string.admin_content_cat_fields_required, vm.state.value.feedback!!.messageRes)
}
@Test fun deletePostNetworkErrorShowsNetworkCopy() {
api.error = java.io.IOException("offline")
val vm = viewModel()
vm.deletePost(9)
assertEquals(R.string.error_network, vm.state.value.feedback!!.messageRes)
}
}

View File

@@ -0,0 +1,70 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.admin
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.AdminCountsDto
import com.runicgateway.app.data.api.dto.AdminDashboardDto
import com.runicgateway.app.data.api.dto.SiteModeStateDto
import com.runicgateway.app.data.api.fake.FakeAdminApi
import com.runicgateway.app.data.repository.AdminRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.httpError
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
class AdminDashboardViewModelTest {
@get:Rule val mainDispatcher = MainDispatcherRule()
private val api = FakeAdminApi()
private fun viewModel() = AdminDashboardViewModel(AdminRepository(api))
@Test fun loadsDashboardOnInit() {
api.dashboard = AdminDashboardDto(siteMode = "live", counts = AdminCountsDto(users = 12))
val vm = viewModel()
val state = vm.state.value.dashboard
assertTrue(state is UiState.Success)
assertEquals(12, (state as UiState.Success).data.counts.users)
}
@Test fun loadSurfacesServerErrorAsUiError() {
api.error = httpError(500)
val vm = viewModel()
assertTrue(vm.state.value.dashboard is UiState.Error)
}
@Test fun setSiteModeSuccessUpdatesModeAndClearsSwitching() {
api.dashboard = AdminDashboardDto(siteMode = "live")
api.siteMode = SiteModeStateDto(siteMode = "maintenance")
val vm = viewModel()
vm.setSiteMode("maintenance")
val s = vm.state.value
assertFalse(s.switching)
assertTrue(s.feedback!!.ok)
assertEquals(R.string.admin_site_mode_updated, s.feedback!!.messageRes)
}
@Test fun setSiteModeForbiddenShowsForbiddenCopy() {
api.dashboard = AdminDashboardDto()
api.error = httpError(403)
val vm = viewModel()
vm.setSiteMode("maintenance")
val fb = vm.state.value.feedback!!
assertFalse(fb.ok)
assertEquals(R.string.admin_forbidden, fb.messageRes)
}
@Test fun clearFeedbackResetsBanner() {
api.error = httpError(500)
val vm = viewModel()
vm.setSiteMode("live")
vm.clearFeedback()
assertEquals(null, vm.state.value.feedback)
}
}

View File

@@ -0,0 +1,63 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.admin
import com.runicgateway.app.R
import com.runicgateway.app.data.api.fake.FakeAdminApi
import com.runicgateway.app.data.repository.AdminRepository
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.errorUnit
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
class AdminModerationViewModelTest {
@get:Rule val mainDispatcher = MainDispatcherRule()
private val api = FakeAdminApi()
private fun viewModel() = AdminModerationViewModel(AdminRepository(api))
@Test fun kickWithNoTargetShowsTargetRequired() {
val vm = viewModel()
vm.kick(account = "", serial = "")
assertEquals(R.string.admin_mod_target_required, vm.state.value.feedback!!.messageRes)
}
@Test fun banForwardsFieldsAndSucceeds() {
val vm = viewModel()
vm.ban(account = "gwen", serial = "", durationSec = 3600, reason = "afk")
assertEquals("gwen", api.lastBan!!.account)
assertNull(api.lastBan!!.serial) // blank -> null
assertEquals(3600L, api.lastBan!!.durationSec)
assertEquals("afk", api.lastBan!!.reason)
val fb = vm.state.value.feedback!!
assertTrue(fb.ok)
assertEquals(R.string.admin_mod_banned, fb.messageRes)
assertFalse(vm.state.value.busy)
}
@Test fun shardOfflineMapsTo503Copy() {
api.unitResponse = errorUnit(503)
val vm = viewModel()
vm.kick(account = "x", serial = "")
assertEquals(R.string.admin_mod_shard_offline, vm.state.value.feedback!!.messageRes)
}
@Test fun broadcastRejectsBlankText() {
val vm = viewModel()
vm.broadcast(text = " ", hue = null)
assertEquals(R.string.admin_mod_text_required, vm.state.value.feedback!!.messageRes)
}
@Test fun unbanForbiddenMapsTo403Copy() {
api.unitResponse = errorUnit(403)
val vm = viewModel()
vm.unban("gwen")
assertEquals(R.string.admin_forbidden, vm.state.value.feedback!!.messageRes)
}
}

View File

@@ -0,0 +1,61 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.admin
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.SupportPageDto
import com.runicgateway.app.data.api.fake.FakeAdminApi
import com.runicgateway.app.data.repository.AdminRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.errorUnit
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
class AdminSupportViewModelTest {
@get:Rule val mainDispatcher = MainDispatcherRule()
private val api = FakeAdminApi()
private fun viewModel() = AdminSupportViewModel(AdminRepository(api))
@Test fun loadsOpenPagesOnInit() {
api.supportPages = listOf(SupportPageDto(pageId = "0x1", message = "help"))
val vm = viewModel()
val pages = vm.state.value.pages
assertTrue(pages is UiState.Success)
assertEquals("0x1", (pages as UiState.Success).data.first().pageId)
}
@Test fun respondRejectsBlankMessage() {
val vm = viewModel()
vm.respond(id = "0x1", message = " ", close = true)
assertEquals(R.string.admin_support_message_required, vm.state.value.feedback!!.messageRes)
assertEquals(null, api.lastRespond)
}
@Test fun respondTrimsMessageAndReloadsOnSuccess() {
api.supportPages = listOf(SupportPageDto(pageId = "0x1"))
val vm = viewModel()
vm.respond(id = "0x1", message = " on it ", close = false)
assertEquals("on it", api.lastRespond!!.second.message)
assertTrue(vm.state.value.feedback!!.ok)
}
@Test fun respondUnknownPageMapsTo404Copy() {
api.unitResponse = errorUnit(404)
val vm = viewModel()
vm.respond(id = "0xZ", message = "hi", close = false)
assertEquals(R.string.admin_support_unknown_page, vm.state.value.feedback!!.messageRes)
}
@Test fun closeShardOfflineMapsTo503Copy() {
api.unitResponse = errorUnit(503)
val vm = viewModel()
vm.close("0x1")
assertEquals(R.string.admin_mod_shard_offline, vm.state.value.feedback!!.messageRes)
}
}

View File

@@ -0,0 +1,67 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.contact
import com.runicgateway.app.data.api.dto.ContactResponse
import com.runicgateway.app.data.api.fake.FakePublicApi
import com.runicgateway.app.data.repository.ContactRepository
import com.runicgateway.app.ui.ErrorKind
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.httpError
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
class ContactViewModelTest {
@get:Rule val mainDispatcher = MainDispatcherRule()
private val api = FakePublicApi()
private fun viewModel() = ContactViewModel(ContactRepository(api))
@Test fun blankFieldsProduceValidationError() {
val vm = viewModel()
vm.onNameChange("Gwen")
vm.send() // email + message still blank
assertEquals(ContactViewModel.Result.ValidationError, vm.state.value.result)
assertEquals(null, api.lastContact) // never hit the API
}
@Test fun successfulSendClearsFieldsAndReportsSent() {
api.contactResponse = ContactResponse(sent = true)
val vm = viewModel()
vm.onNameChange(" Gwen ")
vm.onEmailChange(" g@x.c ")
vm.onMessageChange(" hello ")
vm.send()
assertEquals(ContactViewModel.Result.Sent, vm.state.value.result)
assertEquals("", vm.state.value.name) // fields cleared on success
// Repository trims before sending.
assertEquals("Gwen", api.lastContact!!.name)
assertEquals("g@x.c", api.lastContact!!.email)
}
@Test fun mailerFallbackSurfacesEmail() {
api.contactResponse = ContactResponse(sent = false, fallback = "mailto", email = "team@shard.gg")
val vm = viewModel()
vm.onNameChange("A"); vm.onEmailChange("a@b.c"); vm.onMessageChange("m")
vm.send()
val result = vm.state.value.result
assertTrue(result is ContactViewModel.Result.Fallback)
assertEquals("team@shard.gg", (result as ContactViewModel.Result.Fallback).email)
// Fields kept (not a clean send) so the user can retry.
assertEquals("A", vm.state.value.name)
}
@Test fun serverErrorSurfacesFailedWithKind() {
api.error = httpError(500)
val vm = viewModel()
vm.onNameChange("A"); vm.onEmailChange("a@b.c"); vm.onMessageChange("m")
vm.send()
val result = vm.state.value.result
assertTrue(result is ContactViewModel.Result.Failed)
assertEquals(ErrorKind.SERVER, (result as ContactViewModel.Result.Failed).kind)
}
}

View File

@@ -37,12 +37,16 @@ class MenuAccessTest {
assertTrue(visible.contains(Routes.HOME))
}
@Test fun staffSeeAccountButNoPlayerOnlyGroups() {
val visible = routes(signedIn(Role.EDITOR))
assertTrue(visible.contains(Routes.ACCOUNT))
// No PLAYER-access entry (the M4 game-data groups) leaks to staff.
val playerOnly = APP_MENU.filter { it.access == MenuAccess.PLAYER }.map { it.route }
assertTrue(playerOnly.none { visible.contains(it) })
@Test fun staffSeeThePlayerGameDataGroups() {
// Staff are a superset of players: every staff role sees the PLAYER-access
// game-data groups too (their own linked characters, via the role-agnostic
// /player self-service surface), on top of their staff entries.
val playerGroups = APP_MENU.filter { it.access == MenuAccess.PLAYER }.map { it.route }
for (role in listOf(Role.ADMIN, Role.EDITOR, Role.MODERATOR)) {
val visible = routes(signedIn(role))
assertTrue("$role should see Account", visible.contains(Routes.ACCOUNT))
assertTrue("$role should see the player game-data groups", playerGroups.all { visible.contains(it) })
}
}
@Test fun publicEntryCountIsStableAcrossSessions() {
@@ -58,10 +62,13 @@ class MenuAccessTest {
}
@Test fun playerAccessGatedFunction() {
// A synthetic PLAYER-gated entry is visible to a player, hidden from staff/anon.
// A PLAYER-gated entry is visible to a player AND to every staff role
// (staff superset), hidden only from an unrecognized role and anon.
val entries = listOf(MenuEntry("game", 0, MenuAccess.PLAYER))
assertTrue(visibleEntries(entries, signedIn(Role.PLAYER)).isNotEmpty())
assertTrue(visibleEntries(entries, signedIn(Role.ADMIN)).isEmpty())
for (role in listOf(Role.PLAYER, Role.ADMIN, Role.EDITOR, Role.MODERATOR)) {
assertTrue("$role should see a PLAYER entry", visibleEntries(entries, signedIn(role)).isNotEmpty())
}
assertTrue(visibleEntries(entries, signedIn(Role.UNKNOWN)).isEmpty())
assertTrue(visibleEntries(entries, Session.SignedOut).isEmpty())
}

View File

@@ -0,0 +1,108 @@
/*
* 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.ShardFeature
import com.runicgateway.app.data.repository.ShardFeatures
import com.runicgateway.app.ui.shard.ShardBoard
import com.runicgateway.app.ui.shard.visibleBoards
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The second gate on a shard entry (PLAN.md §5, §9 M11): the shard's admin-configured
* visibility, independent of the session role. A signed-in admin still doesn't see a
* board the shard doesn't publish, and an anonymous visitor still doesn't see a
* signed-in entry however wide the feature config is.
*/
class MenuFeatureGatingTest {
private fun signedIn(role: Role) =
Session.SignedIn(SessionUser(id = 1, username = "u", role = role))
private fun features(vararg visible: String) =
ShardFeatures(level = "anonymous", visible = visible.toSet())
private val shardEntry = MenuEntry("shard", 0, MenuAccess.PUBLIC, feature = ShardFeature.STATUS)
private val plainEntry = MenuEntry("news", 0, MenuAccess.PUBLIC)
@Test fun aShardEntryHidesWhenItsFeatureIsNotVisible() {
val entries = listOf(plainEntry, shardEntry)
val visible = visibleEntries(entries, Session.SignedOut, features("champs")).map { it.route }
assertEquals(listOf("news"), visible)
}
@Test fun aShardEntryShowsWhenItsFeatureIsVisible() {
val entries = listOf(plainEntry, shardEntry)
val visible = visibleEntries(entries, Session.SignedOut, features("status")).map { it.route }
assertEquals(listOf("news", "shard"), visible)
}
@Test fun unknownFeaturesShowEverythingTheRoleAllows() {
// Fail open while the lookup is in flight or has failed — the server gates
// regardless, so a link that briefly 403s beats a nav that flickers in.
val entries = listOf(plainEntry, shardEntry)
val visible = visibleEntries(entries, Session.SignedOut, features = null).map { it.route }
assertEquals(listOf("news", "shard"), visible)
}
@Test fun theTwoGatesAreIndependent() {
val staffShardEntry = MenuEntry("s", 0, MenuAccess.STAFF, feature = ShardFeature.HOUSES)
val entries = listOf(staffShardEntry)
// Right role, feature switched off → hidden.
assertTrue(visibleEntries(entries, signedIn(Role.ADMIN), features("champs")).isEmpty())
// Feature on, wrong role → hidden.
assertTrue(visibleEntries(entries, signedIn(Role.PLAYER), features("houses")).isEmpty())
// Both → shown.
assertFalse(visibleEntries(entries, signedIn(Role.ADMIN), features("houses")).isEmpty())
}
@Test fun anAdminDoesNotBypassAFeatureGate() {
// The rung the server placed the caller on is what /features already accounts
// for. A staff role is not a licence to render a link to a disabled feature —
// a disabled feature 404s for everyone.
val entries = listOf(shardEntry)
assertTrue(visibleEntries(entries, signedIn(Role.ADMIN), features()).isEmpty())
}
@Test fun everyShardMenuEntryDeclaresAFeature() {
// A shard-derived entry with no feature name silently skips the gate. The app
// menu's only such entry today is the Shard hub; this fails if one is added
// without one.
val shardRoutes = APP_MENU.filter { it.route == Routes.SHARD }
assertTrue(shardRoutes.isNotEmpty())
assertTrue(shardRoutes.all { it.feature != null })
}
// ── The hub's board tiles use the same gate ──────────────────────────
@Test fun hubBoardsAreFilteredByFeature() {
val visible = visibleBoards(features("champs", "houses"))
assertEquals(listOf(ShardBoard.CHAMPS, ShardBoard.HOUSES), visible)
}
@Test fun hubBoardsShowAllWhenTheAnswerIsUnknown() {
assertEquals(ShardBoard.entries.toList(), visibleBoards(null))
}
@Test fun eachBoardMapsToItsOwnFeature() {
assertEquals(ShardFeature.CHAMPS, ShardBoard.CHAMPS.feature)
assertEquals(ShardFeature.GUILDS, ShardBoard.GUILDS.feature)
assertEquals(ShardFeature.GOVERNORS, ShardBoard.GOVERNORS.feature)
assertEquals(ShardFeature.HOUSES, ShardBoard.HOUSES.feature)
}
}

View File

@@ -3,14 +3,18 @@
*/
package com.runicgateway.app.ui.player
import com.runicgateway.app.data.api.dto.CharPointsDto
import com.runicgateway.app.data.api.dto.CharProfileDto
import com.runicgateway.app.data.api.dto.EquipmentDto
import com.runicgateway.app.data.api.dto.TitlesDto
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* Unit tests for the character-sheet display helpers (PLAN.md §6.3), mirroring the
* website's `CharacterSheet.jsx#displayTitles`: fame/karma + skill + a *literal*
* selected reward title, dropping bare cliloc numbers the app can't resolve.
* website's `CharacterSheet.jsx`: title selection over the server's cliloc-resolved
* parallel array, item naming precedence, and the Protocol 3.0 points block.
*/
class CharacterSheetHelpersTest {
@@ -51,4 +55,100 @@ class CharacterSheetHelpersTest {
val titles = TitlesDto(selected = 0, reward = listOf("The Great"), fameKarma = "The Great")
assertEquals(listOf("The Great"), displayTitles(titles))
}
// ── Cliloc-resolved titles (Protocol 3.0 §8.6) ───────────────────────
@Test fun displayTitlesPrefersTheServerResolvedRewardName() {
// The website resolves the numeric entries against its own cliloc table and
// sends a parallel array; the raw number is no longer the only thing we have.
val titles = TitlesDto(
selected = 0,
reward = listOf("1049565"),
rewardResolved = listOf("Knight of Trinsic"),
)
assertEquals(listOf("Knight of Trinsic"), displayTitles(titles))
}
@Test fun displayTitlesKeepsSelectedAlignedWhenAnEntryDoesNotResolve() {
// rewardResolved is POSITIONAL. An entry the table had nothing for is null and
// must be skipped WITHOUT shifting `selected` onto its neighbour — otherwise
// the sheet confidently shows the wrong title.
val titles = TitlesDto(
selected = 1,
reward = listOf("1049565", "1049566"),
rewardResolved = listOf(null, "Knight of Trinsic"),
)
assertEquals(listOf("Knight of Trinsic"), displayTitles(titles))
}
@Test fun displayTitlesFallsBackWhenTheSelectedTitleDidNotResolve() {
val titles = TitlesDto(
selected = 0,
reward = listOf("1049565", "1049566"),
rewardResolved = listOf(null, "Bane of Dragons"),
)
assertEquals(listOf("Bane of Dragons"), displayTitles(titles))
}
@Test fun displayTitlesStillSkipsNumbersWhenNothingResolved() {
// A shard that configures no cliloc table sends no rewardResolved at all —
// the pre-3.0 behavior, unchanged.
val titles = TitlesDto(selected = 0, reward = listOf("1049565"), rewardResolved = emptyList())
assertEquals(emptyList<String>(), displayTitles(titles))
}
// ── Equipment names ──────────────────────────────────────────────────
@Test fun itemLabelPrefersAPlayerGivenNameOverTheResolvedTypeName() {
// "Bob's lucky axe" must not be relabelled "hatchet".
val item = EquipmentDto(layer = "OneHanded", name = "Bob's lucky axe", clilocName = "hatchet")
assertEquals("Bob's lucky axe", item.label)
}
@Test fun itemLabelFallsBackThroughClilocNameThenLayer() {
assertEquals("hatchet", EquipmentDto(layer = "OneHanded", clilocName = "hatchet").label)
assertEquals("OneHanded", EquipmentDto(layer = "OneHanded").label)
assertNull(EquipmentDto().label)
}
// ── Loyalty & points (Protocol 3.0 §7.3) ─────────────────────────────
@Test fun pointsLabelUsesTheHumanisedKeyWhenTheNameIsACliloc() {
// The PRIMARY path on a real shard: most systems name themselves with a
// cliloc, so nameString comes back null.
assertEquals("Queens Loyalty", pointsLabel(CharPointsDto(system = "QueensLoyalty")))
assertEquals("Clean Up Britannia", pointsLabel(CharPointsDto(system = "CleanUpBritannia")))
assertEquals("Void Pool", pointsLabel(CharPointsDto(system = "VoidPool")))
}
@Test fun pointsLabelPrefersTheShardsOwnNameWhenItHasOne() {
val entry = CharPointsDto(system = "QueensLoyalty", nameString = "Queen's Loyalty")
assertEquals("Queen's Loyalty", pointsLabel(entry))
}
@Test fun anUncappedSystemReportsNoCap() {
// maxPoints 0 means UNCAPPED and is the common case — three of five live
// boards on a real shard. Nothing may divide by it.
assertNull(CharPointsDto(points = 900, maxPoints = 0).cap)
assertNull(CharPointsDto(points = 900, maxPoints = null).cap)
assertEquals(30000L, CharPointsDto(points = 900, maxPoints = 30000).cap)
}
@Test fun displayPointsDropsZeroesAndSortsByStandingDescending() {
val char = CharProfileDto(
points = listOf(
CharPointsDto(system = "A", points = 10),
CharPointsDto(system = "Zero", points = 0),
CharPointsDto(system = "B", points = 500),
CharPointsDto(system = "Null", points = null),
),
)
assertEquals(listOf("B", "A"), displayPoints(char).map { it.system })
}
@Test fun displayPointsIsEmptyForAProfileWithNoPointsBlock() {
// A pre-3.0 shard plugin sends none, and a new character has earned nothing —
// both render as nothing at all rather than an empty card.
assertEquals(emptyList<CharPointsDto>(), displayPoints(CharProfileDto()))
}
}

View File

@@ -0,0 +1,89 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.player
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.RosterCharDto
import com.runicgateway.app.data.api.dto.RosterDto
import com.runicgateway.app.data.api.dto.SettingsDto
import com.runicgateway.app.data.api.dto.ShardLinkDto
import com.runicgateway.app.data.api.dto.ShardLinkResultDto
import com.runicgateway.app.data.api.fake.FakePlayerShardApi
import com.runicgateway.app.data.api.fake.FakePublicApi
import com.runicgateway.app.data.repository.PlayerShardRepository
import com.runicgateway.app.data.repository.SettingsRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.httpError
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
class CharactersViewModelTest {
@get:Rule val mainDispatcher = MainDispatcherRule()
private val playerApi = FakePlayerShardApi()
private val publicApi = FakePublicApi()
private fun viewModel() = CharactersViewModel(
PlayerShardRepository(playerApi),
SettingsRepository(publicApi),
)
@Test fun loadsAccountsRostersAndSignupFlag() {
playerApi.accounts = listOf(ShardLinkDto(account = "acct1"))
playerApi.roster = RosterDto(acct = "acct1", chars = listOf(RosterCharDto(serial = "0x24C", name = "Gwen")))
publicApi.settings = SettingsDto(gameAccountSignup = true)
val vm = viewModel()
assertEquals(listOf("acct1"), (vm.state.value.accounts as UiState.Success).data)
val roster = vm.state.value.rosters["acct1"]
assertTrue(roster is UiState.Success)
assertEquals("Gwen", (roster as UiState.Success).data.first().name)
assertTrue(vm.state.value.signupEnabled)
}
@Test fun accountsErrorSurfacesError() {
playerApi.error = httpError(503)
assertTrue(viewModel().state.value.accounts is UiState.Error)
}
@Test fun linkBlankCodeIsIgnored() {
val vm = viewModel()
vm.link(" ")
assertEquals(null, vm.state.value.feedback)
}
@Test fun linkSuccessShowsOkAndReloads() {
playerApi.linkResult = ShardLinkResultDto(linked = true, account = "acct1")
val vm = viewModel()
vm.link("CODE1")
val fb = vm.state.value.feedback!!
assertTrue(fb.ok)
assertEquals(R.string.player_link_ok, fb.messageRes)
assertFalse(vm.state.value.busy)
}
@Test fun linkBadCodeMapsTo400Copy() {
playerApi.error = httpError(400)
val vm = viewModel()
vm.link("BAD")
assertEquals(R.string.player_link_bad_code, vm.state.value.feedback!!.messageRes)
}
@Test fun createAccountRejectsShortPasswordWithoutApiCall() {
val vm = viewModel()
vm.createAccount(account = "acct1", password = "short") // < 8 chars
assertEquals(null, vm.state.value.feedback) // guarded before any call/feedback
}
@Test fun createAccountTakenMapsTo409Copy() {
playerApi.error = httpError(409)
val vm = viewModel()
vm.createAccount(account = "acct1", password = "longenough")
assertEquals(R.string.player_create_taken, vm.state.value.feedback!!.messageRes)
}
}

View File

@@ -0,0 +1,83 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.player
import androidx.lifecycle.SavedStateHandle
import com.runicgateway.app.data.api.dto.CharProfileDto
import com.runicgateway.app.data.api.dto.PlayerHouseDto
import com.runicgateway.app.data.api.dto.ShardLinkDto
import com.runicgateway.app.data.api.dto.VendorDto
import com.runicgateway.app.data.api.dto.VendorSaleDto
import com.runicgateway.app.data.api.dto.VendorSnapshotDto
import com.runicgateway.app.data.api.fake.FakePlayerShardApi
import com.runicgateway.app.data.repository.PlayerShardRepository
import com.runicgateway.app.ui.ErrorKind
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.navigation.Routes
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.httpError
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
/** ViewModels over the player self-service game-data API (own chars, vendors, houses). */
class PlayerViewModelTest {
@get:Rule val mainDispatcher = MainDispatcherRule()
private val api = FakePlayerShardApi()
private val repository = PlayerShardRepository(api)
// ── My houses ───────────────────────────────────────────────────────────
@Test fun myHousesLoadsOwnHouses() {
api.houses = listOf(PlayerHouseDto(serial = "0x40001", name = "Keep", isIdoc = false))
val vm = MyHousesViewModel(repository)
assertEquals("Keep", (vm.state.value as UiState.Success).data.first().name)
}
@Test fun myHousesShardOfflineIsShardOfflineError() {
api.error = httpError(503)
val state = MyHousesViewModel(repository).state.value
assertEquals(ErrorKind.SHARD_OFFLINE, (state as UiState.Error).kind)
}
// ── Character sheet (SavedStateHandle serial) ─────────────────────────────
@Test fun characterLoadsBySerial() {
api.char = CharProfileDto(serial = "0x24C", name = "Gwen", online = true)
val vm = CharacterViewModel(repository, SavedStateHandle(mapOf(Routes.Args.SERIAL to "0x24C")))
assertEquals("Gwen", (vm.state.value as UiState.Success).data.name)
}
@Test fun characterForbiddenIsNotFound() {
api.error = httpError(403)
val vm = CharacterViewModel(repository, SavedStateHandle(mapOf(Routes.Args.SERIAL to "0x1")))
// 403 isn't a mapped status -> SERVER bucket (only 404/429/503 are special-cased).
assertTrue(vm.state.value is UiState.Error)
}
// ── Vendors (per-account snapshots + sales) ───────────────────────────────
@Test fun vendorsLoadsAccountsThenPerAccountSnapshots() {
api.accounts = listOf(ShardLinkDto(account = "acct1"))
api.vendors = VendorSnapshotDto(acct = "acct1", vendors = listOf(VendorDto(serial = "0x9", shopName = "Wares")))
api.sales = listOf(VendorSaleDto(itemType = "sword", price = 100))
val vm = VendorsViewModel(repository)
val accounts = vm.state.value.accounts
assertTrue(accounts is UiState.Success)
assertEquals(listOf("acct1"), (accounts as UiState.Success).data)
// Each account's vendors loaded into the per-account map.
val perAccount = vm.state.value.vendors["acct1"]
assertTrue(perAccount is UiState.Success)
assertEquals("Wares", (perAccount as UiState.Success).data.first().shopName)
assertTrue(vm.state.value.sales is UiState.Success)
}
@Test fun vendorsAccountsErrorSurfacesError() {
api.error = java.io.IOException("offline")
val vm = VendorsViewModel(repository)
assertTrue(vm.state.value.accounts is UiState.Error)
assertTrue(vm.state.value.sales is UiState.Error)
}
}

View File

@@ -0,0 +1,44 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.shard
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* [FrameFields] does minimal typed reads from a raw SSE frame object for the
* fields a `*.remove` / `*.decay` delta needs; it must return null (not crash) on
* a missing or ill-typed field so a malformed frame is skipped.
*/
class FrameFieldsTest {
@Test fun longFieldParsesNumericPrimitive() {
val obj = buildJsonObject { put("serial", "12345") }
assertEquals(12345L, FrameFields.longField(obj, "serial"))
}
@Test fun longFieldReturnsNullOnMissingOrNonNumeric() {
val obj = buildJsonObject { put("serial", "0xNaN") }
assertNull(FrameFields.longField(obj, "serial")) // not a Long
assertNull(FrameFields.longField(obj, "absent")) // missing key
}
@Test fun longFieldReturnsNullOnJsonNullOrObject() {
val obj = JsonObject(mapOf("a" to JsonNull, "b" to JsonObject(emptyMap())))
assertNull(FrameFields.longField(obj, "a"))
assertNull(FrameFields.longField(obj, "b"))
}
@Test fun stringFieldReadsPrimitiveContent() {
val obj = JsonObject(mapOf("kind" to JsonPrimitive("champ.remove")))
assertEquals("champ.remove", FrameFields.stringField(obj, "kind"))
assertNull(FrameFields.stringField(obj, "missing"))
}
}

View File

@@ -0,0 +1,128 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.shard
import com.runicgateway.app.core.net.ShardStreamEvent
import com.runicgateway.app.data.api.dto.ChampDto
import com.runicgateway.app.data.api.dto.GovernorDto
import com.runicgateway.app.data.api.dto.GuildDto
import com.runicgateway.app.data.api.dto.HouseDto
import com.runicgateway.app.data.api.dto.PresenceDto
import com.runicgateway.app.data.api.dto.ShardStatusDto
import com.runicgateway.app.data.api.fake.FakePublicApi
import com.runicgateway.app.data.api.fake.FakeShardStream
import com.runicgateway.app.data.repository.ShardFeaturesRepository
import com.runicgateway.app.data.repository.ShardRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.httpError
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
/**
* The live-board ViewModels (§6.2): a snapshot load kept live by merging SSE
* frames. Tests use a [FakeShardStream] that emits a fixed script and completes,
* so both the snapshot path and the frame-merge path are covered deterministically.
*/
class ShardBoardViewModelTest {
@get:Rule val mainDispatcher = MainDispatcherRule()
private val api = FakePublicApi()
private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; coerceInputValues = true }
private fun repo(stream: FakeShardStream = FakeShardStream()) = ShardRepository(api, stream, json)
// The hub reads the feature set only to filter its board tiles; these tests
// exercise loading, so the answer stays at its "unknown" default (show all).
private fun features() = ShardFeaturesRepository(api)
// ── Champs: snapshot + live upsert/remove ─────────────────────────────
@Test fun champsSeedsSnapshotAndMergesLiveFrames() {
api.champs = listOf(ChampDto(serial = "0x1", category = "champion", name = "Rikktor"))
val stream = FakeShardStream(
listOf(
ShardStreamEvent.Open,
ShardStreamEvent.Frame(
"champ.update",
buildJsonObject { put("serial", "0x2"); put("category", "mini"); put("name", "Barracoon") },
),
ShardStreamEvent.Frame("champ.remove", buildJsonObject { put("serial", "0x1") }),
),
)
val vm = ChampsViewModel(repo(stream))
val rows = (vm.state.value as UiState.Success).data
// 0x1 removed, 0x2 upserted.
assertEquals(listOf("0x2"), rows.map { it.serial })
assertTrue(vm.connected.value) // Open was seen
}
@Test fun champsServerErrorIsUiError() {
api.error = httpError(503)
assertTrue(ChampsViewModel(repo()).state.value is UiState.Error)
}
// ── Guilds ────────────────────────────────────────────────────────────
@Test fun guildsSnapshotThenLiveUpdate() {
api.guilds = listOf(GuildDto(id = 1, name = "Knights"))
val stream = FakeShardStream(
listOf(ShardStreamEvent.Frame("guild.update", buildJsonObject { put("id", 2); put("name", "Mages") })),
)
val vm = GuildsViewModel(repo(stream))
val names = (vm.state.value as UiState.Success).data.map { it.name }
assertTrue(names.contains("Knights"))
assertTrue(names.contains("Mages"))
}
// ── Governors (+ history) ─────────────────────────────────────────────
@Test fun governorsSnapshotAndHistory() {
api.governors = listOf(GovernorDto(city = "Britain"))
api.governorHistory = listOf() // empty is fine
val vm = GovernorsViewModel(repo())
assertTrue(vm.state.value is UiState.Success)
vm.loadHistory("Britain")
assertTrue(vm.history.value.containsKey("Britain"))
}
// ── Houses (IDOC board) ───────────────────────────────────────────────
@Test fun housesSnapshotLoads() {
api.houses = listOf(HouseDto(serial = "0x40", name = "Tower", isIdoc = true))
val vm = HousesViewModel(repo())
assertEquals("Tower", (vm.state.value as UiState.Success).data.first().name)
}
@Test fun housesNetworkErrorIsUiError() {
api.error = java.io.IOException("offline")
assertTrue(HousesViewModel(repo()).state.value is UiState.Error)
}
// ── Shard hub (status primary, presence/online best-effort, live feed) ─
@Test fun shardHubLoadsStatusPresenceOnlineAndSeedsFeed() {
api.shardStatus = ShardStatusDto(enabled = true, pluginConnected = true, onlineCount = 5)
api.shardPresence = PresenceDto(count = 5)
val stream = FakeShardStream(
listOf(
ShardStreamEvent.Closed,
ShardStreamEvent.Frame("presence.online", buildJsonObject { put("count", 9) }),
),
)
val vm = ShardViewModel(repo(stream), features())
val hub = (vm.state.value as UiState.Success).data
assertTrue(hub.status.isOnline)
// presence.online frame patched the count in place.
assertEquals(9, hub.presence!!.count)
assertFalse(vm.connected.value) // last lifecycle event was Closed
}
@Test fun shardHubStatusErrorIsUiError() {
api.error = httpError(503)
assertTrue(ShardViewModel(repo(), features()).state.value is UiState.Error)
}
}

View File

@@ -0,0 +1,27 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.util
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.ResponseBody.Companion.toResponseBody
import retrofit2.HttpException
import retrofit2.Response
/**
* Test doubles emulate Retrofit's error contract: a suspend API method throws
* [HttpException] on a non-2xx and an [java.io.IOException] on a transport failure,
* exactly what `safeApiCall` folds into `ApiResult.HttpError` / `NetworkError`.
*/
private val JSON = "application/json".toMediaTypeOrNull()
/** An [HttpException] carrying [code] — what a fake API throws to drive an HTTP-error path. */
fun httpError(code: Int): HttpException =
HttpException(Response.error<Any>(code, "{}".toResponseBody(JSON)))
/** A successful bodyless [Response] (for `@DELETE`/moderation endpoints returning `Response<Unit>`). */
fun okUnit(): Response<Unit> = Response.success(Unit)
/** A non-2xx bodyless [Response] with [code]. */
fun errorUnit(code: Int): Response<Unit> = Response.error(code, "{}".toResponseBody(JSON))

View File

@@ -0,0 +1,31 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.util
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.rules.TestWatcher
import org.junit.runner.Description
/**
* Swaps `Dispatchers.Main` (which `viewModelScope` dispatches on) for a test
* dispatcher for the duration of a test, so ViewModel coroutines run on the test
* scheduler instead of a real Android main looper.
*
* Defaults to an [UnconfinedTestDispatcher] so work launched from a ViewModel's
* `init {}` runs eagerly to its first real suspension — with fakes that never
* truly suspend, that means the final state is settled by the time the constructor
* returns, and a test can assert `state.value` directly without advancing time.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(
val dispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
override fun starting(description: Description) = Dispatchers.setMain(dispatcher)
override fun finished(description: Description) = Dispatchers.resetMain()
}

View File

@@ -20,13 +20,40 @@ sonar.exclusions=**/build/**,**/.gradle/**,**/generated/**
sonar.sourceEncoding=UTF-8
# ── Optional enrichment (enable once the reports are produced in CI) ──
# For richer Kotlin/Android results, run the reporters in sonarqube.yml and point
# SonarQube at their output:
# • Android Lint: ./gradlew lintDebug → app/build/reports/lint-results-debug.xml
# ── Coverage (JaCoCo) ──
# sonarqube.yml runs `./gradlew testDebugUnitTest jacocoTestReport` before the scan;
# that task (app/build.gradle.kts) writes this XML. Without it, Sonar reports 0%
# coverage even though the JVM unit suite (app/src/test) exists.
sonar.coverage.jacoco.xmlReportPaths=app/build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml
# Exclude from *coverage* (not from analysis — bugs/smells are still reported): code a
# JVM unit test physically can't execute, so counting its lines would unfairly sink
# new-code coverage. Two kinds: pure-@Composable UI (needs Robolectric/instrumented
# tests), and Android-framework glue (Keystore-backed stores, foreground push service,
# notifications, Hilt modules). Testable logic — ViewModels, repositories, DTOs, and
# pure core/ code — stays measured. See docs/android/COVERAGE_PLAN.md §1.
sonar.coverage.exclusions=\
app/src/main/java/**/ui/**/*Screen.kt,\
app/src/main/java/**/ui/**/*Screen*.kt,\
app/src/main/java/**/ui/theme/**,\
app/src/main/java/**/ui/components/**,\
app/src/main/java/**/ui/page/BlockRenderer.kt,\
app/src/main/java/**/ui/shard/ShardComponents.kt,\
app/src/main/java/**/ui/LocalAssetResolver.kt,\
app/src/main/java/**/RunicApp.kt,\
app/src/main/java/**/MainActivity.kt,\
app/src/main/java/**/*Application.kt,\
app/src/main/java/**/RunicGatewayApp.kt,\
app/src/main/java/**/di/**,\
app/src/main/java/**/core/push/PushService.kt,\
app/src/main/java/**/core/push/PushManager.kt,\
app/src/main/java/**/core/push/PushNotifier.kt,\
app/src/main/java/**/core/push/NtfyStreamClient.kt,\
app/src/main/java/**/core/auth/Encrypted*.kt
# ── Optional enrichment (enable once produced in CI) ──
# • Android Lint: ./gradlew lintDebug → app/build/reports/lint-results-debug.xml
# sonar.androidLint.reportPaths=app/build/reports/lint-results-debug.xml
# • JaCoCo coverage (needs a coverage-enabled test run):
# sonar.coverage.jacoco.xmlReportPaths=app/build/reports/jacoco/.../*.xml
# The alternative to the CLI scanner used here is the SonarQube Gradle plugin
# (org.sonarqube), which auto-discovers these reports; the CLI + properties file
# is used instead to keep this repo's setup identical to website/ and link/.